← Three-Statement Desk / API
Tokens

Drive Three-Statement Desk from your own code

Everything the web page does is available over HTTP. Send the model the browser builds from a company's historical statements and get one of two replies back, chosen by the task field: drivers (every projection assumption proposed from the history and your guidance notes, each with its basis and, for guidance, a verbatim quote) or review (a verdict on the built model, what it depends on, findings, one response per flag and questions). The natural pipeline is the page's own: propose the drivers, apply them, rebuild, then review.

One thing to be clear about before the first call: the model never does the arithmetic. The statements are read and the projection is built by tsm.js, the same file the web page loads, and the result is sent as facts, a JSON string. See building the facts below.

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":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest endpoint takes {"slug":"three-statement-desk"} in its body), so no slug header is needed afterwards. Send your token as Authorization: Bearer … on every call.

The input object IS the request body. There is no {"input": …} wrapper. A wrapped body returns a 200 with an unknown field 'input' warning, and the model never sees your facts.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422A field is the wrong type. facts must be a string, not an object. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA 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. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

A guest token can call /me and /estimate. Both steps are metered, so it needs a personal token from signing in.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://three-statement-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; running either step 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":"three-statement-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# 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="three-statement-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://three-statement-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

GET /me tells you whether the token is a guest or a person, and what the balance is. subject_type is guest or user — a guest can price a run but cannot start one — and credits is the wallet balance in credits. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

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

4. Choose the lane and price it (free)

The input object is exactly what the app's form submits. The first field is task, and it picks the lane. A missing or unknown task is answered by the closer lane (a projection in the facts means review, otherwise drivers) and the reply's lane names the lane it chose. Estimate each lane separately: the prompts and the facts differ, so the holds differ.

taskwhat it doesfacts come from
driversStep 1. Proposes every driver in facts.fields: value, basis (guidance, history or judgement), rationale and a verbatim quote from notes for guidance; plus cautions, questions and a summary.TSM.buildDriversInput(res, company, notes, question) - the history and the allowed fields, not the current drivers
reviewStep 2. Reviews the built model: verdict (sound, needs_work, not_credible), headline, what it depends on, findings, flag responses, a checks note, questions and a summary.TSM.buildReviewInput(res, company, notes, question) - drivers, every projected year, summary, checks and flags
fieldtypemeaning
taskstring, required"drivers" or "review"
factsstring, requiredThe JSON-encoded facts for that lane (see the table above). Always a string, never an object.
notesstringGuidance or notes, up to 12,000 characters. A longer text keeps its head and tail with a marker where the middle was cut, and facts.notes_clipped_chars says how much.
questionstringWhat you want to know, up to 2,000 characters. May be empty.
retry_notestringOnly when resubmitting after an unparseable reply: a plain instruction about the reply's shape.

The app declares an input schema with task and facts required, so an estimate of an empty body comes back with missing required field warnings. A warning is not a rejection, and /estimate does no other body validation, so check the shape yourself. The web app runs every input through TSM.mustBeObject first.

Building the facts

tsm.js is plain JavaScript with no dependencies and exports itself to node. Download tsm.js next to your script. The page's Save workspace .json button writes {"company","table","notes","question","drivers"}; this script turns that file into a request body for either lane:

// make-body.js - node make-body.js workspace.json drivers|review > body.json
const fs = require("fs");
const TSM = require("./tsm.js");              // https://three-statement-desk.skillsafe.ai/tsm.js
const w = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const lane = process.argv[3] || "drivers";
const res = TSM.compute(w.table, w.drivers || {});
if (lane === "review" && !res.ok) throw new Error(res.errors.join(" "));
if (lane === "drivers" && !res.hist) throw new Error(res.errors.join(" "));
const body = lane === "drivers"
  ? TSM.buildDriversInput(res, w.company, w.notes, w.question)
  : TSM.buildReviewInput(res, w.company, w.notes, w.question);
process.stdout.write(JSON.stringify(TSM.mustBeObject(body)));

To go from a drivers reply to a review, apply the proposals to the drivers and rebuild, exactly as the page's Apply, then review the model button does: w.drivers = TSM.applyProposal(reply.proposals, w.drivers).raw, then build the review body. Values outside a field's range are clamped and reported.

Worked example: drivers (the bundled Ostrevan Components case, abbreviated)

{
  "task": "drivers",
  "facts": "{\"units\":\"$ millions unless stated; ratios per historical year, oldest first\",\"company\":\"Ostrevan Components Inc.\",\"history\":{\"years\":[\"FY2023\",\"FY2024\",\"FY2025\"],\"lines\":{\"revenue\":...",
  "notes": "Q4 FY2025 earnings call - prepared remarks (CFO)  Looking ahead to fiscal 2026, we expect organic revenue growth of 6% t...",
  "question": "What does the FY2026 guidance imply for the five-year drivers?"
}

Worked example: review (the same case after applying the drivers, abbreviated)

{
  "task": "review",
  "facts": "{\"units\":\"$ millions unless stated; P1 is the first projected year after FY2025\",\"company\":\"Ostrevan Components Inc.\",\"last_historical_year\":\"FY2025\",\"history\":{\"years\":[\"FY2023\",\"FY...",
  "notes": "Q4 FY2025 earnings call - prepared remarks (CFO)  Looking ahead to fiscal 2026, we expect organic revenue growth of 6% t...",
  "question": "What does the FY2026 guidance imply for the five-year drivers?"
}
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or take the worked example from this page.
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
#   "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is what gets
# RESERVED; charged_credits after settlement is normally much lower.

5. Run it, then poll

POST /run returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The reply is the string at data.output.output. The terminal job also carries charged_credits (the real price) and the truncated flag.

Always send an Idempotency-Key. Derive it from the input as the web app does, with the lane and an attempt counter: three-statement-desk:<task>:<hash>:a1 (the lane id is part of the key, so the two steps over one paste never collide). A retried request with the same key returns the same job instead of billing a second run. Replaying a key with a different body is a 409, so bump the attempt suffix when you resend a changed body. The web app uses a short hash of the input JSON; any stable hash works, the samples below use the first 16 hex digits of a SHA-256.

If the reply cannot be parsed as one JSON object, the web app retries exactly once: it adds a retry_note field to the same input (a plain instruction to reply with only the JSON object for the same task, every array present) and sends it with the attempt suffix bumped to :a2, so the reformat retry is a distinct, separately billed run. Do the same from code.

# 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="three-statement-desk:review:$(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\":\"review\",\"verdict\":\"stretched\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > review.json

6. Or stream it

POST /run-stream is the same call over server-sent events. Each delta event carries {"text": "..."}, a chunk of the reply, and the final done event carries status, charged_credits and truncated. A browser client may receive progress ticks rather than text deltas; the finished job from step 5 always has the whole reply.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
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\":\"review\",\"verdict\":\"stretched\","}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

data.output.output is a string holding one JSON object. The web app strips any code fence, takes everything from the first { to the last }, parses it and normalizes it for the lane it asked for: an unknown verdict falls back to needs_work, an unknown severity to medium, missing arrays become empty. A drivers reply with no proposals array, or a review reply with neither findings nor flag_responses, counts as unparseable and triggers the one retry_note retry. Then it checks the reply against what it sent - every guidance quote must be word for word in the notes, every history value must equal the history average, the review's verdict must follow from the checks and flag severities, and every number must appear in the facts or the notes. You should do the same.

# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("reply.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r.get("lane"), "-", r["headline"])
if r.get("lane") == "drivers":
    for p in r["proposals"]:
        print(p["field"], p["value"], p["basis"], p["quote"])
else:
    print("verdict:", r["verdict"])
    for f in r["findings"]:
        print(f["severity"], f["area"], f["field"], f["issue"])
EOF

The output contract

Both lanes share one envelope - lane, headline, the lane's arrays, questions and summary - and use empty arrays, never null.

drivers

{
  "lane": "drivers",
  "headline": "The fiscal 2026 guidance sets most drivers directly: revenue growth inside 6% to 7%, gross margin up roughly 20 to 30 basis points, capex at about 4.5% of sales, $20 million of annual debt repayment, a payout of about 30% and a tax rate of 24% to 25%, with the rest held at historical averages.",
  "proposals": [
    {
      "field": "rev_growth",
      "value": 6.5,
      "basis": "guidance",
      "rationale": "Management guides organic revenue growth of 6% to 7%; the value sits inside that range and close to the 6.3% history average.",
      "quote": "we expect organic revenue growth of 6% to 7%"
    },
    {
      "field": "gross_margin",
      "value": 34.3,
      "basis": "guidance",
      "rationale": "Guidance calls for expansion of roughly 20 to 30 basis points from the FY2025 gross margin of 34.1%; the value sits inside that band and above the 33.8% history average.",
      "quote": "We expect gross margin to expand modestly, roughly 20 to 30 basis points, as those pricing actions annualise and freight costs normalise."
    }
  ],
  "cautions": [
    "The guidance speaks only to fiscal 2026, yet each driver is held flat across every projected year, so the later years rest on extending a single year of outlook."
  ],
  "questions": [
    "Does the outlook for organic revenue growth of 6% to 7% extend beyond fiscal 2026, and is any acquired revenue expected?"
  ],
  "summary": "The fiscal 2026 guidance implies growth inside 6% to 7%, modest gross margin expansion, capex of about 4.5% of sales, $20 million of debt repaid each year, a payout of about 30% and no buybacks, with working capital days held where they have been. Because the guidance covers only fiscal 2026, applying it to every projected year is a judgement the model owner should confirm."
}

proposals holds exactly one entry per key of facts.fields, in order: rev_growth, gross_margin, sga_pct, da_pct, capex_pct, dso, dio, dpo, oca_pct, acc_pct, tax_rate, debt_rate, cash_rate, debt_repay, payout_pct, buybacks. value is a bare number in the field's unit (percentages as points, days, $ millions). basis is guidance (quote required, verbatim from notes), history (value equals the history average) or judgement.

review

{
  "lane": "review",
  "verdict": "sound",
  "headline": "The model is sound, with every integrity check passing and no flags, but it carries the FY2026 guidance unchanged into every year from P1 to P5, so cash builds to $301.2m by P5 with nowhere to go.",
  "depends_on": "The outcome rests on revenue growth of 6.5% and a gross margin of 34.3% held flat in every projected year, which lift the EBITDA margin from 17.1% to 17.4%. Cash from operations of $120.1m in P1 rising to $160.1m in P5 funds capex at 4.5% of sales, dividends at a 30.0% payout and $20.0m of debt repaid each year. Everything left over accumulates as cash, giving cumulative free cash flow of $449.8m.",
  "findings": [
    {
      "severity": "medium",
      "area": "margins",
      "field": "gross_margin",
      "issue": "Gross margin of 34.3% against 34.1% in FY2025 reflects the guided expansion of 20 to 30 basis points, but that guidance covers fiscal 2026 only; the model holds the higher margin in every year through P5.",
      "fix": "Confirm whether the pricing gains are expected to persist beyond fiscal 2026, or hold the margin nearer the 33.8% history average after P1 as a sensitivity."
    },
    {
      "severity": "medium",
      "area": "revenue",
      "field": "rev_growth",
      "issue": "Growth of 6.5% sits inside the 6% to 7% guided for fiscal 2026 and above the 6.3% history average, yet it is applied to every year, taking revenue to $1,243.8m by P5 on one year of guidance.",
      "fix": "Treat 6.5% as the P1 assumption and consider fading later years toward the 6.3% history average unless management gives a longer-range view."
    }
  ],
  "flag_responses": [],
  "checks_note": "All integrity checks pass: the historical balance sheets and net income rebuild, and the projected balance sheet balances, cash ties and equity and PP&E roll forward every year.",
  "questions": [
    "Does management expect the fiscal 2026 growth of 6% to 7% and the gross margin expansion to continue beyond fiscal 2026?"
  ],
  "summary": "The FY2026 guidance implies drivers of 6.5% growth, a 34.3% gross margin, capex at 4.5% of sales, a 24.5% tax rate, a 30.0% payout and $20.0m of annual debt repayment, and the model applies each of them unchanged from P1 to P5 even though the guidance covers only one year. The model is sound because every check passes and no flags were raised, but its later years depend on that one-year guidance holding and on an idle cash build that earns nothing."
}

verdict: not_credible if any check fails or any flag is high severity; otherwise needs_work if any flag is medium; otherwise sound. area is one of revenue, margins, working_capital, capex, financing, cash, tax, integrity; field is a driver id or empty. flag_responses has exactly one entry per flag code, in order.

The flag codes

codeseverityraised when
liquidity_shortfallhighThe revolver needed exceeds the revolver commitment you entered.
revolver_above_commitmenthighThe revolver drawn at the start is already above the commitment you entered, before any projected year.
negative_equityhighEquity turns negative in a projected year.
negative_ebitdahighEBITDA is zero or negative in a projected year.
hist_bs_unbalancedhighThe latest historical balance sheet does not balance; the gap is carried as an unreconciled line.
net_lossmediumNet income is negative in a projected year.
revolver_drawmediumCash falls below the minimum and the revolver is drawn (inside its commitment).
growth_above_historymediumProjected growth averages at least 5 points above the historical average.
margin_expansionmediumThe final EBITDA margin is at least 3 points above the last historical year.
wc_days_shiftmediumDSO, DIO or DPO differs from its historical average by more than 15 days.
high_leveragemediumNet debt exceeds 4.0x EBITDA in a projected year.
low_coveragemediumEBITDA covers interest less than 2.0x in a projected year.
hist_ni_mismatchmediumReported net income does not rebuild from the pasted lines.
capex_below_dalowCapex is below D&A, so PP&E shrinks.
payout_above_earningslowDividends and buybacks exceed net income in a projected year.
tax_rate_shiftlowThe tax rate differs from the historical effective rate by more than 10 points.
idle_cashlowCash builds past 25% of final-year revenue with no use modelled.
single_year_historylowOnly one historical year was pasted.
capex_derivedlowHistorical capex was derived from the PP&E roll-forward.

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 comes back with truncated: true. What you hold then is a prefix of the reply. The web page closes the cut-off JSON, shows the sections that arrived and says how many it recovered (five for drivers, eight for review). From code, check the flag before you treat a reply as complete, then resubmit and increment the attempt suffix on the Idempotency-Key.