← Rollforward Desk / API
Tokens

Drive Rollforward Desk from your own code

Everything the web page does is available over HTTP. You send the roll-forward facts for one balance-sheet account and one period: the schedule, the foot check, the gap, the flags and the postings behind each line. You get the same review the page shows. It gives a verdict on whether the schedule can go into the close package, a note for every activity line, an explanation, action and owner for every exception, a line for every posting no rule could place, the support requests to raise and a note for the audit file. The natural use is month-end close: a script rolls every balance-sheet account forward, files the note with the schedule and holds anything whose verdict is not ties.

Before the first call, note this: the model never does the arithmetic. The GL detail is read, classified into lines L2 to L8, footed and its gap worked by rollkit.js, the same file the web page loads. The result is sent as facts, a JSON string. The model's job is judgement over those facts. See building the facts below.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api. A success carries its payload in data. A failure has a non-2xx HTTP status and an error object:

HTTP 2xx   { "data":  { ... } }
HTTP 4xx   { "error": { "code": "...", "message": "...", "details": { ... } } }

This is exactly how the vendored SDK reads it. It treats a response as failed when the HTTP status is not 2xx, then takes error.code, error.message and error.details. Otherwise it returns data. So branch on the status, not on a body flag. Send your token as Authorization: Bearer YOUR_TOKEN and Content-Type: application/json on every call. The token is minted for this app, so no slug header is needed.

The input object IS the request body. There is no {"input": ...} wrapper: the SDK posts JSON.stringify(input) as the body of /estimate, /run and /run-stream. Always send a JSON object. This app declares its input fields, so /estimate and /run return a warnings list naming a missing task or facts, or an unknown field such as a stray input wrapper. A warning is advisory, not a rejection: the run still happens and is still charged. The page guards itself with Rollkit.mustBeObject, which throws unless the body is an object with string task and facts. Put the same check in your client, and treat any warning as a bug in your body.

Error codes

Only statuses and codes the platform has actually been seen to return are listed. Always log error.code and error.message as given, and branch on the HTTP status.

statuscodewhat to do
400validation_errorThe platform rejected the request body or a field in it. Read error.message, fix the body and resend with a new Idempotency-Key.
401unauthorized"Invalid or expired app session". The token is missing, expired or revoked, or it is an account API key rather than an app token. Get a fresh one from the token page.
402(read error.code)The balance is below min_credits from /estimate. Top up. A balance between min_credits and hold_credits is not refused; it runs truncated (see truncation).
404not_foundAn id in the path does not exist, for example a mistyped job_id on /jobs/{job_id}.
429(read error.code)Shared rate limit. Back off and retry; never tight-loop. Keep the same Idempotency-Key.
SSEevent: errorOn /run-stream, a failure after the stream opened arrives as an error event whose data is {code, message, job_id}.

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. It reads the same storage the app uses, so you never need a developer tool.

There are two kinds of token. A guest token is enough for /me and /estimate, and you can mint one yourself with POST /guest and {"slug":"rollforward-desk"}, as below. Reviewing a roll-forward is metered, so /run and /run-stream need a personal token. You get one by signing in on the token page. It bills your own balance. Treat it like a password.

# Personal token (needed for runs): sign in at
#   https://rollforward-desk.skillsafe.ai/tokens.html
# and press "Copy shell export", which gives you:
#   export SKILLSAFE_TOKEN="..."
#
# Guest token (enough for /me and /estimate), minted from the command line:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"rollforward-desk"}' | jq -r '.data.token'

2. A tiny client

One helper that adds the two headers, sends an already-serialised JSON body, unwraps data and raises on a non-2xx status with error.code. It takes the body as a string so a run can hash the exact bytes it sends (step 5). The later steps assume this helper is in scope.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # from https://rollforward-desk.skillsafe.ai/tokens.html

# api METHOD PATH [BODY_FILE] [extra curl args...]
# Prints the response body; exits non-zero (curl --fail-with-body) on a 4xx/5xx.
api() {
  local method="$1" path="$2" body="$3"; shift 3 2>/dev/null || shift $#
  if [ -n "$body" ]; then
    curl -sS --fail-with-body -X "$method" "$BASE$path" \
      -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
      --data-binary "@$body" "$@"
  else
    curl -sS --fail-with-body -X "$method" "$BASE$path" \
      -H "Authorization: Bearer $TOKEN" "$@"
  fi
}

3. Check the session and the balance

GET /me is free. It returns the subject behind the token: subject_type (user for a personal token), subject_id, credits (the balance) and, for a signed-in user, profile fields such as username. Call it first. A 401 here means the token is stale, so nothing else will work either.

api GET /me | jq '.data | {subject_type, subject_id, credits}'

4. Build the input and price it (free)

There is one lane, so task is always "review". An unknown or missing task is still answered as a review. The body is one flat JSON object:

fieldtypewhat goes in it
taskstring, required"review"
factsstring, requiredThe JSON-encoded output of Rollkit.buildFacts. A string, not an object: the model is told to parse it first.
accountstringThe balance-sheet account, as you name it (2140 Accrued expenses).
entitystringThe legal entity whose books these are.
periodstringThe period in words (June 2026).
questionstringOptional, at most 600 characters (the page cuts it there). Answered in the headline or summary; it never overrides the rules. Send "" when you have none.
retry_notestringOnly when resending after a reply that could not be parsed, or to ask for a shorter one. The model obeys it for shape and length and never mentions it.

POST /estimate takes exactly the body you will run and costs nothing: no charge and no job. Read hold_credits as the amount the platform reserves for a run of this size. It is priced worst-case against the full output cap, so it is not the price. The settled charge comes back later as charged_credits on the job, usually far below the hold. min_credits is the floor below which a run is refused. The response also names the model the app is bound to.

Building the facts

The page computes facts in your browser before any model runs. An API caller must build it the same way. The simplest route is to load rollkit.js in Node: Rollkit.analyze(glText, priorAccrualsText, settings) reads the GL detail and returns a profile. Then Rollkit.buildInput({profile, account, entity, period, currency, question}) returns the whole body, with facts already stringified. The settings are normal (credit or debit), sign, date_order, period_start, period_end, bb (the close-package beginning balance), prior_gl (the GL at prior-period end), gl_end and tolerance, as the page's worked examples in example.js show. The facts object carries:

The flag codes rollkit.js raises:

severitycodes
blockno_beginning, no_gl_end, bad_number (also raised as warn)
warnbb_mismatch, eb_paste_mismatch, out_of_period, duplicate, unclassified, direction, unreversed_accrual, unmatched_reversal, skipped_rows, truncated, clipped
infobb_from_paste, bad_dates, cutoff_not_checked, reversals_not_matched, same_period_reversal, zero_rows

Worked example: the Velmarsk body

This is the exact body rollkit.js builds for the page's vireo-explained example: accrued expenses for an invented company, June 2026. The gap of (4,250.00) is explained exactly by a beginning-balance mismatch, one posting dated in July and one duplicated payment. It was generated locally with:

node -e 'var R=require("./rollkit.js"),E=require("./example.js");var x=E.byId("vireo-explained");var P=R.analyze(x.gl,x.prior,x);console.log(JSON.stringify(R.buildInput({profile:P,account:x.account,entity:x.entity,period:x.period,currency:x.currency,question:x.question})))'

The output, unabridged: note that facts is one long string.

{"task":"review","facts":"{\"account\":\"2140 Accrued expenses\",\"entity\":\"Velmarsk Components GmbH\",\"period\":\"June 2026\",\"currency\":\"EUR\",\"period_start\":\"2026-06-01\",\"period_end\":\"2026-06-30\",\"normal_balance\":\"credit\",\"tolerance\":1,\"formula\":\"L1 + L2 + L3 + L4 + L5 + L6 + L7 + L8 = L9; L11 = L10 - L9 (every line in the account's natural sign, so reversals and payments are normally negative)\",\"lines\":[{\"id\":\"L1\",\"key\":\"BB\",\"name\":\"Beginning balance\",\"amount\":318900,\"rows\":0,\"ties_to\":\"prior-period close package (entered)\"},{\"id\":\"L2\",\"key\":\"A\",\"name\":\"Additions / new activity\",\"amount\":5600,\"rows\":1,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 1 row (R4) classified by source AP (1); debits 0.00, credits 5,600.00\",\"debits\":0,\"credits\":5600},{\"id\":\"L3\",\"key\":\"B\",\"name\":\"Accruals booked this period\",\"amount\":65250,\"rows\":5,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 5 rows (R8, R9, R10, R11, R13) classified by source ACCR (5); debits 0.00, credits 65,250.00\",\"debits\":0,\"credits\":65250},{\"id\":\"L4\",\"key\":\"C\",\"name\":\"Reversals of prior accruals\",\"amount\":-52250,\"rows\":3,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 3 rows (R1, R2, R3) classified by source REV (3); debits 52,250.00, credits 0.00\",\"debits\":52250,\"credits\":0},{\"id\":\"L5\",\"key\":\"D\",\"name\":\"Payments / settlements\",\"amount\":-20900,\"rows\":3,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 3 rows (R5, R6, R7) classified by source PAY (3); debits 20,900.00, credits 0.00\",\"debits\":20900,\"credits\":0},{\"id\":\"L6\",\"key\":\"E\",\"name\":\"Reclasses / adjustments\",\"amount\":0,\"rows\":0,\"ties_to\":\"no rows\"},{\"id\":\"L7\",\"key\":\"F\",\"name\":\"FX translation\",\"amount\":1240,\"rows\":1,\"ties_to\":\"GL detail for 2140 Accrued expenses, 2026-06-01 to 2026-06-30: 1 row (R12) classified by memo 'FX' (1); debits 0.00, credits 1,240.00\",\"debits\":0,\"credits\":1240},{\"id\":\"L8\",\"key\":\"U\",\"name\":\"Unclassified activity\",\"amount\":0,\"rows\":0,\"ties_to\":\"no rows\"},{\"id\":\"L9\",\"key\":\"CEB\",\"name\":\"Computed ending balance\",\"amount\":317840,\"rows\":0,\"ties_to\":\"beginning balance plus every activity line above\"},{\"id\":\"L10\",\"key\":\"GL\",\"name\":\"Ending balance per GL\",\"amount\":313590,\"rows\":0,\"ties_to\":\"GL / trial balance at period end (entered)\"},{\"id\":\"L11\",\"key\":\"GAP\",\"name\":\"Unexplained difference\",\"amount\":-4250,\"rows\":0,\"ties_to\":\"ending balance per GL less computed ending balance\"}],\"gap\":{\"amount\":-4250,\"status\":\"explained\",\"causes\":[\"bb_mismatch\",\"out_of_period\",\"duplicate\"],\"parts\":[{\"code\":\"bb_mismatch\",\"amount\":-7500,\"detail\":\"GL at prior-period end less the close-package beginning balance: (7,500.00)\"},{\"code\":\"out_of_period\",\"amount\":-3000,\"detail\":\"rows dated outside the period (R13) net 3,000.00; removing them changes the computed ending balance by (3,000.00)\"},{\"code\":\"duplicate\",\"amount\":6250,\"detail\":\"R6 = R7: 1 extra copy of (6,250.00)\"}],\"hints\":[]},\"flags\":[{\"id\":\"X1\",\"code\":\"bb_mismatch\",\"severity\":\"warn\",\"detail\":\"The beginning balance per the close package, 318,900.00, differs from the GL at prior-period end, 311,400.00, by (7,500.00) (GL less package): a post-close entry, or a package that was never updated.\",\"amount\":-7500},{\"id\":\"X2\",\"code\":\"out_of_period\",\"severity\":\"warn\",\"detail\":\"1 row(s) are dated outside 2026-06-01 to 2026-06-30, netting 3,000.00: R13 (2026-07-02).\",\"rows\":[\"R13\"],\"amount\":3000},{\"id\":\"X3\",\"code\":\"duplicate\",\"severity\":\"warn\",\"detail\":\"1 set(s) of identical rows (same date, reference, source, memo and amount): R6 = R7 ((6,250.00)).\",\"rows\":[\"R6\",\"R7\"]},{\"id\":\"X4\",\"code\":\"unreversed_accrual\",\"severity\":\"warn\",\"detail\":\"1 of last period's 4 accrual(s) have no reversal of the same amount this period: P3 Audit fee Q2 accrual (April-May) 9,000.00.\"}],\"prior_accruals\":[{\"id\":\"P1\",\"ref\":\"JE-5090\",\"memo\":\"May accrual - freight\",\"amount\":14200,\"reversed_by\":\"R1\"},{\"id\":\"P2\",\"ref\":\"JE-5090\",\"memo\":\"May accrual - energy\",\"amount\":21600,\"reversed_by\":\"R2\"},{\"id\":\"P3\",\"ref\":\"JE-5090\",\"memo\":\"Audit fee Q2 accrual (April-May)\",\"amount\":9000,\"reversed_by\":null},{\"id\":\"P4\",\"ref\":\"JE-5090\",\"memo\":\"May accrual - temp labor\",\"amount\":16450,\"reversed_by\":\"R3\"}],\"rows\":[{\"id\":\"R1\",\"date\":\"2026-06-01\",\"je\":\"JE-5101\",\"source\":\"REV\",\"memo\":\"Reverse May accrual - freight\",\"amount\":-14200,\"line\":\"Reversals of prior accruals\",\"component\":\"C\",\"rule\":\"source REV\"},{\"id\":\"R2\",\"date\":\"2026-06-01\",\"je\":\"JE-5101\",\"source\":\"REV\",\"memo\":\"Reverse May accrual - energy\",\"amount\":-21600,\"line\":\"Reversals of prior accruals\",\"component\":\"C\",\"rule\":\"source REV\"},{\"id\":\"R3\",\"date\":\"2026-06-01\",\"je\":\"JE-5101\",\"source\":\"REV\",\"memo\":\"Reverse May accrual - temp labor\",\"amount\":-16450,\"line\":\"Reversals of prior accruals\",\"component\":\"C\",\"rule\":\"source REV\"},{\"id\":\"R4\",\"date\":\"2026-06-09\",\"je\":\"AP-8812\",\"source\":\"AP\",\"memo\":\"Addition - tooling warranty obligation, contract 4471\",\"amount\":5600,\"line\":\"Additions / new activity\",\"component\":\"A\",\"rule\":\"source AP\"},{\"id\":\"R5\",\"date\":\"2026-06-15\",\"je\":\"PY-2215\",\"source\":\"PAY\",\"memo\":\"Payment - Wendelbruck Logistik, April freight\",\"amount\":-8400,\"line\":\"Payments / settlements\",\"component\":\"D\",\"rule\":\"source PAY\"},{\"id\":\"R6\",\"date\":\"2026-06-22\",\"je\":\"PY-2222\",\"source\":\"PAY\",\"memo\":\"Payment - Stadtwerke energy settlement\",\"amount\":-6250,\"line\":\"Payments / settlements\",\"component\":\"D\",\"rule\":\"source PAY\"},{\"id\":\"R7\",\"date\":\"2026-06-22\",\"je\":\"PY-2222\",\"source\":\"PAY\",\"memo\":\"Payment - Stadtwerke energy settlement\",\"amount\":-6250,\"line\":\"Payments / settlements\",\"component\":\"D\",\"rule\":\"source PAY\"},{\"id\":\"R8\",\"date\":\"2026-06-30\",\"je\":\"JE-5190\",\"source\":\"ACCR\",\"memo\":\"June accrual - freight\",\"amount\":15050,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"},{\"id\":\"R9\",\"date\":\"2026-06-30\",\"je\":\"JE-5190\",\"source\":\"ACCR\",\"memo\":\"June accrual - energy\",\"amount\":20900,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"},{\"id\":\"R10\",\"date\":\"2026-06-30\",\"je\":\"JE-5190\",\"source\":\"ACCR\",\"memo\":\"June accrual - audit fee Q2\",\"amount\":9000,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"},{\"id\":\"R11\",\"date\":\"2026-06-30\",\"je\":\"JE-5190\",\"source\":\"ACCR\",\"memo\":\"June accrual - temp labor\",\"amount\":17300,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"},{\"id\":\"R12\",\"date\":\"2026-06-30\",\"je\":\"JE-5195\",\"source\":\"FXREV\",\"memo\":\"FX revaluation - USD-denominated accruals\",\"amount\":1240,\"line\":\"FX translation\",\"component\":\"F\",\"rule\":\"memo 'FX'\"},{\"id\":\"R13\",\"date\":\"2026-07-02\",\"je\":\"JE-5201\",\"source\":\"ACCR\",\"memo\":\"July accrual - freight (posted early)\",\"amount\":3000,\"line\":\"Accruals booked this period\",\"component\":\"B\",\"rule\":\"source ACCR\"}],\"counts\":{\"rows_read\":13,\"rows_sent\":13,\"rows_skipped\":0,\"unclassified\":0},\"verdict_floor\":\"does_not_tie\"}","account":"2140 Accrued expenses","entity":"Velmarsk Components GmbH","period":"June 2026","question":"Why does this not tie to the trial balance?"}

Save your own body as body.json, or just the facts object as facts.json (the output of JSON.stringify(Rollkit.buildFacts(profile, ctx))). The samples below read facts.json as text and send that text as the facts string. If you build the facts in your own code, JSON-encode them first.

# Wrap facts.json into the body; tojson turns the facts object into the required STRING.
jq -c '{task: "review", facts: tojson,
        account: "2140 Accrued expenses", entity: "Velmarsk Components GmbH",
        period: "June 2026", question: "Why does this not tie to the trial balance?"}' \
  facts.json > body.json

api POST /estimate body.json | jq '.data | {model, hold_credits, min_credits}'

5. Run it, then poll the job

POST /run with the same body starts the review and returns {"job_id": "..."} straight away. Then poll GET /jobs/{job_id} until status is succeeded or failed. The SDK polls once a second and gives up after 180 seconds; do the same. A finished job carries the model's reply as text in output.output, the settled charged_credits, truncated and, on failure, error.

Send an Idempotency-Key header on every run. It is the header the SDK sets on /run and /run-stream. Derive it from the body and an attempt number, for example rollforward-desk:review:<first 16 hex of sha256(body)>:a1. Then a retry after a timeout or a dropped connection reuses the key and is recognised as the same request, not billed as a new one. Change the attempt suffix only when you mean a new run, for example a resend with a retry_note.

KEY="rollforward-desk:review:$(shasum -a 256 body.json | cut -c1-16):a1"

JOB=$(api POST /run body.json -H "Idempotency-Key: $KEY" | jq -r '.data.job_id')
echo "job $JOB"

for i in $(seq 1 180); do
  api GET "/jobs/$JOB" > job.json
  STATUS=$(jq -r '.data.status' job.json)
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 1
done

jq '.data | {status, charged_credits, truncated, error}' job.json
jq -r '.data.output.output' job.json > reply.txt     # the model's reply (one JSON object as text)

6. Or stream it

POST /run-stream takes the same body and the same Idempotency-Key header, and answers with Server-Sent Events. Frames are separated by a blank line, and each has an event: line and a JSON data: line. The events the SDK handles:

Ignore any other event name, such as heartbeats. A page in a browser has been seen to receive heartbeats and a single done but no delta frames, so never rely on deltas to assemble the reply: take it from done, or from the job. Also, when the response Content-Type is not text/event-stream, it is a plain {data} / {error} envelope. That is what an idempotent replay of an already-run key returns.

KEY="rollforward-desk:review:$(shasum -a 256 body.json | cut -c1-16):a1"

# -N turns off buffering so frames print as they arrive; tee keeps a copy.
curl -sS -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data-binary @body.json | tee stream.txt

# The final payload is the data line after "event: done" (or "event: pending": then poll the job).
awk '/^event: (done|pending)/ { getline; sub(/^data: ?/, ""); print }' stream.txt | jq '{job_id, status, charged_credits}'

7. Parse and check the reply

The reply text is meant to be exactly one JSON object. Parse it the way recon.js does in parseResult: trim it, drop a stray code fence, take everything from the first { to the last } and JSON.parse that. Then hold it to the facts you sent. The page never trusts the model's reading of the numbers; reconcile() checks each of the points below and shows every disagreement.

Invariants worth asserting

In JavaScript, load recon.js itself and call Recon.reconcile. The other languages below port the structural checks; port numbersIn and plugs too if you gate on them.

# reply.txt from step 5; facts.json is what you sent.
# First "{" to last "}", as recon.js does:
perl -0777 -ne 'print $1 if /(\{.*\})/s' reply.txt > review.json
jq -e . review.json > /dev/null || { echo "reply is not JSON"; exit 1; }

# Prints a list of problems; [] means every structural invariant holds.
jq -n --slurpfile r review.json --slurpfile f facts.json '
  ($r[0]) as $r | ($f[0]) as $f |
  {"ties": 0, "ties_with_exceptions": 1, "does_not_tie": 2} as $rank |
  [
    (if ($rank[$r.verdict // ""] // -1) < $rank[$f.verdict_floor] then "verdict looser than the floor" else empty end),
    (if $r.gap.status != $f.gap.status then "gap.status differs from the foot check" else empty end),
    (if (($r.gap.causes // []) | sort) != (if $f.gap.status == "explained" then ($f.gap.causes | sort) else [] end)
       then "gap.causes differ from the proven causes" else empty end),
    (if ($r.verdict == "does_not_tie") != ($f.gap.status != "ties") then "verdict disagrees with the foot check" else empty end),
    ($f.flags[] | select(.severity == "warn" or .severity == "block") | .id as $id
       | select([($r.exceptions // [])[] | select(.flag == $id)] | length != 1) | "flag \($id) not answered exactly once"),
    ($f.rows[] | select(.component == "U") | .id as $id
       | select([($r.reclass_suggestions // [])[] | select(.row == $id)] | length != 1) | "row \($id) not placed exactly once"),
    ($f.lines[] | select((.id | test("^L[2-8]$")) and .rows > 0) | .id as $id
       | select([($r.lines // [])[] | select(.line == $id)] | length != 1) | "line \($id) not noted exactly once")
  ]'

The output contract

The reply is one JSON object and nothing else: no prose around it, no code fences and no Markdown inside strings. Arrays with nothing in them are empty arrays, never omitted and never null. This is its shape, taken from the app's system prompt. It is an illustration of the fields, not real model output:

{
  "lane": "review",
  "verdict": "ties | ties_with_exceptions | does_not_tie",
  "headline": "one sentence: can this schedule go into the close package, and why",
  "lines": [ { "line": "L3", "note": "..." } ],
  "gap": { "status": "ties | explained | unexplained | cannot_foot", "causes": [], "explanation": "...", "action": "" },
  "exceptions": [ { "flag": "X2", "explanation": "...", "action": "...", "owner": "gl_accounting", "blocking": true } ],
  "reclass_suggestions": [ { "row": "R3", "component": "U", "reason": "..." } ],
  "support_requests": [ "..." ],
  "file_note": "3-6 sentences for the close package or the audit file",
  "summary": "2-3 sentences for the reviewer who reads nothing else"
}
fieldvalues and rules
laneAlways "review".
verdictties, ties_with_exceptions, does_not_tie. does_not_tie whenever the gap does not tie; ties only when the gap ties and nothing blocks; never looser than verdict_floor.
headlineOne sentence. Answers question when one was sent (or summary does).
lines[]line (L2 to L8, only lines with rows) and note (at most 45 words, citing the line's ties_to).
gap.statusties, explained, unexplained, cannot_foot. Must equal facts.gap.status.
gap.causesA subset of bb_mismatch, out_of_period, duplicate: exactly facts.gap.causes when explained, otherwise empty.
gap.explanation / gap.actionAt most 70 and 40 words. The action is an empty string when the status is ties. Never a plug.
exceptions[]flag (an X id), explanation (at most 70 words), action (at most 40), owner, blocking (boolean).
ownerpreparer (how the schedule was built: the paste, a missing balance, an unclassified row), gl_accounting (journals, accruals, reversals, reclasses, cut-off), ap_ar (sub-ledger invoices, billings, credit memos, payments), treasury (cash settlements and FX), controller (sign-off, estimates, policy).
reclass_suggestions[]row (an R id that was sent), component, reason (at most 40 words).
componentA additions, B accruals booked, C reversals of prior accruals, D payments / settlements, E reclasses / adjustments, F FX translation, U unclassified. A and B take a positive amount, C and D a negative one, E and F either sign. U means the memo and source do not show where the row belongs.
support_requests[]One string per open point, at most 45 words, naming the ids and the figure. May be empty when the verdict is ties.
file_note / summary3 to 6 sentences for the audit file; 2 to 3 sentences for the reviewer.

Every amount in the prose is copied from facts, written with thousands separators and two decimals (7,500.00). A negative is written with a minus sign, in parentheses, or as a plain size when the sentence gives the direction.

Truncation and partial results

/estimate gives two numbers that matter here. A run whose balance is below min_credits is refused (402). A run whose balance is between min_credits and hold_credits is not refused. It runs with the output cap scaled down to what the balance affords. The job still reports status: "succeeded", but with truncated: true. The reply you then hold is a prefix. The line notes and exceptions may be complete while file_note and summary are missing, or the JSON may stop mid-string.

Always check truncated before you treat a reply as complete. To show what did arrive, do what the page does: Recon.closeJson(text) closes an open string and any open brackets, then normalize reads whichever sections are present. Do not file a truncated review. Top up, then resend with a new attempt suffix on the Idempotency-Key. If the reply was cut short or could not be parsed, you can add a retry_note asking for a shorter reply.