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:
| task | what it does |
|---|---|
refresh | Reviews 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. |
check | The 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
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | A 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_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. 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"}}
# Open https://deck-qc-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "deck-qc-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://deck-qc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "deck-qc-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://deck-qc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"deck-qc-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://deck-qc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"deck-qc-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://deck-qc-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "deck-qc-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://deck-qc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "deck-qc-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://deck-qc-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"deck-qc-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
2. A tiny client
One helper that 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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "deck-qc-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://deck-qc-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "deck-qc-desk";
const TOKEN = "YOUR_TOKEN"; // from https://deck-qc-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "deck-qc-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://deck-qc-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class DeckQcDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "deck-qc-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "deck-qc-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://deck-qc-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "deck-qc-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class DeckQcDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "deck-qc-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
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}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await DeckQcDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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):
| task | what it does |
|---|---|
refresh | Reviews the refresh change list: mapping readings, instances to skip, missed instances, derived numbers, narrative conflicts, questions. |
check | The 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.
| field | type | meaning |
|---|---|---|
task | string, required | "refresh" or "check". |
deck | string, required | The 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. |
facts | string, required | The 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. |
question | string, optional | What 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_note | string, optional | Leave 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):
| form | example |
|---|---|
| old -> new (label) | $212M -> $218M (Revenue) |
| label old -> new | Adj. EBITDA $41M -> $43M (also =>, to, becomes) |
| tab-separated | $184M tab $190M tab Subscription revenue (comma-separated columns work too) |
| new value only | Net 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:
| key | contents |
|---|---|
slide_count, slides | How many slides were read (at most 80) and {n, title} for each. |
numbers_found, numbers, numbers_omitted | Every 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_counts | What 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_warnings | Slide-parsing problems: no markers, duplicate slide numbers, text before the first marker, more than 80 slides. |
lane, deck_name | The lane again and the deck name (up to 120 characters). |
slides_not_sent, slides_middle_cut, note_clipped_chars | Present 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. |
refresh | Refresh 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.
INPUT = json.load(open("body.json")) # task, deck, facts, question
def must_be_object(body):
"""The same check as DeckScan.mustBeObject - /estimate will not do it for you."""
if not isinstance(body, dict):
raise ValueError("run input must be a JSON object")
if not isinstance(body.get("task"), str) or not body["task"]:
raise ValueError("run input needs a task string")
if not isinstance(body.get("deck"), str) or not body["deck"]:
raise ValueError("run input needs the deck as a string")
if not isinstance(body.get("facts"), str):
raise ValueError("facts must be a JSON string")
return body
est = call("estimate", must_be_object(INPUT))
print(INPUT["task"], est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est.get("warnings"))
# Free: no job, no charge. The hold is a reservation against the full output
# cap, not the price of the run.
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
const DeckScan = createRequire(import.meta.url)("./deckscan.js");
const INPUT = DeckScan.mustBeObject(JSON.parse(readFileSync("body.json", "utf8")));
const est = await call("estimate", INPUT);
console.log(INPUT.task, est.model, est.model_alias, est.markup_bps, est.hold_credits, est.min_credits, est.warnings);
raw, _ := os.ReadFile("body.json")
var input map[string]any
_ = json.Unmarshal(raw, &input)
// estimate does no body validation, so check the shape here.
lane, _ := input["task"].(string)
deck, _ := input["deck"].(string)
if _, ok := input["facts"].(string); !ok || lane == "" || deck == "" {
panic("run input needs task and deck strings and facts as a JSON string")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(lane, string(est)) // model, model_alias, markup_bps, hold_credits, min_credits, warnings
String input = java.nio.file.Files.readString(java.nio.file.Path.of("body.json"));
// estimate does no body validation: check that task and deck are strings and
// that facts is a JSON *string* (starts with a quote) before you run.
System.out.println(call("estimate", input));
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
// "hold_credits":...,"min_credits":...,"input_checked":true,"warnings":[]}}
INPUT = JSON.parse(File.read("body.json"))
unless INPUT.is_a?(Hash) && INPUT["task"].is_a?(String) && INPUT["deck"].is_a?(String) && INPUT["facts"].is_a?(String)
raise "run input needs task and deck strings and facts as a JSON string"
end
est = call("estimate", INPUT)
puts est.values_at("model", "model_alias", "markup_bps", "hold_credits", "min_credits").inspect
<?php
$input = json_decode(file_get_contents("body.json"), true);
if (!is_array($input) || !is_string($input["task"] ?? null) || !is_string($input["deck"] ?? null) || !is_string($input["facts"] ?? null)) {
throw new RuntimeException("run input needs task and deck strings and facts as a JSON string");
}
$est = call("estimate", $input);
echo $input["task"], " ", $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("body.json"));
if (input.GetProperty("facts").ValueKind != JsonValueKind.String)
throw new Exception("facts must be a JSON string"); // estimate would not tell you
var est = await DeckQcDesk.Call("estimate", input);
Console.WriteLine($"{input.GetProperty("task")} {est.GetProperty("model")} hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
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
import hashlib, time
lane = INPUT["task"] # "check" or "refresh"
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"deck-qc-desk:{lane}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
reply = json.loads(job["output"]["output"])
print(reply["lane"], reply.get("verdict"), reply["headline"])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const lane = INPUT.task; // "check" or "refresh"
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `deck-qc-desk:${lane}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const reply = JSON.parse(job.output.output);
console.log(reply.lane, reply.verdict, reply.headline, job.charged_credits);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("deck-qc-desk:%s:%x:a1", lane, sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"([a-z]+)\".*", "$1");
String key = "deck-qc-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
lane = INPUT["task"]
key = "deck-qc-desk:#{lane}:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
reply = JSON.parse(job["output"]["output"])
puts reply["lane"], reply["headline"]
<?php
$key = "deck-qc-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
$reply = json_decode($job["output"]["output"], true);
echo $reply["lane"], " ", $reply["headline"], PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var lane = input.GetProperty("task").GetString();
var key = $"deck-qc-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await DeckQcDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var reply = JsonSerializer.Deserialize<JsonElement>(job.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine($"{reply.GetProperty("lane")} {reply.GetProperty("headline")}");
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}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
console.log(done, raw.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
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
SEVERITIES = ("critical", "important", "minor")
def parse_reply(text, sent_lane):
t = text.strip()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
lane = r.get("lane") if r.get("lane") in ("check", "refresh") else (
"check" if r.get("findings") or r.get("verdict") else
"refresh" if r.get("mapping") or r.get("skip") else sent_lane)
r["lane"], r["lane_mismatch"] = lane, lane != sent_lane
if lane == "check":
r["findings"] = r.get("findings") or []
for f in r["findings"]:
if f.get("severity") not in SEVERITIES:
f["severity"] = "important"
r["findings"].sort(key=lambda f: SEVERITIES.index(f["severity"]))
r["prescan_responses"] = r.get("prescan_responses") or []
if r.get("verdict") not in ("not_ready", "fix_before_sending", "client_ready"):
sev = {f["severity"] for f in r["findings"]} # derived, as the page does
r["verdict"] = "not_ready" if "critical" in sev else "fix_before_sending" if "important" in sev else "client_ready"
else:
for k in ("mapping", "skip", "extra", "derived", "narrative", "questions"):
r[k] = r.get(k) or []
return r
r = parse_reply(job["output"]["output"], INPUT["task"])
if r["lane"] == "check":
print(r["verdict"], [(f["fix_from"], f["fix_to"]) for f in r["findings"] if f.get("fix_from")])
else:
print([m["id"] + " " + m["confidence"] for m in r["mapping"]], [s["id"] for s in r["skip"]])
// recon.js does all of this, exactly as the page does:
import { createRequire } from "node:module";
const Recon = createRequire(import.meta.url)("./recon.js"); // https://deck-qc-desk.skillsafe.ai/recon.js
const r = Recon.normalize(Recon.parseResult(job.output.output), INPUT.task);
if (r.lane_mismatch) console.warn("the model answered the", r.lane, "lane");
if (r.lane === "check") {
console.log(r.verdict, r.findings.filter((f) => f.fix_from).map((f) => `${f.fix_from} -> ${f.fix_to}`));
} else {
console.log(r.mapping.map((m) => `${m.id} ${m.confidence}`), r.skip.map((s) => s.id));
}
type Reply struct {
Lane string `json:"lane"`
Verdict string `json:"verdict"`
Headline string `json:"headline"`
Findings []struct {
Severity string `json:"severity"`
Category string `json:"category"`
Slides []int `json:"slides"`
Quote string `json:"quote"`
FixFrom string `json:"fix_from"`
FixTo string `json:"fix_to"`
Prescan string `json:"prescan"`
} `json:"findings"`
Mapping []struct {
ID string `json:"id"`
Reading string `json:"reading"`
Confidence string `json:"confidence"`
ProposedOld string `json:"proposed_old"`
} `json:"mapping"`
Skip []struct {
ID string `json:"id"`
Reason string `json:"reason"`
} `json:"skip"`
Summary string `json:"summary"`
}
text := jobOutput // data.output.output from step 5
var r Reply
_ = json.Unmarshal([]byte(text[strings.Index(text, "{"):strings.LastIndex(text, "}")+1]), &r)
fmt.Println(r.Lane, r.Verdict, len(r.Findings), len(r.Mapping), len(r.Skip))
// With Jackson: strip to the outermost object, then read it.
String t = jobOutput.trim();
String obj = t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1);
var r = new com.fasterxml.jackson.databind.ObjectMapper().readTree(obj);
if ("check".equals(r.path("lane").asText())) {
System.out.println(r.get("verdict").asText() + " " + r.get("findings").size() + " findings");
} else {
System.out.println(r.get("mapping").size() + " mapping lines, " + r.get("skip").size() + " skips");
}
t = job["output"]["output"].strip
r = JSON.parse(t[t.index("{")..t.rindex("}")])
if r["lane"] == "check"
puts r["verdict"], r["findings"].select { |f| f["fix_from"].to_s != "" }.map { |f| "#{f['fix_from']} -> #{f['fix_to']}" }.inspect
else
puts r["mapping"].map { |m| "#{m['id']} #{m['confidence']}" }.inspect, r["skip"].map { |s| s["id"] }.inspect
end
<?php
$t = trim($job["output"]["output"]);
$r = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
if (($r["lane"] ?? "") === "check") {
echo $r["verdict"], " ", count($r["findings"]), " findings", PHP_EOL;
} else {
echo count($r["mapping"]), " mapping lines, ", count($r["skip"]), " skips", PHP_EOL;
}
var t = job.GetProperty("output").GetProperty("output").GetString()!.Trim();
var obj = t[t.IndexOf('{')..(t.LastIndexOf('}') + 1)];
var r = JsonSerializer.Deserialize<JsonElement>(obj);
if (r.GetProperty("lane").GetString() == "check")
Console.WriteLine($"{r.GetProperty("verdict")} {r.GetProperty("findings").GetArrayLength()} findings");
else
Console.WriteLine($"{r.GetProperty("mapping").GetArrayLength()} mapping lines, {r.GetProperty("skip").GetArrayLength()} skips");
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:
- Check: every cited slide exists, and every
quoteis found on the slide it cites (whitespace and case aside). - Check: every
fix_fromis a figure on the cited slide. - Check:
prescan_responsesanswers everyfacts.prescan_flagsid exactly once and names no other; aconfirmedflag also appears as a finding withprescanset to its id; a dismissed critical flag deserves a second look. - Check: the verdict follows from the severities (any critical finding:
not_ready; otherwise any important:fix_before_sending; otherwiseclient_ready), and the deck is neverclient_readywhile a critical prescan flag is not dismissed. - Check: no checklist item is
truewhile a critical or important finding of its kind remains (numbers: number_consistency, calculation; narrative: data_narrative, factual; language: language, terminology; charts: sources; formatting: formatting, period_labels). - Refresh:
mappingreads every pair infacts.refresh.pairs; aproposed_oldappears only on a new-value-only line and is a figure in the deck. - Refresh: every
skipid is an instance infacts.refresh.instances; everyderivedid is infacts.refresh.derived(new entries have an empty id). - Refresh: every
extra,derivedandnarrativequote is on its slide, theextraandderivedquotes contain a figure, and anextradoes not repeat a listed instance. - Refresh: a recalculation agrees with the browser's
engine_suggestion(within 0.05 points), and none is offered when the policy isflag.
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.