← Deck QC Desk / API
Tokens

Drive Deck QC Desk from your own code

Everything the web page does is available over HTTP. Send the slide text of one deck, with the prescan the browser computes for it, and get the same report back: a verdict (not ready, fix before sending, client ready), findings by severity and category with the exact quote and a fix_from / fix_to for number fixes, one response per prescan flag, a checklist and a summary. Or send a deck with a mapping of old figures to new ones and get a review of the refresh change list: how each mapping line was read, the instances to skip, the instances the browser missed, the derived numbers that go stale, narrative conflicts and the questions to settle before anything is applied. The natural use is a deal-team pipeline: a script rolls each deck to the latest numbers, runs the QC pass on the result and files the report next to the deck.

One thing to be clear about before the first call: the model never does the mechanical half. Slides, figures, cross-slide consistency, arithmetic and style drift are worked out by deckscan.js, and the refresh change list by refresh.js, the same files the web page loads. The result is sent as facts, a JSON string. The model's job is judgement over that work. See building the facts below.

Two lanes: the task field

Every request names its lane in task, and one system prompt routes on it. There are two:

taskwhat it does
refreshReviews a refresh change list built by refresh.js: a reading of every mapping line with a confidence (and a proposed_old for new-value-only lines), the instances to skip because they are a different metric or period, the instances the browser missed, the derived numbers that go stale (recalculated when the policy is recalc), narrative conflicts and the questions to settle before approving.
checkThe QC pass before a deck goes to a client: a verdict (not_ready, fix_before_sending, client_ready), a headline, findings by severity (critical, important, minor) and category, each with slides, an exact quote, the issue, the action and fix_from / fix_to for number fixes, one prescan_responses entry per prescan flag, a five-item checklist and a summary.

A missing or unknown task is still answered, as the closest lane (a facts.refresh object means refresh), and the reply's lane names the lane that was used. Always send task and check lane in the reply.

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":"deck-qc-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 deck or 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 lanes are metered, so a run 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://deck-qc-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; both lanes need 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":"deck-qc-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="deck-qc-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://deck-qc-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. Price the run (free)

The input object is exactly what the app's form submits. The first field is task, the lane (see the two lanes):

taskwhat it does
refreshReviews the refresh change list: mapping readings, instances to skip, missed instances, derived numbers, narrative conflicts, questions.
checkThe QC pass: verdict (not_ready, fix_before_sending, client_ready), findings by severity and category with quotes and fix_from / fix_to, prescan responses, checklist, summary.

A missing or unknown task is answered as the closest lane, and the reply's lane names it.

fieldtypemeaning
taskstring, required"refresh" or "check".
deckstring, requiredThe slide text, one block per slide, each opening with a ## Slide N line. DeckScan.clipDeck clips it on whole slides at 40,000 characters (a slide over 4,000 characters loses its middle, with a marker) and names the slides left out in a closing [Slides 31-40 not sent ...] line. Slide numbers are the only way the reply cites a location.
factsstring, requiredThe JSON-encoded output of DeckScan.buildFacts: the slide list, every figure found, the prescan flags and counts, parse warnings and the deck name, plus facts.refresh = Refresh.planFacts(...) for the refresh lane.
questionstring, optionalWhat you want to know, up to 2,000 characters. May be empty. A longer question is cut on a word boundary and facts.note_clipped_chars says how much was dropped.
retry_notestring, optionalLeave it out. The page sets it only on its one reformat retry after an unparseable reply: a plain instruction about the reply's shape.

facts must be a string holding JSON, not an object; an object is a validation_error on a run. The app declares an input schema with task, deck and facts required, but /estimate does no body validation: a malformed body (a bare string, facts as an object, no deck at all) prices as happily as a good one. So validate on your side before you run. The web app runs every input through DeckScan.mustBeObject first: it must be a JSON object whose task and deck are non-empty strings and whose facts is a string.

Building the facts

deckscan.js and refresh.js are plain JavaScript with no dependencies, are served next to the page (deckscan.js, refresh.js) and export themselves to node. Download both into one folder (refresh.js loads ./deckscan.js itself), put the slide text in a file with ## Slide N markers, and let them build the body. The prescan (res.flags, res.counts) and the change list (plan.instances, plan.derived) are free and local; only the run is metered.

// make-body.js
//   node make-body.js deck.md "your question"                        > body.json   (check)
//   node make-body.js deck.md "your question" mapping.txt recalc     > body.json   (refresh)
const fs = require("fs");
const DeckScan = require("./deckscan.js");   // https://deck-qc-desk.skillsafe.ai/deckscan.js
const Refresh = require("./refresh.js");     // https://deck-qc-desk.skillsafe.ai/refresh.js

const [deckFile, question = "", mappingFile, policy = "flag"] = process.argv.slice(2);
const deckText = fs.readFileSync(deckFile, "utf8");
const name = "Project Harbor - Tarnwick Freight pitch";   // up to 120 characters, shown in facts.deck_name

const res = DeckScan.scan(deckText);                       // slides, numbers, prescan flags P1, P2 ...
if (!res.slides.length) throw new Error("no slides found");

let body;
if (!mappingFile) {
  body = DeckScan.buildInput("check", res, { question, name });
} else {
  const mappingText = fs.readFileSync(mappingFile, "utf8");
  const plan = Refresh.buildPlan(deckText, mappingText, { policy });   // policy: "flag" | "recalc"
  if (!plan.pairs.length) throw new Error(plan.errors.join(" ") || "no mapping lines");
  body = DeckScan.buildInput("refresh", res, { question, name, refresh: Refresh.planFacts(plan, mappingText) });
}
process.stdout.write(JSON.stringify(DeckScan.mustBeObject(body)));   // {task, deck, facts:"{...}", question}

The mapping takes one line per figure, up to 40 lines, in any of these forms (lines starting with # are ignored):

formexample
old -> new (label)$212M -> $218M (Revenue)
label old -> newAdj. EBITDA $41M -> $43M (also =>, to, becomes)
tab-separated$184M tab $190M tab Subscription revenue (comma-separated columns work too)
new value onlyNet revenue retention was 118%: the browser cannot place it, lists the deck's candidate figures under unresolved, and the review names the one it replaces in proposed_old

Each old figure is found in every style the deck uses ($485M, $485MM, $0.485B, $485.0 million, and bare chart or table numbers read in the deck's scale), and each replacement is written in that instance's own style. With policy: "recalc" the browser also recomputes the derived figures it has inputs for and offers them as engine_suggestion; with "flag" they are only flagged.

What facts carries once it is parsed:

keycontents
slide_count, slidesHow many slides were read (at most 80) and {n, title} for each.
numbers_found, numbers, numbers_omittedEvery figure found, and up to 160 of them as {slide, text, metric, period, series}; the metric is read from the nearest keyword and period is set when the line labels one.
prescan_flags, prescan_countsWhat the browser found, as {id, severity, category, slides, message} with ids P1, P2 ...; and the count per severity. Categories are the same as a finding's: number consistency, calculation, data narrative, sources, period labels, terminology, formatting, language.
parse_warningsSlide-parsing problems: no markers, duplicate slide numbers, text before the first marker, more than 80 slides.
lane, deck_nameThe lane again and the deck name (up to 120 characters).
slides_not_sent, slides_middle_cut, note_clipped_charsPresent only when something was clipped: the slides left out of deck, the slides that lost their middle, and how much of the question was dropped. The page shows the same cut by the run button, stamps "Partial" on the result and its exports, and adds -PARTIAL to the download names.
refreshRefresh lane only, from Refresh.planFacts(plan, mappingText): policy, mapping_text, pairs ({id: "M1", label, old, new}, old null for a new-value-only line), instances (up to 120, {id: "R1", pair, slide, text, replace, kind, context} with kind exact, bare or mismatch), derived (up to 60, {id: "D1", slide, text, why, engine_suggestion, basis}), unresolved (with candidates), engine_notes, and instances_omitted / derived_omitted when those lists were cut.

Worked inputs

The page's Project Harbor example (a sell-side pitch for Tarnwick Freight) as a check body. The deck and the facts string are abbreviated here; the prescan finds 14 flags (5 critical, 8 important, 1 minor), among them P1 "Revenue (FY25) reads $485M on slides 2, 3 but $458M on slide 9." and P3 "From $410M to $485M is +18.3%; the slide says 15%.":

{
  "task": "check",
  "deck": "## Slide 1\nProject Harbor\nDiscussion materials for the Board of Directors of Tarnwick Freight\nQ4 2025\n\n## Slide 2\n- Tarnwick is the #1 player in the $120B North American freight brokerage market\n- FY25 revenue of $485M and Adj. EBITDA of $97M\n- Revenue grew from $410M in FY24 to $485M in FY25, up 15%\n...",
  "facts": "{\"slide_count\":9,\"slides\":[{\"n\":1,\"title\":\"Project Harbor\"},{\"n\":2,\"title\":\"Executive summary\"},...],\"numbers\":[{\"slide\":2,\"text\":\"$120B\",\"metric\":\"market_size\"},{\"slide\":2,\"text\":\"$485M\",\"metric\":\"revenue\",\"period\":\"FY25\"},...],\"prescan_flags\":[{\"id\":\"P1\",\"severity\":\"critical\",\"category\":\"number_consistency\",\"slides\":[2,3,9],\"message\":\"Revenue (FY25) reads $485M on slides 2, 3 but $458M on slide 9.\"},...],\"prescan_counts\":{\"critical\":5,\"important\":8,\"minor\":1},\"parse_warnings\":[],\"lane\":\"check\",\"deck_name\":\"Project Harbor - Tarnwick Freight pitch\"}",
  "question": "Final pass before this goes to the client tomorrow. What would embarrass us?"
}

The Velloran Analytics example (a board deck rolled to final FY26 numbers) as a refresh body, built with policy: "recalc" from this mapping:

Revenue $212M -> $218M
Subscription revenue $184M -> $190M
Adj. EBITDA $41M -> $43M
Net revenue retention was 118%

The browser finds 9 instances (R1 to R9, including the bare 41 in the slide 4 chart series and the $41M total contract value on slide 6), 6 derived figures with engine suggestions such as D1 16.5% -> 19.8%, and leaves the retention line unresolved with candidates 116% and 111%:

{
  "task": "refresh",
  "deck": "## Slide 1\nVelloran Analytics\nBoard update - FY26 results\nMarch 2026\n\n## Slide 2\n- FY26 revenue of $212M, up 16.5% from $182M in FY25\n- Adj. EBITDA of $41M, a 19.3% Adj. EBITDA margin\n...",
  "facts": "{\"slide_count\":7,...,\"prescan_flags\":[],\"lane\":\"refresh\",\"deck_name\":\"Velloran Analytics - FY26 board update\",\"refresh\":{\"policy\":\"recalc\",\"mapping_text\":\"Revenue $212M -> $218M\\n...\",\"pairs\":[{\"id\":\"M1\",\"label\":\"Revenue\",\"old\":\"$212M\",\"new\":\"$218M\"},...,{\"id\":\"M4\",\"label\":\"Net revenue retention\",\"old\":null,\"new\":\"118%\"}],\"instances\":[{\"id\":\"R1\",\"pair\":\"M1\",\"slide\":2,\"text\":\"$212M\",\"replace\":\"$218M\",\"kind\":\"exact\",\"context\":\"- FY26 revenue of $212M, up 16.5% from $182M in FY25\"},...,{\"id\":\"R8\",\"pair\":\"M3\",\"slide\":6,\"text\":\"$41M\",\"replace\":\"$43M\",\"kind\":\"exact\",\"context\":\"- Total contract value signed in FY26: $41M\"},...],\"derived\":[{\"id\":\"D1\",\"slide\":2,\"text\":\"16.5%\",\"why\":\"growth rate - stale if its base or end point moved\",\"engine_suggestion\":\"19.8%\",\"basis\":\"from $182M to $212M (new)\"},...],\"unresolved\":[{\"id\":\"M4\",\"label\":\"Net revenue retention\",\"new\":\"118%\",\"candidates\":[{\"text\":\"116%\",\"slides\":[2,5]},{\"text\":\"111%\",\"slides\":[5]}]}],\"engine_notes\":[]}}",
  "question": "Final audited numbers came in. Update the deck and tell me what else moves."
}

Now price it. /estimate is free: it creates no job and charges nothing, and returns hold_credits, min_credits, model, model_alias and markup_bps. hold_credits is a reservation against the full output cap, not the price: you are charged for what the run actually uses, reported afterwards as charged_credits. Price each lane separately; a refresh body carries the change list and prices differently from a check body over the same deck.

# 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,sys;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("check","refresh") and isinstance(b.get("deck"),str) and b["deck"] and isinstance(b.get("facts"),str)'
INPUT=$(cat body.json)
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')

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

A run is metered, so it needs a personal token from signing in; a guest token gets a 403 here. 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, and put the lane in it. The web app sends deck-qc-desk:<lane>:<hash>:a<attempt>, for example deck-qc-desk:check:<hash>:a1. A retried request with the same key returns the same job instead of billing a second run, and because the lane is part of the key, a refresh review and a QC pass over the same deck never collide. 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 lane in task, no prose, no code fences, 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 lane and the input. A retried
# request with the same key returns the SAME job instead of billing a second run.
KEY="deck-qc-desk:$LANE:$(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\":\"check\",\"verdict\":\"not_ready\", ...}"},
#   "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 is the same call over server-sent events, with the same personal token and the same lane-bearing Idempotency-Key. 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\":\"refresh\",\"headline\":\"14 values change on 5 slides"}
# 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 with Recon.normalize (recon.js, which also exports itself to node). The lane is read from lane; when that is missing it is guessed (findings or verdict means check, mapping or skip means refresh), and a lane other than the one you sent is surfaced as a mismatch. For check, an unknown verdict is derived from the findings, an unknown severity becomes important, an unknown category formatting, findings are re-sorted critical first, prescan ids are upper-cased and a checklist item counts only when it is literally true. For refresh, ids are upper-cased and an unknown confidence becomes medium. Missing arrays become empty. A reply with no headline, no findings and no mapping counts as unparseable and triggers the one retry_note retry.

# 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") == "refresh":
    for s in r["skip"]:
        print("skip", s["id"], s["reason"])
else:
    for f in r["findings"]:
        print(f["severity"], f["slides"], f["quote"], f["fix_from"], "->", f["fix_to"])
EOF

Invariants worth asserting

The page holds every reply to the deck with Recon.reconcile(r, ctx), where ctx is {slides: res.slides, flags: res.flags} for check and {plan} for refresh; it returns the disagreements it found. What it checks:

Handing the result on

The two lanes chain. A refresh review goes back into the change list with Refresh.applyReview(deckText, mappingText, policy, review), which fills in the proposed old values, unticks the skipped instances and adds the missed instances and recalculations; then Refresh.apply(plan, extras) returns the refreshed deck as text with a list of changes. Nothing is applied that the plan does not show. That text is the input to a check run. In the other direction, the fix_from / fix_to pairs of a QC report are mapping lines ($458M -> $485M) for the next refresh.

const Recon = require("./recon.js");
const review = Recon.normalize(Recon.parseResult(replyText), "refresh");
Recon.reconcile(review, { plan });                    // marks bad quotes so they are not applied
const next = Refresh.applyReview(deckText, mappingText, "recalc", review);
const applied = Refresh.apply(next.plan, next.extras);
console.log(applied.count, "changes on", applied.slides_touched, "slides");
const checkBody = DeckScan.buildInput("check", DeckScan.scan(applied.text), { question: "", name });

The output contract

check

{
  "lane": "check",
  "verdict": "not_ready" | "fix_before_sending" | "client_ready",
  "headline": "one sentence a managing director would read first",
  "findings": [
    {
      "severity": "critical" | "important" | "minor",
      "category": "number_consistency" | "calculation" | "data_narrative" | "factual" | "language"
                | "terminology" | "sources" | "period_labels" | "formatting",
      "slides": [2, 9],
      "quote": "exact text from the first cited slide",
      "issue": "what is wrong, with the figures",
      "action": "what to do",
      "fix_from": "the wrong figure exactly as the deck writes it, or empty",
      "fix_to": "the corrected figure in the same style, or empty",
      "prescan": "P1 or empty"
    }
  ],
  "prescan_responses": [{"id": "P1", "call": "confirmed" | "dismissed", "reason": "..."}],
  "checklist": {
    "numbers_reconciled": true,
    "narrative_matches_data": true,
    "language_ib_standard": true,
    "charts_sourced": true,
    "formatting_consistent": true
  },
  "summary": "two to four sentences: what blocks delivery, what to fix, what was checked and found clean"
}

Severity: critical is number mismatches, calculation errors, factual errors and data contradicting the narrative (these block client delivery); important is language, missing sources, terminology drift, unlabelled periods and unsupported claims; minor is date formats, number formats and small polish. Findings come critical first. fix_from and fix_to are both empty when the right value cannot be determined from the deck. The question, when there is one, is answered in the headline or the summary. The model never claims to have checked layout, fonts or colours; visual QC stays with a person.

refresh

{
  "lane": "refresh",
  "headline": "one sentence: how many values change on how many slides, and the main risk",
  "mapping": [{"id": "M1", "reading": "...", "confidence": "high" | "medium" | "low", "proposed_old": ""}],
  "skip": [{"id": "R8", "reason": "..."}],
  "extra": [{"slide": 4, "quote": "exact text", "replace_with": "same text with the new value", "reason": "..."}],
  "derived": [{"id": "D1", "slide": 2, "quote": "exact text", "replace_with": "", "why": "..."}],
  "narrative": [{"slide": 4, "quote": "exact text", "conflict": "..."}],
  "questions": ["..."],
  "summary": "two to four sentences: what will change, what stays flagged, what to check visually after editing"
}

One mapping entry per pair, in order. skip lists only instances that are NOT the mapped figure (the same digits standing for another metric or period). extra never repeats a listed instance. derived covers every entry in facts.refresh.derived plus any derived figure the browser missed (empty id); replace_with is filled only when the policy is recalc and the deck gives the inputs, with the arithmetic shown in why. Every array is present, empty when there is nothing to say.

Worked replies

Abbreviated from the saved runs the page replays for its examples. The check reply to the Project Harbor body above:

{
  "lane": "check",
  "verdict": "not_ready",
  "headline": "Not ready for the client: FY25 revenue reads $458M in the appendix against $485M elsewhere, the stated 15% growth, 22% margin and segment total do not reconcile, and slide 3 claims margin expansion while its own table shows margins falling from 22.1% to 20.0%.",
  "findings": [
    {
      "severity": "critical",
      "category": "number_consistency",
      "slides": [9, 2, 3],
      "quote": "FY25 revenue of $458M",
      "issue": "Slide 9 gives FY25 revenue as $458M; slides 2 and 3 give $485M ($485MM). The appendix figure is a transposition.",
      "action": "Correct slide 9 to $485M.",
      "fix_from": "$458M",
      "fix_to": "$485M",
      "prescan": "P1"
    },
    {
      "severity": "critical",
      "category": "calculation",
      "slides": [2],
      "quote": "Revenue grew from $410M in FY24 to $485M in FY25, up 15%",
      "issue": "$485M / $410M - 1 = 18.3%, not 15%. The endpoints match slide 3, so the growth rate is the wrong figure.",
      "action": "Change the growth rate to 18%, rounded as the deck rounds whole percentages on this slide.",
      "fix_from": "15%",
      "fix_to": "18%",
      "prescan": "P3"
    },
    {
      "severity": "critical",
      "category": "data_narrative",
      "slides": [3],
      "quote": "Consistent margin expansion over the last three years",
      "issue": "The table on the same slide shows Adj. EBITDA margin falling every year: 22.1% (FY23) to 21.5% (FY24) to 20.0% (FY25).",
      "action": "Replace the caption with a statement that matches the data.",
      "fix_from": "",
      "fix_to": "",
      "prescan": ""
    },
    {
      "severity": "important",
      "category": "language",
      "slides": [2],
      "quote": "We think a sale process could attract a lot of interest from strategic buyers!",
      "issue": "First person, casual phrasing and an exclamation point, none of which meet the IB register.",
      "action": "Rewrite, for example: \"A sale process is expected to attract significant interest from strategic buyers.\"",
      "fix_from": "",
      "fix_to": "",
      "prescan": "P11"
    }
  ],
  "prescan_responses": [
    {"id": "P1", "call": "confirmed", "reason": "$458M on slide 9 is a transposition of the $485M FY25 revenue on slides 2 and 3."},
    {"id": "P3", "call": "confirmed", "reason": "$485M / $410M - 1 = 18.3%; the slide states 15%."}
  ],
  "checklist": {
    "numbers_reconciled": false,
    "narrative_matches_data": false,
    "language_ib_standard": false,
    "charts_sourced": false,
    "formatting_consistent": false
  },
  "summary": "What would embarrass the team: the $458M appendix revenue, the 15% growth rate (actual 18.3%), the 22% margin on slide 5 (actual 20.0%), segments that sum to $477M against a $485M total, and a #1 claim implying 0.4% market share; these block delivery. The slide 7 implied values ($922M-$1,067M) and the $97M Adj. EBITDA are consistent across all slides. Visual QC of layout, chart rendering and fonts remains for a person."
}

The full reply has 17 findings and answers all 14 prescan flags. The refresh reply to the Velloran body above:

{
  "lane": "refresh",
  "headline": "14 values change on 5 slides (2, 3, 4, 5 and 7); the main risk is the $41M total contract value on slide 6, which shares its digits with Adj. EBITDA and must not change.",
  "mapping": [
    {"id": "M1", "reading": "FY26 total revenue moves from $212M to $218M; found on slide 2, the slide 3 total revenue row and the slide 7 footnote ($212.0 million).", "confidence": "high", "proposed_old": ""},
    {"id": "M2", "reading": "FY26 subscription revenue on slide 3 moves from $184M to $190M. With services unchanged at $28M, $190M + $28M = $218M, which ties to M1.", "confidence": "high", "proposed_old": ""},
    {"id": "M3", "reading": "FY26 Adj. EBITDA moves from $41M to $43M on slides 2, 4 (text and chart series) and 7. The $41M total contract value on slide 6 is a different metric.", "confidence": "high", "proposed_old": ""},
    {"id": "M4", "reading": "New-value-only line: FY26 net revenue retention becomes 118%. It replaces 116% on slides 2 and 5; 111% is the FY25 comparative and stays.", "confidence": "high", "proposed_old": "116%"}
  ],
  "skip": [
    {"id": "R8", "reason": "Slide 6 $41M is total contract value signed in FY26, a pipeline metric that happens to equal Adj. EBITDA; it must stay at $41M."}
  ],
  "extra": [
    {"slide": 2, "quote": "Net revenue retention of 116%", "replace_with": "Net revenue retention of 118%", "reason": "FY26 net revenue retention under unresolved mapping M4."},
    {"slide": 5, "quote": "Net revenue retention of 116%", "replace_with": "Net revenue retention of 118%", "reason": "Same figure under M4; the FY25 comparative of 111% on this line stays."}
  ],
  "derived": [
    {"id": "D1", "slide": 2, "quote": "up 16.5% from $182M", "replace_with": "up 19.8% from $182M", "why": "$218M / $182M - 1 = 19.8%. Agrees with the engine suggestion."},
    {"id": "D4", "slide": 4, "quote": "grew 37% from $30M", "replace_with": "grew 43% from $30M", "why": "$43M / $30M - 1 = 43.3%, rounded to a whole percent as the deck does = 43%."},
    {"id": "D5", "slide": 4, "quote": "expanded from 16.5% to 19.3%", "replace_with": "", "why": "The 16.5% here is the FY25 margin, $30M / $182M = 16.5%; neither input moved, so it stays."},
    {"id": "", "slide": 5, "quote": "top 10 customers are 14% of revenue", "replace_with": "", "why": "The denominator moved from $212M to $218M, but the deck does not give top 10 customer revenue; it needs confirming."}
  ],
  "narrative": [
    {"slide": 2, "quote": "Source: Company management accounts", "conflict": "The figures on this slide are being replaced with final audited numbers; the source line still cites management accounts."}
  ],
  "questions": [
    "Confirm that 118% replaces the FY26 net revenue retention of 116% on slides 2 and 5, and that the FY25 comparative of 111% is unchanged.",
    "Confirm that the slide 6 total contract value of $41M stays unchanged (instance R8 is skipped)."
  ],
  "summary": "Revenue, subscription revenue, Adj. EBITDA and net revenue retention change, and the dependent revenue growth (19.8%), Adj. EBITDA margin (19.7%) and Adj. EBITDA growth (43%) are recalculated; the slide 3 table still foots ($190M + $28M = $218M). The slide 6 $41M contract value is skipped. Check every edited slide visually: a longer number can overflow a text box or widen a table."
}

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 findings may be complete while the checklist and summary are missing. The web page closes the cut-off JSON (Recon.closeJson), shows the sections that arrived and says how many it recovered: six for check (verdict, headline, findings, prescan responses, checklist, summary) and eight for refresh (headline, mapping, skip, extra, derived, narrative, questions, summary). From code, check the flag before you treat a reply as complete, then resubmit and increment the attempt suffix on the Idempotency-Key.