Drive Three-Statement Desk from your own code
Everything the web page does is available over HTTP. Send the model the browser builds from a
company's historical statements and get one of two replies back, chosen by the task
field: drivers (every projection assumption proposed from the history and your
guidance notes, each with its basis and, for guidance, a verbatim quote) or review
(a verdict on the built model, what it depends on, findings, one response per flag and questions).
The natural pipeline is the page's own: propose the drivers, apply them, rebuild, then review.
One thing to be clear about before the first call: the model never does the arithmetic.
The statements are read and the projection is built by tsm.js, the same file the web page
loads, and the result is sent as facts, a JSON string. See
building the facts below.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest endpoint takes {"slug":"three-statement-desk"} in its
body), so no slug header is needed afterwards. Send your token as Authorization: Bearer …
on every call.
The input object IS the request body. There is no {"input": …} wrapper.
A wrapped body returns a 200 with an unknown field 'input' warning, and the model never
sees your facts.
Error codes
| 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 steps are metered, so it needs a personal token from
signing in.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://three-statement-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; running either step needs a personal token
# from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"three-statement-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://three-statement-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 run a metered review.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "three-statement-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://three-statement-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 run a metered review.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "three-statement-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://three-statement-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 run a metered review.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"three-statement-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://three-statement-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 run a metered review.
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\":\"three-statement-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://three-statement-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 run a metered review.
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: "three-statement-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://three-statement-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 run a metered review.
$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" => "three-statement-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://three-statement-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 run a metered review.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"three-statement-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="three-statement-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://three-statement-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "three-statement-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://three-statement-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 = "three-statement-desk";
const TOKEN = "YOUR_TOKEN"; // from https://three-statement-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 = "three-statement-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://three-statement-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 ThreeStatementDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "three-statement-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 = "three-statement-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://three-statement-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 = "three-statement-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 ThreeStatementDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "three-statement-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 ThreeStatementDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Choose the lane and price it (free)
The input object is exactly what the app's form submits. The first field is
task, and it picks the lane. A missing or unknown task is answered
by the closer lane (a projection in the facts means review, otherwise drivers) and the
reply's lane names the lane it chose. Estimate each lane separately: the prompts and the
facts differ, so the holds differ.
| task | what it does | facts come from |
|---|---|---|
drivers | Step 1. Proposes every driver in facts.fields: value, basis (guidance, history or judgement), rationale and a verbatim quote from notes for guidance; plus cautions, questions and a summary. | TSM.buildDriversInput(res, company, notes, question) - the history and the allowed fields, not the current drivers |
review | Step 2. Reviews the built model: verdict (sound, needs_work, not_credible), headline, what it depends on, findings, flag responses, a checks note, questions and a summary. | TSM.buildReviewInput(res, company, notes, question) - drivers, every projected year, summary, checks and flags |
| field | type | meaning |
|---|---|---|
task | string, required | "drivers" or "review" |
facts | string, required | The JSON-encoded facts for that lane (see the table above). Always a string, never an object. |
notes | string | Guidance or notes, up to 12,000 characters. A longer text keeps its head and tail with a marker where the middle was cut, and facts.notes_clipped_chars says how much. |
question | string | What you want to know, up to 2,000 characters. May be empty. |
retry_note | string | Only when resubmitting after an unparseable reply: a plain instruction about the reply's shape. |
The app declares an input schema with task and facts required, so an estimate
of an empty body comes back with missing required field warnings. A warning is not a
rejection, and /estimate does no other body validation, so check the shape yourself. The
web app runs every input through TSM.mustBeObject first.
Building the facts
tsm.js is plain JavaScript with no dependencies and exports itself to node. Download
tsm.js next to your script. The page's Save workspace .json
button writes {"company","table","notes","question","drivers"}; this script turns that
file into a request body for either lane:
// make-body.js - node make-body.js workspace.json drivers|review > body.json
const fs = require("fs");
const TSM = require("./tsm.js"); // https://three-statement-desk.skillsafe.ai/tsm.js
const w = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const lane = process.argv[3] || "drivers";
const res = TSM.compute(w.table, w.drivers || {});
if (lane === "review" && !res.ok) throw new Error(res.errors.join(" "));
if (lane === "drivers" && !res.hist) throw new Error(res.errors.join(" "));
const body = lane === "drivers"
? TSM.buildDriversInput(res, w.company, w.notes, w.question)
: TSM.buildReviewInput(res, w.company, w.notes, w.question);
process.stdout.write(JSON.stringify(TSM.mustBeObject(body)));
To go from a drivers reply to a review, apply the proposals to the drivers and rebuild, exactly as
the page's Apply, then review the model button does:
w.drivers = TSM.applyProposal(reply.proposals, w.drivers).raw, then build the
review body. Values outside a field's range are clamped and reported.
Worked example: drivers (the bundled Ostrevan Components case, abbreviated)
{
"task": "drivers",
"facts": "{\"units\":\"$ millions unless stated; ratios per historical year, oldest first\",\"company\":\"Ostrevan Components Inc.\",\"history\":{\"years\":[\"FY2023\",\"FY2024\",\"FY2025\"],\"lines\":{\"revenue\":...",
"notes": "Q4 FY2025 earnings call - prepared remarks (CFO) Looking ahead to fiscal 2026, we expect organic revenue growth of 6% t...",
"question": "What does the FY2026 guidance imply for the five-year drivers?"
}
Worked example: review (the same case after applying the drivers, abbreviated)
{
"task": "review",
"facts": "{\"units\":\"$ millions unless stated; P1 is the first projected year after FY2025\",\"company\":\"Ostrevan Components Inc.\",\"last_historical_year\":\"FY2025\",\"history\":{\"years\":[\"FY2023\",\"FY...",
"notes": "Q4 FY2025 earnings call - prepared remarks (CFO) Looking ahead to fiscal 2026, we expect organic revenue growth of 6% t...",
"question": "What does the FY2026 guidance imply for the five-year drivers?"
}
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or take the worked example from this page.
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
# "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is what gets
# RESERVED; charged_credits after settlement is normally much lower.
INPUT = json.load(open("body.json")) # task, facts, question
est = call("estimate", INPUT)
print(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";
const INPUT = JSON.parse(readFileSync("body.json", "utf8"));
const est = await call("estimate", INPUT);
console.log(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)
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(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"));
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"))
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);
$est = call("estimate", $input);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("body.json"));
var est = await ThreeStatementDesk.Call("estimate", input);
Console.WriteLine($"{est.GetProperty("model")} hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
5. Run it, then poll
POST /run returns a job_id; poll GET jobs/{job_id} until
status is succeeded or failed. The reply is the string at
data.output.output. The terminal job also carries charged_credits (the real
price) and the truncated flag.
Always send an Idempotency-Key. Derive it from the input as the web
app does, with the lane and an attempt counter: three-statement-desk:<task>:<hash>:a1 (the lane id is part of the key, so the two steps over one paste never collide).
A retried request with the same key returns the same job instead of billing a second run.
Replaying a key with a different body is a 409, so bump the attempt suffix when you resend a
changed body. The web app uses a short hash of the input JSON; any stable hash works, the samples
below use the first 16 hex digits of a SHA-256.
If the reply cannot be parsed as one JSON object, the web app retries exactly once: it adds a
retry_note field to the same input (a plain instruction to reply with only the JSON
object for the same task, every array present) and sends it with the attempt suffix bumped
to :a2, so the reformat retry is a distinct, separately billed run. Do the same from code.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="three-statement-desk:review:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"lane\":\"review\",\"verdict\":\"stretched\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > review.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"three-statement-desk:review:{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"))
review = json.loads(job["output"]["output"])
print(review["verdict"], [f["field"] for f in review["findings"]])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `three-statement-desk:review:${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 review = JSON.parse(job.output.output);
console.log(review.verdict, review.findings.map((f) => f.field), job.charged_credits);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("three-statement-desk:review:%x:a1", 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()
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" {
fmt.Println(job.Output.Output, job.Charged)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "three-statement-desk:review:" + 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.
require "digest"
key = "three-statement-desk:review:#{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"
review = JSON.parse(job["output"]["output"])
puts review["verdict"], review["findings"].map { |f| f["field"] }.inspect
<?php
$key = "three-statement-desk:review:" . 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"]);
}
$review = json_decode($job["output"]["output"], true);
echo $review["verdict"], PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var key = "three-statement-desk:review:" + 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 ThreeStatementDesk.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 review = JsonSerializer.Deserialize<JsonElement>(job.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine(review.GetProperty("verdict"));
6. Or stream it
POST /run-stream is the same call over server-sent events. Each delta event
carries {"text": "..."}, a chunk of the reply, and the final done event
carries status, charged_credits and truncated. A browser
client may receive progress ticks rather than text deltas; the finished job from step 5 always has
the whole reply.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"lane\":\"review\",\"verdict\":\"stretched\","}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
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 for the lane it asked for: an unknown verdict falls back to
needs_work, an unknown severity to medium, missing arrays become empty. A
drivers reply with no proposals array, or a review reply with neither
findings nor flag_responses, counts as unparseable and triggers the one
retry_note retry. Then it checks the reply against what it sent - every guidance quote
must be word for word in the notes, every history value must equal the history average, the review's
verdict must follow from the checks and flag severities, and every number must appear in the facts or
the notes. You should do the same.
# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("reply.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r.get("lane"), "-", r["headline"])
if r.get("lane") == "drivers":
for p in r["proposals"]:
print(p["field"], p["value"], p["basis"], p["quote"])
else:
print("verdict:", r["verdict"])
for f in r["findings"]:
print(f["severity"], f["area"], f["field"], f["issue"])
EOF
text = job["output"]["output"]
r = json.loads(text[text.index("{"):text.rindex("}") + 1])
if r.get("lane") == "drivers":
notes = INPUT["notes"]
for p in r["proposals"]:
# A guidance quote must be word for word in the notes you sent.
ok = p["basis"] != "guidance" or " ".join(p["quote"].split()).lower() in " ".join(notes.split()).lower()
print(p["field"], p["value"], p["basis"], "quote ok" if ok else "QUOTE NOT IN NOTES")
else:
print(r["verdict"], "-", r["headline"])
for f in r["findings"]:
print(f["severity"], f["area"], f["field"], f["issue"])
const text = job.output.output;
const r = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
if (r.lane === "drivers") {
for (const p of r.proposals) console.log(p.field, p.value, p.basis, p.quote);
} else {
console.log(r.verdict, "-", r.headline);
for (const f of r.findings) console.log(f.severity, f.area, f.field, f.issue);
}
text := job.Output.Output
obj := text[strings.Index(text, "{") : strings.LastIndex(text, "}")+1]
var r map[string]any
if err := json.Unmarshal([]byte(obj), &r); err != nil {
panic(err)
}
fmt.Println(r["lane"], "-", r["headline"])
if r["lane"] == "drivers" {
for _, p := range r["proposals"].([]any) {
m := p.(map[string]any)
fmt.Println(m["field"], m["value"], m["basis"])
}
} else {
fmt.Println("verdict:", r["verdict"])
}
String text = job.getJSONObject("output").getString("output");
JSONObject r = new JSONObject(text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1));
System.out.println(r.optString("lane") + " - " + r.getString("headline"));
if ("drivers".equals(r.optString("lane"))) {
for (Object o : r.getJSONArray("proposals")) {
JSONObject p = (JSONObject) o;
System.out.println(p.getString("field") + " " + p.get("value") + " " + p.getString("basis"));
}
} else {
System.out.println("verdict: " + r.getString("verdict"));
}
text = job["output"]["output"]
r = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{r["lane"]} - #{r["headline"]}"
if r["lane"] == "drivers"
r["proposals"].each { |p| puts [p["field"], p["value"], p["basis"]].join(" ") }
else
puts "verdict: #{r["verdict"]}"
r["findings"].each { |f| puts [f["severity"], f["area"], f["field"]].join(" ") }
end
<?php
$text = $job["output"]["output"];
$r = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1), true);
echo $r["lane"], " - ", $r["headline"], PHP_EOL;
if ($r["lane"] === "drivers") {
foreach ($r["proposals"] as $p) echo $p["field"], " ", $p["value"], " ", $p["basis"], PHP_EOL;
} else {
echo "verdict: ", $r["verdict"], PHP_EOL;
}
var text = job.GetProperty("output").GetProperty("output").GetString()!;
var obj = text.Substring(text.IndexOf('{'), text.LastIndexOf('}') - text.IndexOf('{') + 1);
var r = JsonDocument.Parse(obj).RootElement;
Console.WriteLine($"{r.GetProperty("lane")} - {r.GetProperty("headline")}");
if (r.GetProperty("lane").GetString() == "drivers")
foreach (var p in r.GetProperty("proposals").EnumerateArray())
Console.WriteLine($"{p.GetProperty("field")} {p.GetProperty("value")} {p.GetProperty("basis")}");
else
Console.WriteLine($"verdict: {r.GetProperty("verdict")}");
The output contract
Both lanes share one envelope - lane, headline, the lane's arrays, questions and summary - and use empty arrays, never null.
drivers
{
"lane": "drivers",
"headline": "The fiscal 2026 guidance sets most drivers directly: revenue growth inside 6% to 7%, gross margin up roughly 20 to 30 basis points, capex at about 4.5% of sales, $20 million of annual debt repayment, a payout of about 30% and a tax rate of 24% to 25%, with the rest held at historical averages.",
"proposals": [
{
"field": "rev_growth",
"value": 6.5,
"basis": "guidance",
"rationale": "Management guides organic revenue growth of 6% to 7%; the value sits inside that range and close to the 6.3% history average.",
"quote": "we expect organic revenue growth of 6% to 7%"
},
{
"field": "gross_margin",
"value": 34.3,
"basis": "guidance",
"rationale": "Guidance calls for expansion of roughly 20 to 30 basis points from the FY2025 gross margin of 34.1%; the value sits inside that band and above the 33.8% history average.",
"quote": "We expect gross margin to expand modestly, roughly 20 to 30 basis points, as those pricing actions annualise and freight costs normalise."
}
],
"cautions": [
"The guidance speaks only to fiscal 2026, yet each driver is held flat across every projected year, so the later years rest on extending a single year of outlook."
],
"questions": [
"Does the outlook for organic revenue growth of 6% to 7% extend beyond fiscal 2026, and is any acquired revenue expected?"
],
"summary": "The fiscal 2026 guidance implies growth inside 6% to 7%, modest gross margin expansion, capex of about 4.5% of sales, $20 million of debt repaid each year, a payout of about 30% and no buybacks, with working capital days held where they have been. Because the guidance covers only fiscal 2026, applying it to every projected year is a judgement the model owner should confirm."
}
proposals holds exactly one entry per key of facts.fields, in order:
rev_growth, gross_margin, sga_pct, da_pct, capex_pct, dso, dio, dpo, oca_pct, acc_pct, tax_rate,
debt_rate, cash_rate, debt_repay, payout_pct, buybacks. value is a bare number in the
field's unit (percentages as points, days, $ millions). basis is guidance (quote required,
verbatim from notes), history (value equals the history average) or judgement.
review
{
"lane": "review",
"verdict": "sound",
"headline": "The model is sound, with every integrity check passing and no flags, but it carries the FY2026 guidance unchanged into every year from P1 to P5, so cash builds to $301.2m by P5 with nowhere to go.",
"depends_on": "The outcome rests on revenue growth of 6.5% and a gross margin of 34.3% held flat in every projected year, which lift the EBITDA margin from 17.1% to 17.4%. Cash from operations of $120.1m in P1 rising to $160.1m in P5 funds capex at 4.5% of sales, dividends at a 30.0% payout and $20.0m of debt repaid each year. Everything left over accumulates as cash, giving cumulative free cash flow of $449.8m.",
"findings": [
{
"severity": "medium",
"area": "margins",
"field": "gross_margin",
"issue": "Gross margin of 34.3% against 34.1% in FY2025 reflects the guided expansion of 20 to 30 basis points, but that guidance covers fiscal 2026 only; the model holds the higher margin in every year through P5.",
"fix": "Confirm whether the pricing gains are expected to persist beyond fiscal 2026, or hold the margin nearer the 33.8% history average after P1 as a sensitivity."
},
{
"severity": "medium",
"area": "revenue",
"field": "rev_growth",
"issue": "Growth of 6.5% sits inside the 6% to 7% guided for fiscal 2026 and above the 6.3% history average, yet it is applied to every year, taking revenue to $1,243.8m by P5 on one year of guidance.",
"fix": "Treat 6.5% as the P1 assumption and consider fading later years toward the 6.3% history average unless management gives a longer-range view."
}
],
"flag_responses": [],
"checks_note": "All integrity checks pass: the historical balance sheets and net income rebuild, and the projected balance sheet balances, cash ties and equity and PP&E roll forward every year.",
"questions": [
"Does management expect the fiscal 2026 growth of 6% to 7% and the gross margin expansion to continue beyond fiscal 2026?"
],
"summary": "The FY2026 guidance implies drivers of 6.5% growth, a 34.3% gross margin, capex at 4.5% of sales, a 24.5% tax rate, a 30.0% payout and $20.0m of annual debt repayment, and the model applies each of them unchanged from P1 to P5 even though the guidance covers only one year. The model is sound because every check passes and no flags were raised, but its later years depend on that one-year guidance holding and on an idle cash build that earns nothing."
}
verdict: not_credible if any check fails or any flag is high severity;
otherwise needs_work if any flag is medium; otherwise sound.
area is one of revenue, margins, working_capital, capex, financing, cash, tax, integrity;
field is a driver id or empty. flag_responses has exactly one entry per flag
code, in order.
The flag codes
| code | severity | raised when |
|---|---|---|
liquidity_shortfall | high | The revolver needed exceeds the revolver commitment you entered. |
revolver_above_commitment | high | The revolver drawn at the start is already above the commitment you entered, before any projected year. |
negative_equity | high | Equity turns negative in a projected year. |
negative_ebitda | high | EBITDA is zero or negative in a projected year. |
hist_bs_unbalanced | high | The latest historical balance sheet does not balance; the gap is carried as an unreconciled line. |
net_loss | medium | Net income is negative in a projected year. |
revolver_draw | medium | Cash falls below the minimum and the revolver is drawn (inside its commitment). |
growth_above_history | medium | Projected growth averages at least 5 points above the historical average. |
margin_expansion | medium | The final EBITDA margin is at least 3 points above the last historical year. |
wc_days_shift | medium | DSO, DIO or DPO differs from its historical average by more than 15 days. |
high_leverage | medium | Net debt exceeds 4.0x EBITDA in a projected year. |
low_coverage | medium | EBITDA covers interest less than 2.0x in a projected year. |
hist_ni_mismatch | medium | Reported net income does not rebuild from the pasted lines. |
capex_below_da | low | Capex is below D&A, so PP&E shrinks. |
payout_above_earnings | low | Dividends and buybacks exceed net income in a projected year. |
tax_rate_shift | low | The tax rate differs from the historical effective rate by more than 10 points. |
idle_cash | low | Cash builds past 25% of final-year revenue with no use modelled. |
single_year_history | low | Only one historical year was pasted. |
capex_derived | low | Historical capex was derived from the PP&E roll-forward. |
Truncation and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused. It executes with a reduced output cap and comes back with truncated: true. What
you hold then is a prefix of the reply. The web page closes the cut-off JSON, shows the sections that
arrived and says how many it recovered (five for drivers, eight for review). From code, check the flag
before you treat a reply as complete, then resubmit and increment the attempt suffix on the
Idempotency-Key.