Drive Signing Desk from your own code
A preparation aid for a legal workflow, not legal advice. Nothing is ever sent for
signature: the reply is a packet you load into your own e-signature tool. The agreement text
travels in document (for long agreements the middle is cut and marked; the opening and
the signature blocks and exhibits are always kept), so send only what you are allowed to share.
Everything the web page does is available over HTTP. Send a finalized agreement with its signers,
approvers and copy recipients, as the browser reads them, and get the same signing packet back: a
status that says whether the document can go out (ready_to_send,
confirm_then_send, resolve_first), the seven-item pre-signature
checklist, the issues to fix, the signing order with each person's exact email, the fields each
signer completes, the copy list, a draft envelope subject and message, and next steps. The natural
use is a contracts-team routine: before an agreement goes into your e-signature tool, run it through
here and hold the envelope until the status is ready_to_send.
The model does not do the reading alone. Party names in the preamble against the
signature blocks, referenced against attached exhibits, unfilled blanks, drafting notes, blocks
without a name or title line, the recipients and their sides, and the proposed signing order are
all worked out by signkit.js, the same file the web page loads, and sent as
facts, a JSON string. See building the body.
One lane: the task field
Every request names its lane in task. Signing Desk has exactly one.
| task | what it does | what it needs |
|---|---|---|
prepare | The pre-signature check and signing packet (signature-request): status, headline, summary, the seven-item checklist, issues, routing and routing_reason, the signing order, cc, fields, envelope, next_steps and one prescan_responses entry per browser flag. | document_title, document (the agreement text) and facts (the browser's analysis, as a JSON string). |
Any other value, or a missing task, is still answered as prepare and the
reply's lane is "prepare". Always send "task": "prepare";
the page's own guard refuses a body without a task.
Input fields
The body is one flat JSON object. Every value is a string.
| field | type | required | what it holds |
|---|---|---|---|
task | string | yes | Always "prepare". |
document_title | string | yes | What you call the agreement; "Untitled agreement" when blank. |
document | string | yes | The agreement text. Up to 48,000 characters are sent; beyond that the middle is dropped and replaced by a marker saying how many characters were not sent. The opening (parties) and the end (signature blocks, exhibits) are always included. |
facts | string | yes | A JSON string (the output of JSON.stringify), never an object, built by signkit.js. Its keys are listed below. |
question | string | no | Your own question, answered inside summary. Omitted when empty; cut at 1,500 characters. |
retry_note | string | no | Only on a reformat retry, after a reply that could not be parsed: say what was wrong. Never on a first run. |
The keys inside facts:
| key | what it holds |
|---|---|
document_type_guess, pages, words, source | MSA, NDA, SOW, Amendment, Order Form, Lease and so on (or Other); an estimated page count; the word count; text or docx. |
parties, your_company | Parties found in the opening paragraph: id (P1..), name, signature_block_name, ours; and the company you entered as your side. |
signature_blocks | One per signature line: line, entity, printed_name, printed_title, has_name_line, has_title_line, has_date_line. |
exhibits | referenced and attached exhibit and schedule names. |
effective_date | date, as_written, left_blank, last_signature_rule, or null. |
tracked_changes, comments | Counts from a .docx read in the page; null for pasted text. |
initials_requested, counterparts_clause, esignature_clause | Booleans read from the text. |
attested | final_agreed_version and counsel_reviewed: what you confirmed. |
signers, approvers, cc | Recipients with ids S1.., A1.., C1.., each with name and email; signers also carry title, party and side (ours, theirs, unknown). A recipient with a problem (for example no email) has a problems list. |
routing_mode, proposed_order | counterparty_first, we_first or parallel, and the browser's signing order in the same shape as the reply's signing. |
browser_status, checklist | The browser's status hint, and each checklist item's id, browser_status and flags. |
flags, flags_not_listed | Up to 40 flags: id (F1..), severity (high, medium, low), category, checklist_item, message; and how many more were raised but not listed. |
prepared_on | The date you entered (YYYY-MM-DD), or null. |
Building the body
Do not hand-assemble facts. Load signkit.js (it runs unchanged in Node
via require()) and give analyze the same fields the page's form has;
buildInput then clips the document and the question and serializes the facts:
| set field | what it holds |
|---|---|
title | What you call the agreement. |
ours | Your company's legal name, as the agreement writes it. |
routing | counterparty_first (default), we_first or parallel. |
prepared | Today, YYYY-MM-DD. |
doc | The agreement text. |
signers | One per line: name, email, title, party. |
approvers, cc | One per line: name, email. |
attest_final, attest_counsel | Booleans: this is the final agreed version; counsel has reviewed it. |
// make-body.js - build the run body with the SAME engine the web page uses.
// Save signkit.js from https://signing-desk.skillsafe.ai/signkit.js next to this file.
// Usage: node make-body.js agreement.txt
const fs = require("fs");
const K = require("./signkit.js");
const A = K.analyze({
title: "Harborline x Northwind master services agreement",
ours: "Harborline Analytics, Inc.",
routing: "counterparty_first", // counterparty_first, we_first or parallel
prepared: "2026-09-26", // YYYY-MM-DD
doc: fs.readFileSync(process.argv[2] || "agreement.txt", "utf8"),
signers: [ // name, email, title, party
"Dana Whitfield, dana.whitfield@harborline.example, Chief Financial Officer, Harborline Analytics, Inc.",
"Marcus Oyelaran, m.oyelaran@northwindfreight.example, VP Operations, Northwind Freight Systems LLC"
].join("\n"),
approvers: "Priya Raman, priya.raman@harborline.example",
cc: "Contracts Desk, contracts@harborline.example\nLegal Notices, legal@northwindfreight.example",
attest_final: true,
attest_counsel: true
});
const body = K.buildInput(A, { question: "" }); // a non-empty question is answered in summary
if (!body) throw new Error("the agreement text is empty");
fs.writeFileSync("body.json", JSON.stringify(K.mustBeObject(body)));
console.log(A.flags.length, "flags; browser status", A.hint);
console.log("Idempotency-Key: signing-desk:prepare:" + K.hashInput(body) + ":a1");
Run on the Harborline and Northwind example agreement from the page, this reproduces the worked
request below byte for byte (its hash is 1rlwbua1t45hw4, the key the page's saved
example run is filed under). mustBeObject is the page's own guard: it throws unless
the body is an object of scalars with a task.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}
The token is minted for this app (the guest endpoint takes {"slug":"signing-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ….
The input object IS the request body. There is no {"input": …}
wrapper. A wrapped body is answered with an unknown field 'input' warning, and the
model never sees your agreement.
Error codes
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser
already holds, with Copy token and Copy shell export buttons, and
a sign-in button for a personal token. A guest token, minted with
POST /guest and {"slug":"signing-desk"}, can call /me and
/estimate; the run is metered, so /run and /run-stream need
a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://signing-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"signing-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://signing-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "signing-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://signing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "signing-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://signing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"signing-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://signing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"signing-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://signing-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "signing-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://signing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "signing-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://signing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"signing-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
2. A tiny client
One helper that sends the token, unwraps data and raises on ok: false.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="signing-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://signing-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "signing-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://signing-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "signing-desk";
// Paste the token from https://signing-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "signing-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://signing-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class SigningDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "signing-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "signing-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://signing-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "signing-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class SigningDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "signing-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await SigningDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. Expect model_alias gpt-terra and
markup_bps 1000. hold_credits is what the run
reserves, not the price; the charged_credits reported after the run
is usually far lower. The body is the input object itself, with no {"input": …}
wrapper. /estimate does not validate the body, so check the shape yourself: an object
whose every value is a string, task equal to prepare,
document_title, document and facts non-empty, and
facts a JSON string that parses to an object.
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task")=="prepare" and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("document_title","document","facts")) and isinstance(json.loads(b["facts"]),dict)'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
# "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is usually far lower.
INPUT = json.load(open("body.json")) # built by make-body.js above
assert isinstance(INPUT, dict) and INPUT.get("task") == "prepare"
assert all(isinstance(v, str) for v in INPUT.values())
assert all(INPUT.get(k, "").strip() for k in ("document_title", "document", "facts"))
assert isinstance(json.loads(INPUT["facts"]), dict) # facts is a JSON STRING
est = call("estimate", INPUT)
print(est["model_alias"], est["markup_bps"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // built by make-body.js above
if (!INPUT || typeof INPUT !== "object" || INPUT.task !== "prepare") throw new Error("task must be prepare");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
for (const k of ["document_title", "document", "facts"]) if (!INPUT[k]) throw new Error(k + " is required");
JSON.parse(INPUT.facts); // throws unless facts is a JSON string
const est = await call("estimate", INPUT);
console.log(est.model_alias, est.markup_bps, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // built by make-body.js above
var input map[string]string // every field is a string, facts included
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
if input["task"] != "prepare" {
panic("task must be prepare")
}
for _, k := range []string{"document_title", "document", "facts"} {
if strings.TrimSpace(input[k]) == "" {
panic(k + " is required")
}
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string holding an object")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.js above
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"prepare\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task prepare");
for (String k : new String[] {"document_title", "document", "facts"})
if (!input.contains("\"" + k + "\"")) throw new IllegalStateException(k + " is required");
String est = call("estimate", input);
System.out.println(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.js above
raise "task must be prepare" unless INPUT["task"] == "prepare"
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
%w[document_title document facts].each { |k| raise "#{k} is required" if INPUT[k].to_s.strip.empty? }
raise "facts must hold an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
est = call("estimate", INPUT)
puts est["model_alias"], est["markup_bps"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.js above
if (!is_array($input) || ($input["task"] ?? "") !== "prepare") { throw new Exception("task must be prepare"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
foreach (["document_title", "document", "facts"] as $k) { if (trim($input[$k] ?? "") === "") { throw new Exception("$k is required"); } }
if (!is_array(json_decode($input["facts"], true))) { throw new Exception("facts must be a JSON string"); }
$est = call("estimate", $input);
echo $est["model_alias"], " ", $est["markup_bps"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.js above
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
if (root.GetProperty("task").GetString() != "prepare") throw new Exception("task must be prepare");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception($"{p.Name} must be a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // facts is a JSON string
var est = await SigningDesk.Call("estimate", root);
Console.WriteLine(est); // model_alias gpt-terra, markup_bps 1000, hold_credits, min_credits
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until it is
terminal. The reply is a string at data.output.output. Send an
Idempotency-Key built from the lane, a hash of the input and the attempt number,
signing-desk:prepare:<hash>:a<attempt>, so a retried request returns the
same job instead of billing a second run. The page uses SignKit.hashInput(body) for
the hash (make-body.js prints that key); any stable digest of the body works from
other languages. Leave retry_note out of the hash and bump the attempt instead.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="signing-desk:prepare:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"lane\":\"prepare\",\"document\":{...},\"status\":\"resolve_first\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"signing-desk:prepare:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `signing-desk:prepare:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("signing-desk:prepare:%x:a1", sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "signing-desk:prepare:" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "signing-desk:prepare:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "signing-desk:prepare:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = "signing-desk:prepare:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await SigningDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
6. Or stream it
POST /run-stream takes the same body and headers and answers with server-sent events:
job (the job id), delta (chunks of the reply) and done (the
status, charged_credits, truncated and, when present, the full
output). A browser page may receive only tick heartbeats and then
done, never a delta, so take the reply from done.output.output
when it is there, fall back to the concatenated deltas, and fall back again to
GET /jobs/{id}.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"lane\":\"prepare\",\"document\":{\"title\":\"Harborline x Northwind"}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
The reply is one JSON object in data.output.output. The model is told to send no code
fences, but tolerate them: strip a leading ```json and a trailing
```, keep everything from the first { to the last }, and
JSON.parse that outer object.
# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json, re
t = open("reply.json").read().strip()
t = re.sub(r"^```(?:json)?\s*", "", t, flags=re.I)
t = re.sub(r"\s*```\s*$", "", t)
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r["status"], "-", r["headline"])
for c in r["checklist"]:
print(c["id"], c["status"], "|", c["note"])
for i in r["issues"]:
print(i["ref"], i["severity"], "|", i["issue"], "->", i["fix"])
for s in sorted(r["signing"], key=lambda s: s["order"]):
print(s["order"], s["action"], s["name"], s["email"], "|", s["role"])
print(r["envelope"]["subject"])
EOF
import re
t = re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=re.I)
t = re.sub(r"\s*```\s*$", "", t)
reply = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(reply["status"], reply["headline"])
print([(i["ref"], i["severity"]) for i in reply["issues"]])
print([(s["order"], s["action"], s["name"]) for s in sorted(reply["signing"], key=lambda s: s["order"])])
let t = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");
const reply = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
console.log(reply.status, reply.headline);
console.log(reply.issues.map((i) => [i.ref, i.severity]));
console.log([...reply.signing].sort((a, b) => a.order - b.order).map((s) => [s.order, s.action, s.name]));
t := strings.TrimSpace(jobOutput)
t = strings.TrimPrefix(strings.TrimPrefix(t, "```json"), "```")
t = strings.TrimSuffix(strings.TrimSpace(t), "```")
start, end := strings.Index(t, "{"), strings.LastIndex(t, "}")
var reply struct {
Status string `json:"status"`
Headline string `json:"headline"`
Issues []struct {
Ref, Severity, Issue, Fix string
} `json:"issues"`
Signing []struct {
Order int
Ref, Name, Email, Action string
} `json:"signing"`
}
if err := json.Unmarshal([]byte(t[start:end+1]), &reply); err != nil {
panic(err)
}
fmt.Println(reply.Status, reply.Headline)
for _, s := range reply.Signing {
fmt.Println(s.Order, s.Action, s.Name, s.Email)
}
// output is data.output.output from step 5: a string holding the reply JSON.
String t = output.strip().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("\\s*```\\s*$", "");
String json = t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1);
// With Jackson: JsonNode r = new ObjectMapper().readTree(json);
// r.get("status"): ready_to_send | confirm_then_send | resolve_first
// r.get("issues"), r.get("signing") (sort by "order"), r.get("fields"), r.get("envelope")
System.out.println(json);
t = text.strip.sub(/\A```(?:json)?\s*/i, "").sub(/\s*```\s*\z/, "")
reply = JSON.parse(t[t.index("{")..t.rindex("}")])
puts reply["status"], reply["headline"]
reply["issues"].each { |i| puts "#{i['ref']} #{i['severity']} #{i['issue']}" }
reply["signing"].sort_by { |s| s["order"] }.each { |s| puts "#{s['order']} #{s['action']} #{s['name']} #{s['email']}" }
<?php
$t = preg_replace('/\s*```\s*$/', "", preg_replace('/^```(?:json)?\s*/i', "", trim($text)));
$reply = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
echo $reply["status"], " ", $reply["headline"], PHP_EOL;
foreach ($reply["issues"] as $i) { echo $i["ref"], " ", $i["severity"], " ", $i["issue"], PHP_EOL; }
usort($reply["signing"], fn($a, $b) => $a["order"] <=> $b["order"]);
foreach ($reply["signing"] as $s) { echo $s["order"], " ", $s["action"], " ", $s["name"], " ", $s["email"], PHP_EOL; }
var t = System.Text.RegularExpressions.Regex.Replace(output.Trim(), @"^```(?:json)?\s*", "");
t = System.Text.RegularExpressions.Regex.Replace(t, @"\s*```\s*$", "");
var json2 = t.Substring(t.IndexOf('{'), t.LastIndexOf('}') - t.IndexOf('{') + 1);
using var parsed = JsonDocument.Parse(json2);
var r = parsed.RootElement;
Console.WriteLine($"{r.GetProperty("status")} {r.GetProperty("headline")}");
foreach (var s in r.GetProperty("signing").EnumerateArray())
Console.WriteLine($"{s.GetProperty("order")} {s.GetProperty("action")} {s.GetProperty("name")} {s.GetProperty("email")}");
Invariants worth asserting
The web page holds every reply to the browser's facts before it shows it (recon.js,
reconcile()). Do the same before you load a packet into an e-signature tool:
- Flags: every flag in
facts.flagshas exactly oneprescan_responsesentry; no response names a flag that was not sent; adismissedflag carries a reason (read it and decide whether you agree). - Status: one of
ready_to_send,confirm_then_send,resolve_first, and never looser than the flags left standing (those not dismissed): anyhighmeansresolve_first, else anymediummeans at leastconfirm_then_send. - Issues: every standing
highormediumflag appears in someissues[].ref. - Checklist: all seven ids are present;
ready_to_sendnever comes with a checklist item markedissue; an item the browser markedissueis notpassunless its high flags were dismissed. - References: every ref in
issues,checklist[].refsandfields(F#,S#,A#,C#,P#) exists infacts. - Signing order: every signer and approver appears, and no one else; each email is exactly the one you entered (case aside); approvers have
"action": "approve", signers"sign"; every approver is ordered before every signer.counterparty_first: everytheirssigner before everyourssigner;we_first: the reverse;parallel: all signers share oneorderandroutingis"parallel". - CC: every copy recipient is in
cc(by ref or email). - No invented people: every email address anywhere in
signing,cc,envelope,next_steps,issuesandsummaryis one you entered or one written in the agreement; every name indocument.partiesappears in the agreement text as written.
# assert-reply.py - the core checks, for body.json and reply.json from the steps above.
import json, re
body = json.load(open("body.json")); facts = json.loads(body["facts"])
t = open("reply.json").read(); r = json.loads(t[t.index("{"):t.rindex("}") + 1])
flags = {f["id"]: f for f in facts["flags"]}
ids = [p["id"] for p in r["prescan_responses"]]
assert sorted(ids) == sorted(flags), "every flag answered exactly once"
dismissed = {p["id"] for p in r["prescan_responses"] if p["status"] == "dismissed"}
standing = [f for i, f in flags.items() if i not in dismissed]
rank = ["ready_to_send", "confirm_then_send", "resolve_first"]
floor = 2 if any(f["severity"] == "high" for f in standing) else 1 if any(f["severity"] == "medium" for f in standing) else 0
assert rank.index(r["status"]) >= floor, "status looser than the flags left standing"
covered = {x for i in r["issues"] for x in re.findall(r"[FSACP]\d+", i["ref"])}
assert all(f["id"] in covered for f in standing if f["severity"] != "low")
assert {c["id"] for c in r["checklist"]} == {"final_form", "exhibits", "entity_names", "dates",
"signature_blocks", "internal_approvals", "counsel_review"}
people = {p["id"]: p for p in facts["signers"] + facts["approvers"]}
assert sorted(s["ref"] for s in r["signing"]) == sorted(people), "each signer and approver exactly once"
for s in r["signing"]:
assert s["email"].lower() == people[s["ref"]]["email"].lower()
assert s["action"] == ("approve" if s["ref"].startswith("A") else "sign")
signers = [s for s in r["signing"] if s["ref"].startswith("S")]
approvers = [s for s in r["signing"] if s["ref"].startswith("A")]
if approvers and signers:
assert max(a["order"] for a in approvers) < min(s["order"] for s in signers)
side = {s["id"]: s["side"] for s in facts["signers"]}
mode = facts["routing_mode"]
if mode == "parallel":
assert len({s["order"] for s in signers}) <= 1 and r["routing"] == "parallel"
else:
first, second = ("ours", "theirs") if mode == "we_first" else ("theirs", "ours")
a = [s["order"] for s in signers if side[s["ref"]] == first]
b = [s["order"] for s in signers if side[s["ref"]] == second]
if a and b:
assert max(a) < min(b), "signing order does not follow routing_mode"
known = {p["email"].lower() for p in facts["signers"] + facts["approvers"] + facts["cc"]}
known |= {e.lower() for e in re.findall(r"[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}", body["document"])}
blob = json.dumps([r["signing"], r["cc"], r["envelope"], r["next_steps"], r["issues"], r["summary"]])
assert all(e.lower() in known for e in re.findall(r"[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}", blob)), "invented email"
assert all(p.lower() in body["document"].lower() for p in r["document"]["parties"])
The output contract
Every key is always present. Arrays may be empty and strings may be "" when there is
nothing to say. An enum is written "a|b|c": the reply carries exactly one of the
values. Text fields are plain prose: no Markdown, no pipe characters.
{"lane":"prepare",
"document":{"title":"...","type":"MSA|NDA|SOW|Amendment|Order Form|Lease|...","parties":["exact party name"],"pages":3},
"status":"ready_to_send|confirm_then_send|resolve_first",
"headline":"...","summary":"...",
"checklist":[{"id":"final_form","status":"pass|issue|confirm","note":"...","refs":"F1,F4"}],
"issues":[{"ref":"F2","severity":"high|medium|low","issue":"...","fix":"..."}],
"routing":"sequential|parallel","routing_reason":"...",
"signing":[{"order":1,"ref":"A1","name":"...","email":"...","role":"Internal approver","action":"approve|sign"}],
"cc":[{"ref":"C1","name":"...","email":"..."}],
"fields":[{"ref":"S1","field":"signature|date|name|title|initials","where":"..."}],
"envelope":{"subject":"...","message":"..."},
"next_steps":["..."],
"prescan_responses":[{"id":"F1","status":"confirmed|dismissed","reason":"..."}]}
| key | shape | what it holds |
|---|---|---|
lane | string | Always "prepare". |
document | object | title, type, parties (party names exactly as the opening paragraph writes them) and pages (a number). |
status | enum | Whether the document can go out; see the next table. |
headline | string | One sentence: the agreement, and whether it can go out. |
summary | string | 2-4 sentences: what must happen before sending and how it will be routed; answers question when there is one. |
checklist | array of {id, status, note, refs} | One entry per checklist id; status is pass, issue or confirm; refs is a comma-separated list of flag ids, or "". |
issues | array of {ref, severity, issue, fix} | One per confirmed high or medium flag (its id in ref), plus any the model found itself (ref "" or a recipient id). |
routing, routing_reason | enum, string | sequential or parallel, and one sentence on why. |
signing | array of {order, ref, name, email, role, action} | Every approver (first, approve) and signer (sign) exactly once, with the exact name and email from facts. order is a step number; parallel signers share one. role is "<party> authorized signatory (title)" or "Internal approver". |
cc | array of {ref, name, email} | Every copy recipient. |
fields | array of {ref, field, where} | For each signer: signature, date, and name and title where the block has those lines; initials when initials are requested. where names the block or page. |
envelope | {subject, message} | A subject naming the agreement and parties, and a 3-6 sentence plain-text note to the signers. |
next_steps | array of strings | 3-5 items: fix or confirm first, what happens after sending, a follow-up, filing the executed copy. |
prescan_responses | array of {id, status, reason} | Exactly one per flag in facts.flags: confirmed or dismissed, with the reason. |
Status
| status | meaning |
|---|---|
ready_to_send | No confirmed high or medium flags. Load the packet and send. |
confirm_then_send | The document is sound, but a person must confirm something first: counsel review or the final version not confirmed, a witness or notary line, a block missing a name or title line, signing authority. |
resolve_first | A confirmed high flag, or a defect that would make the executed document wrong: a blank, a wrong entity name, a missing exhibit, open drafting notes or tracked changes, a party with no signer or no signature block. |
The model may be stricter than facts.browser_status, never looser, unless it
dismissed the flags that set it. The page treats a status outside the three values as
resolve_first.
Checklist ids
| id | the item |
|---|---|
final_form | Document is in final, agreed form (no open redlines). issue when blanks, drafting notes, tracked changes or comments remain. |
exhibits | All exhibits and schedules are attached. |
entity_names | Correct legal entity names on signature blocks. |
dates | Dates are correct or left blank for execution date. |
signature_blocks | Signature blocks match the authorized signers. |
internal_approvals | Any required internal approvals have been obtained. pass when approvers are listed, confirm otherwise. |
counsel_review | Document has been reviewed by appropriate counsel. pass only when facts.attested.counsel_reviewed is true. |
Enums
| where | values | the page's fallback |
|---|---|---|
status | ready_to_send, confirm_then_send, resolve_first | resolve_first |
checklist[].status | pass, issue, confirm | confirm |
issues[].severity | high, medium, low | medium |
routing | sequential, parallel | sequential |
signing[].action | approve, sign | sign |
fields[].field | signature, date, name, title, initials | signature |
prescan_responses[].status | confirmed, dismissed | confirmed |
Worked example
The Harborline and Northwind master services agreement from the page: Harborline Analytics, Inc.
is our side, the routing is counterparty first, one internal approver, two copy recipients, and
both attestations ticked. The browser raised four high flags: the counterparty's name differs in
its signature block, Exhibit B is referenced but not attached, the monthly fee is still a bracketed
blank, and a note to draft survived. The request below is shortened (the … marks cut
text, so this excerpt is not itself sendable); make-body.js above rebuilds the full
body.
The request body, abbreviated:
{
"task": "prepare",
"document_title": "Harborline x Northwind master services agreement",
"document": "MASTER SERVICES AGREEMENT\n\nThis Master Services Agreement (the \"Agreement\") is entered into as of the date of the last signature below (the \"Effective Date\") by and between Harborline Analytics, Inc., a Delaware corporation with offices at 400 Pier Street, Suite 12, Portland, Oregon (\"Harborline\"), and Northwind Freight Systems LLC, an Ohio limited liability company with offices at 88 Canal Road, Toledo, Ohio (\"Customer\").\n\n1. SERVICES\n…\n\nIN WITNESS WHEREOF, the parties have executed this Agreement by their authorized representatives.\n\nHARBORLINE ANALYTICS, INC.\nBy: ______________________\nName: Dana Whitfield\nTitle: Chief Financial Officer\nDate: ____________________\n\nNORTHWIND FREIGHT SYSTEM, LLC\nBy: ______________________\nName: ____________________\nTitle: ___________________\nDate: ____________________\n\nEXHIBIT A\n…\n\nEXHIBIT C\n…",
"facts": "{\"document_type_guess\":\"MSA\",\"pages\":1,…,\"routing_mode\":\"counterparty_first\",…,\"browser_status\":\"resolve_first\",…}"
}
Its facts, decoded (the recipient and flag keys; the rest are left out):
{
"parties": [
{
"id": "P1",
"name": "Harborline Analytics, Inc.",
"signature_block_name": "HARBORLINE ANALYTICS, INC.",
"ours": true
},
{
"id": "P2",
"name": "Northwind Freight Systems LLC",
"signature_block_name": "NORTHWIND FREIGHT SYSTEM, LLC",
"ours": false
}
],
"your_company": "Harborline Analytics, Inc.",
"exhibits": {
"referenced": [
"Exhibit A",
"Exhibit B",
"Exhibit C"
],
"attached": [
"Exhibit A",
"Exhibit C"
]
},
"attested": {
"final_agreed_version": true,
"counsel_reviewed": true
},
"signers": [
{
"id": "S1",
"name": "Dana Whitfield",
"email": "dana.whitfield@harborline.example",
"title": "Chief Financial Officer",
"party": "Harborline Analytics, Inc.",
"side": "ours"
},
{
"id": "S2",
"name": "Marcus Oyelaran",
"email": "m.oyelaran@northwindfreight.example",
"title": "VP Operations",
"party": "Northwind Freight Systems LLC",
"side": "theirs"
}
],
"approvers": [
{
"id": "A1",
"name": "Priya Raman",
"email": "priya.raman@harborline.example"
}
],
"cc": [
{
"id": "C1",
"name": "Contracts Desk",
"email": "contracts@harborline.example"
},
{
"id": "C2",
"name": "Legal Notices",
"email": "legal@northwindfreight.example"
}
],
"routing_mode": "counterparty_first",
"browser_status": "resolve_first",
"flags": [
{
"id": "F1",
"severity": "high",
"category": "parties",
"checklist_item": "entity_names",
"message": "The preamble names \"Northwind Freight Systems LLC\" (line 3) but the signature block says \"NORTHWIND FREIG…"
},
{
"id": "F2",
"severity": "high",
"category": "exhibits",
"checklist_item": "exhibits",
"message": "Exhibit B is referenced (first at line 10, 1 mention) but no Exhibit B is attached after the signature se…"
},
{
"id": "F3",
"severity": "high",
"category": "blanks",
"checklist_item": "final_form",
"message": "Unfilled bracketed blank \"[●]\" at line 10: …tform subscription fee is USD [●] per month, invoiced monthly i…"
},
{
"id": "F4",
"severity": "high",
"category": "draft",
"checklist_item": "final_form",
"message": "Drafting mark \"[Note to Draft\" at line 21: …(12) months before the claim. [Note to Draft: Finance to conf…"
}
],
"flags_not_listed": 0,
"prepared_on": "2026-09-26"
}
The reply, parsed from data.output.output and abbreviated: 5 of 7 checklist items, 2 of
4 issues, 4 of 8 fields, 2 of 4 next steps and 2 of 4 prescan responses are shown, and long strings
end in …. The status is resolve_first because four high flags were
confirmed; the signing order puts the approver first, the Northwind signer second and Harborline's
countersignature last, as counterparty_first requires.
{
"lane": "prepare",
"document": {
"title": "Harborline x Northwind master services agreement",
"type": "MSA",
"parties": [
"Harborline Analytics, Inc.",
"Northwind Freight Systems LLC"
],
"pages": 1
},
"status": "resolve_first",
"headline": "The Master Services Agreement between Harborline Analytics, Inc. and Northwind Freight Systems LLC cannot go out yet: it has an unfilled fee blank, an open drafting note, a missing Exhibit B and a misnamed Northwind signature block.",
"summary": "Before sending, fill the monthly subscription fee in Section 2.1, resolve and delete the Note to Draft in Section 5, attach Exhibit B, and correct the Northwind signature block to read NORTHWIND …",
"checklist": [
{
"id": "final_form",
"status": "issue",
"note": "Section 2.1 still has the blank USD [●] for the monthly fee and Section 5 still contains …",
"refs": "F3,F4"
},
{
"id": "exhibits",
"status": "issue",
"note": "Exhibits A and C are attached, but Exhibit B (fees) is referenced in Section 2.1 and not …",
"refs": "F2"
},
{
"id": "entity_names",
"status": "issue",
"note": "The preamble names Northwind Freight Systems LLC but its signature block reads NORTHWIND …",
"refs": "F1"
},
{
"id": "internal_approvals",
"status": "pass",
"note": "Priya Raman is listed as an internal approver and approves inside the envelope before …",
"refs": ""
},
{
"id": "counsel_review",
"status": "pass",
"note": "The user confirmed counsel reviewed the agreement and that this is the final agreed …",
"refs": ""
}
],
"issues": [
{
"ref": "F1",
"severity": "high",
"issue": "The Northwind signature block reads NORTHWIND FREIGHT SYSTEM, LLC, which does not match the party named in the preamble, Northwind Freight Systems LLC.",
"fix": "Change the block heading to NORTHWIND FREIGHT SYSTEMS LLC so it matches the preamble exactly."
},
{
"ref": "F2",
"severity": "high",
"issue": "Section 2.1 says Customer pays the fees set out in Exhibit B, but no Exhibit B is attached.",
"fix": "Attach the agreed Exhibit B fee schedule after the signature blocks before sending."
}
],
"routing": "sequential",
"routing_reason": "Routing is counterparty first, so Northwind Freight Systems LLC signs before Harborline Analytics, Inc. countersigns and the fully signed copy returns to Harborline.",
"signing": [
{
"order": 1,
"ref": "A1",
"name": "Priya Raman",
"email": "priya.raman@harborline.example",
"role": "Internal approver",
"action": "approve"
},
{
"order": 2,
"ref": "S2",
"name": "Marcus Oyelaran",
"email": "m.oyelaran@northwindfreight.example",
"role": "Northwind Freight Systems LLC authorized signatory (VP Operations)",
"action": "sign"
},
{
"order": 3,
"ref": "S1",
"name": "Dana Whitfield",
"email": "dana.whitfield@harborline.example",
"role": "Harborline Analytics, Inc. authorized signatory (Chief Financial Officer)",
"action": "sign"
}
],
"cc": [
{
"ref": "C1",
"name": "Contracts Desk",
"email": "contracts@harborline.example"
},
{
"ref": "C2",
"name": "Legal Notices",
"email": "legal@northwindfreight.example"
}
],
"fields": [
{
"ref": "S2",
"field": "signature",
"where": "NORTHWIND FREIGHT SYSTEMS LLC block, By line"
},
{
"ref": "S2",
"field": "name",
"where": "NORTHWIND FREIGHT SYSTEMS LLC block, Name line"
},
{
"ref": "S2",
"field": "title",
"where": "NORTHWIND FREIGHT SYSTEMS LLC block, Title line"
},
{
"ref": "S2",
"field": "date",
"where": "NORTHWIND FREIGHT SYSTEMS LLC block, Date line"
}
],
"envelope": {
"subject": "Master Services Agreement for signature: Harborline Analytics, Inc. and Northwind Freight Systems LLC",
"message": "Hello, attached is the Master Services Agreement between Harborline Analytics, Inc. and Northwind Freight Systems LLC for route-analytics services. Priya Raman will first review and approve it internally. Marcus …"
},
"next_steps": [
"Fix the four blockers first: fill the fee in Section 2.1, resolve and remove the Note to Draft in Section 5, attach Exhibit B, and correct …",
"Load the corrected document into your e-signature tool with Priya Raman approving first, then Marcus Oyelaran, then Dana Whitfield, copying …"
],
"prescan_responses": [
{
"id": "F1",
"status": "confirmed",
"reason": "The preamble names Northwind Freight Systems LLC while the signature block reads NORTHWIND FREIGHT SYSTEM, LLC, dropping the s in Systems and adding a comma."
},
{
"id": "F2",
"status": "confirmed",
"reason": "Section 2.1 relies on Exhibit B for the fees, but only Exhibits A and C follow the signature blocks."
}
]
}
Truncation and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused: it executes with a reduced output cap and reports truncated: true, in the
done event of /run-stream and on the job from GET /jobs/{id}.
What you hold is then a prefix of the reply. The web page closes the cut-off JSON
(Recon.closeJson in recon.js), shows the sections that arrived and says how
many it recovered, out of thirteen: document, status,
headline, summary, checklist, issues,
routing, signing, cc, fields,
envelope, next_steps and prescan_responses. A truncated
packet can be missing signers, fields or the envelope message, so never load one into an
e-signature tool: check the flag, top up, and resubmit with the attempt suffix on the
Idempotency-Key incremented (signing-desk:prepare:<hash>:a2).
If a complete reply will not parse as one JSON object, the page retries once, as the next attempt,
with a retry_note saying what was wrong and asking for only the JSON object for task
prepare. Do the same: keep the hash, bump the attempt, add retry_note.