← Signing Desk / API
Tokens

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.

taskwhat it doeswhat it needs
prepareThe 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.

fieldtyperequiredwhat it holds
taskstringyesAlways "prepare".
document_titlestringyesWhat you call the agreement; "Untitled agreement" when blank.
documentstringyesThe 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.
factsstringyesA JSON string (the output of JSON.stringify), never an object, built by signkit.js. Its keys are listed below.
questionstringnoYour own question, answered inside summary. Omitted when empty; cut at 1,500 characters.
retry_notestringnoOnly 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:

keywhat it holds
document_type_guess, pages, words, sourceMSA, NDA, SOW, Amendment, Order Form, Lease and so on (or Other); an estimated page count; the word count; text or docx.
parties, your_companyParties found in the opening paragraph: id (P1..), name, signature_block_name, ours; and the company you entered as your side.
signature_blocksOne per signature line: line, entity, printed_name, printed_title, has_name_line, has_title_line, has_date_line.
exhibitsreferenced and attached exhibit and schedule names.
effective_datedate, as_written, left_blank, last_signature_rule, or null.
tracked_changes, commentsCounts from a .docx read in the page; null for pasted text.
initials_requested, counterparts_clause, esignature_clauseBooleans read from the text.
attestedfinal_agreed_version and counsel_reviewed: what you confirmed.
signers, approvers, ccRecipients 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_ordercounterparty_first, we_first or parallel, and the browser's signing order in the same shape as the reply's signing.
browser_status, checklistThe browser's status hint, and each checklist item's id, browser_status and flags.
flags, flags_not_listedUp to 40 flags: id (F1..), severity (high, medium, low), category, checklist_item, message; and how many more were raised but not listed.
prepared_onThe 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 fieldwhat it holds
titleWhat you call the agreement.
oursYour company's legal name, as the agreement writes it.
routingcounterparty_first (default), we_first or parallel.
preparedToday, YYYY-MM-DD.
docThe agreement text.
signersOne per line: name, email, title, party.
approvers, ccOne per line: name, email.
attest_final, attest_counselBooleans: 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

statuscodewhat to do
400validation_errorA field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe 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.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA 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"}}

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
}

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

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.

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

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}

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

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:

# 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":"..."}]}
keyshapewhat it holds
lanestringAlways "prepare".
documentobjecttitle, type, parties (party names exactly as the opening paragraph writes them) and pages (a number).
statusenumWhether the document can go out; see the next table.
headlinestringOne sentence: the agreement, and whether it can go out.
summarystring2-4 sentences: what must happen before sending and how it will be routed; answers question when there is one.
checklistarray 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 "".
issuesarray 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_reasonenum, stringsequential or parallel, and one sentence on why.
signingarray 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".
ccarray of {ref, name, email}Every copy recipient.
fieldsarray 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_stepsarray of strings3-5 items: fix or confirm first, what happens after sending, a follow-up, filing the executed copy.
prescan_responsesarray of {id, status, reason}Exactly one per flag in facts.flags: confirmed or dismissed, with the reason.

Status

statusmeaning
ready_to_sendNo confirmed high or medium flags. Load the packet and send.
confirm_then_sendThe 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_firstA 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

idthe item
final_formDocument is in final, agreed form (no open redlines). issue when blanks, drafting notes, tracked changes or comments remain.
exhibitsAll exhibits and schedules are attached.
entity_namesCorrect legal entity names on signature blocks.
datesDates are correct or left blank for execution date.
signature_blocksSignature blocks match the authorized signers.
internal_approvalsAny required internal approvals have been obtained. pass when approvers are listed, confirm otherwise.
counsel_reviewDocument has been reviewed by appropriate counsel. pass only when facts.attested.counsel_reviewed is true.

Enums

wherevaluesthe page's fallback
statusready_to_send, confirm_then_send, resolve_firstresolve_first
checklist[].statuspass, issue, confirmconfirm
issues[].severityhigh, medium, lowmedium
routingsequential, parallelsequential
signing[].actionapprove, signsign
fields[].fieldsignature, date, name, title, initialssignature
prescan_responses[].statusconfirmed, dismissedconfirmed

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.