← Quickfire Sums / API
Tokens

Drive Quickfire Sums from your own code

Everything the coach does on the page is available over HTTP. A natural use is a classroom or a tutoring tool that keeps its own rounds: ask for a fresh, level-appropriate item set for this week's topic, play it in your own interface, then send the round back for a debrief of the misses.

One thing to be clear about first: the page never trusts the model's arithmetic. Every item comes with a check expression; the page evaluates it with its own parser and drops any item whose answer disagrees. Over the API nothing has checked the reply yet — the page's own checker ships as a plain script you can load in node, see the end of the examples.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest call takes {"slug":"quickfire-sums"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.

Errors:

StatusCodeMeaning
400validation_errorThe body is not a JSON object.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the coach is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

One system prompt serves two tasks. task picks the contract; anything else is read as the closer task and the reply's own task names the one it chose.

taskWhenWhat comes back
generate with mode: "topic"A topic the bank does not cover, or levels 4 and 5.count items, each with an exact answer and a check expression.
generate with mode: "misses"After a round: another round on exactly the traps that caught the learner.At least one variant per miss, each naming the miss it varies in source_id.
debriefAfter a round with misses (or a clean one, for the pace note).One card per item the round marks wrong, timed out or skipped — and none for anything else.

The declared input schema is scalar-only, so every list travels as a JSON string. task is required (/estimate warns when it is missing):

FieldTaskWhat it holds
taskboth"generate" or "debrief".
modebothgenerate: "topic" or "misses". debrief: the round's clock, "ten" or "sprint".
topicbothFree text, e.g. Restaurant tips, tax and splitting the bill, or a bank topic title. The page cuts the middle past 120 characters.
levelbothNumber 1-5 (the bank covers 1-3).
countgenerateNumber 6-20: how many items to write.
level_guidegenerateThe page's sentence describing the level and the seconds it allows.
bank_topicgenerateThe bank topic id (add, mul, pct, …) when the topic is one of the page's own, else empty.
missesgenerateJSON string: [{"id","prompt","answer","given","result"}], at most 12 (the newest); [] in topic mode.
avoidgenerateJSON string: prompts already seen (at most the newest 60); none may be repeated.
rounddebriefJSON string: the round's items in order, [{"id","prompt","answer","given","result","ms","limit_ms"}], result one of correct, wrong, timeout, skipped. Every miss plus the latest right answers, 40 items at most.
statsdebriefJSON string: answered, correct, wrong, timeouts, skipped, accuracy, best_streak, median_ms_right, points.
misses_countdebriefNumber of missed items in the whole round.
learner_note, retry_notebothOptional. The page sends retry_note only on its one automatic reformat retry.

The reply text is one JSON object:

generate:
{ "task": "generate", "title": "...", "level_label": "...",
  "items": [{ "prompt": "48 × 25", "answer": "1200", "check": "48*25", "choices": [] or [4 numeric strings], "why": "...", "source_id": "" or a miss id }],
  "notes": ["..."] }

debrief:
{ "task": "debrief", "headline": "...",
  "misses": [{ "id": "mul-3-27", "slip": "...", "rule": "...", "mnemonic": "...", "worked": "3 × 37 = 90 + 21 = 111" }],
  "speed": "...", "next": ["..."] }

check uses only digits, ., + - * /, ^, parentheses and sqrt(...). answer is an integer, a terminating decimal with at most 3 places, or a lowest-terms fraction such as 5/6. The page rejects an item when the check does not evaluate to the answer, when a plain-arithmetic prompt works out to something else, when its choices repeat a value or omit the answer, or when its prompt repeats one already seen.

Worked examples

These are the bundled examples on the page: the exact run body the page builds and a reply produced against the shipped prompt (the page replays the same replies for free, and re-checks them like any live reply).

generate, topic mode: restaurant tips, tax and splitting the bill, level 2

Input:

{
 "task": "generate",
 "mode": "topic",
 "topic": "Restaurant tips, tax and splitting the bill",
 "level": 2,
 "count": 12,
 "level_guide": "Level 2 - fluent: two-digit numbers or one extra step; an adult who practises answers in under 8 seconds.",
 "bank_topic": "",
 "misses": "[]",
 "avoid": "[]"
}

Reply:

{
 "task": "generate",
 "title": "Tips, tax and split bills",
 "level_label": "level 2 - percent of a bill plus one extra step",
 "items": [
  {
   "prompt": "A 40 € bill with a 15% tip: total?",
   "answer": "46",
   "check": "40+40*15/100",
   "choices": [],
   "why": "10% is 4, half of that is 2, so the tip is 6 and the total 46.",
   "source_id": ""
  },
  {
   "prompt": "A 20% tip on a 35 € bill: how much is the tip?",
   "answer": "7",
   "check": "35*20/100",
   "choices": [],
   "why": "20% is a fifth: 35 ÷ 5 = 7.",
   "source_id": ""
  },
  {
   "prompt": "A 90 € bill split evenly between 4 people: what does each pay?",
   "answer": "22.5",
   "check": "90/4",
   "choices": [],
   "why": "Halve twice: 90, 45, 22.5.",
   "source_id": ""
  },
  {
   "prompt": "A 50 € meal plus 10% tax: total?",
   "answer": "55",
   "check": "50+50*10/100",
   "choices": [],
   "why": "10% is one place-value shift: 5, so the total is 55.",
   "source_id": ""
  },
  {
   "prompt": "An 18% tip on a 50 € bill: how much is the tip?",
   "answer": "9",
   "check": "50*18/100",
   "choices": [
    "9",
    "18",
    "5",
    "0.9"
   ],
   "why": "Any percent of 50 is half the percent: 18 ÷ 2 = 9.",
   "source_id": ""
  },
  {
   "prompt": "A 120 € dinner with a 10% tip, split between 3: what does each pay?",
   "answer": "44",
   "check": "(120+120*10/100)/3",
   "choices": [],
   "why": "Total first, 120 + 12 = 132, then 132 ÷ 3 = 44.",
   "source_id": ""
  },
  {
   "prompt": "A 15% tip on an 80 € bill: how much is the tip?",
   "answer": "12",
   "check": "80*15/100",
   "choices": [],
   "why": "10% is 8 and 5% is 4, so 15% is 12.",
   "source_id": ""
  },
  {
   "prompt": "A 25 € lunch with a 20% tip: total?",
   "answer": "30",
   "check": "25+25*20/100",
   "choices": [],
   "why": "Adding 20% is multiplying by 1.2: a fifth of 25 is 5, so 30.",
   "source_id": ""
  },
  {
   "prompt": "5% tax on a 70 € bill: how much is the tax?",
   "answer": "3.5",
   "check": "70*5/100",
   "choices": [],
   "why": "5% is half of 10%: 7 halved is 3.5.",
   "source_id": ""
  },
  {
   "prompt": "Four friends share a 66 € bill plus a 14 € tip equally: what does each pay?",
   "answer": "20",
   "check": "(66+14)/4",
   "choices": [],
   "why": "Add first to a round number, 80, then divide: 80 ÷ 4 = 20.",
   "source_id": ""
  },
  {
   "prompt": "A 45 € bill with a 20% tip, split between 2: what does each pay?",
   "answer": "27",
   "check": "(45+45*20/100)/2",
   "choices": [],
   "why": "20% of 45 is 9, total 54, half of 54 is 27.",
   "source_id": ""
  },
  {
   "prompt": "A 30 € meal plus 8% tax: total?",
   "answer": "32.4",
   "check": "30+30*8/100",
   "choices": [],
   "why": "8% of 30 is 8 × 0.3 = 2.4, so the total is 32.4.",
   "source_id": ""
  }
 ],
 "notes": [
  "Practises finding a tip or tax as a percent of a bill, adding it on, and sharing the total evenly.",
  "One multiple-choice item offers the usual slips: forgetting to halve, or misplacing the decimal point."
 ]
}

generate, topic mode: squaring numbers near 50 and 100, level 4 (beyond the bank)

Input:

{
 "task": "generate",
 "mode": "topic",
 "topic": "Squaring numbers near 50 and near 100",
 "level": 4,
 "count": 10,
 "level_guide": "Level 4 - strong: tricks a competition student uses (complements, near-squares, percent reversals); about 15 seconds.",
 "bank_topic": "",
 "misses": "[]",
 "avoid": "[]"
}

Reply:

{
 "task": "generate",
 "title": "Squares near 50 and 100",
 "level_label": "level 4 - near-square tricks around 50 and 100",
 "items": [
  {
   "prompt": "48²",
   "answer": "2304",
   "check": "48^2",
   "choices": [],
   "why": "Near 50: (25 − 2) hundreds plus 2² gives 2300 + 4.",
   "source_id": ""
  },
  {
   "prompt": "53²",
   "answer": "2809",
   "check": "53^2",
   "choices": [],
   "why": "Near 50: (25 + 3) hundreds plus 3² gives 2800 + 9.",
   "source_id": ""
  },
  {
   "prompt": "97²",
   "answer": "9409",
   "check": "97^2",
   "choices": [],
   "why": "Near 100: 97 − 3 = 94 hundreds, then 3² = 09.",
   "source_id": ""
  },
  {
   "prompt": "103²",
   "answer": "10609",
   "check": "103^2",
   "choices": [],
   "why": "Near 100: 103 + 3 = 106 hundreds, then 3² = 09.",
   "source_id": ""
  },
  {
   "prompt": "47²",
   "answer": "2209",
   "check": "47^2",
   "choices": [
    "2209",
    "2491",
    "2109",
    "2249"
   ],
   "why": "Near 50: (25 − 3) hundreds plus 3² gives 2200 + 9.",
   "source_id": ""
  },
  {
   "prompt": "52²",
   "answer": "2704",
   "check": "52^2",
   "choices": [],
   "why": "Near 50: (25 + 2) hundreds plus 2² gives 2700 + 4.",
   "source_id": ""
  },
  {
   "prompt": "96²",
   "answer": "9216",
   "check": "96^2",
   "choices": [],
   "why": "Near 100: 96 − 4 = 92 hundreds, then 4² = 16.",
   "source_id": ""
  },
  {
   "prompt": "106²",
   "answer": "11236",
   "check": "106^2",
   "choices": [],
   "why": "Near 100: 106 + 6 = 112 hundreds, then 6² = 36.",
   "source_id": ""
  },
  {
   "prompt": "49²",
   "answer": "2401",
   "check": "49^2",
   "choices": [],
   "why": "Near 50: (25 − 1) hundreds plus 1² gives 2400 + 1.",
   "source_id": ""
  },
  {
   "prompt": "104²",
   "answer": "10816",
   "check": "104^2",
   "choices": [
    "10816",
    "10016",
    "10416",
    "10806"
   ],
   "why": "Near 100: 104 + 4 = 108 hundreds, then 4² = 16.",
   "source_id": ""
  }
 ],
 "notes": [
  "Near 50: take 25 plus or minus the distance as hundreds, then add the distance squared.",
  "Near 100: add or take the distance to get the hundreds, then write the distance squared as the last two digits."
 ]
}

generate, misses mode: variants of three times-table misses

Input:

{
 "task": "generate",
 "mode": "misses",
 "topic": "Times tables",
 "level": 3,
 "count": 6,
 "level_guide": "Level 3 - stretch: multi-step or three-digit work; needs a known shortcut to answer in about 12 seconds.",
 "bank_topic": "mul",
 "misses": "(a JSON string - decoded below)",
 "avoid": "(a JSON string - decoded below)"
}

misses, decoded:

[
 {
  "id": "mul-3-27",
  "prompt": "3 × 37",
  "answer": "111",
  "given": "101",
  "result": "wrong"
 },
 {
  "id": "mul-3-28",
  "prompt": "16 × 8",
  "answer": "128",
  "given": "",
  "result": "timeout"
 },
 {
  "id": "mul-3-31",
  "prompt": "3 × 36",
  "answer": "108",
  "given": "105",
  "result": "wrong"
 }
]

avoid, decoded:

[
 "7 × 13",
 "31 × 3",
 "13 × 4",
 "3 × 37",
 "16 × 9",
 "31 × 5",
 "16 × 8",
 "9 × 24",
 "3 × 36",
 "15 × 4"
]

Reply:

{
 "task": "generate",
 "title": "Carries and doubling in times tables",
 "level_label": "level 3 - two-digit by one-digit with a carry",
 "items": [
  {
   "prompt": "37 × 6",
   "answer": "222",
   "check": "37*6",
   "choices": [],
   "why": "3 × 37 = 111, so 6 × 37 is double that: 222.",
   "source_id": "mul-3-27"
  },
  {
   "prompt": "3 × 47",
   "answer": "141",
   "check": "3*47",
   "choices": [],
   "why": "Split the tens: 120 + 21 = 141, keeping the carried ten.",
   "source_id": "mul-3-27"
  },
  {
   "prompt": "16 × 7",
   "answer": "112",
   "check": "16*7",
   "choices": [],
   "why": "10 × 7 plus 6 × 7: 70 + 42 = 112.",
   "source_id": "mul-3-28"
  },
  {
   "prompt": "8 × 17",
   "answer": "136",
   "check": "8*17",
   "choices": [],
   "why": "Times 8 is doubling three times: 34, 68, 136.",
   "source_id": "mul-3-28"
  },
  {
   "prompt": "3 × 46",
   "answer": "138",
   "check": "3*46",
   "choices": [],
   "why": "Split the tens: 120 + 18 = 138; 3 × 6 is 18, not 15.",
   "source_id": "mul-3-31"
  },
  {
   "prompt": "36 × 6",
   "answer": "216",
   "check": "36*6",
   "choices": [],
   "why": "30 × 6 plus 6 × 6: 180 + 36 = 216.",
   "source_id": "mul-3-31"
  }
 ],
 "notes": [
  "Two variants per miss: the dropped carry in 3 × 37, the slow 16 × 8, and the 3 × 6 slip in 3 × 36."
 ]
}

debrief: a times-table round with two wrong answers and a time-out

Input:

{
 "task": "debrief",
 "topic": "Times tables",
 "level": 3,
 "mode": "ten",
 "round": "(a JSON string - decoded below)",
 "stats": "(a JSON string - decoded below)",
 "misses_count": 3
}

round, decoded:

[
 {
  "id": "mul-3-2",
  "prompt": "7 × 13",
  "answer": "91",
  "given": "91",
  "result": "correct",
  "ms": 4100,
  "limit_ms": 14000
 },
 {
  "id": "mul-3-34",
  "prompt": "31 × 3",
  "answer": "93",
  "given": "93",
  "result": "correct",
  "ms": 5200,
  "limit_ms": 14000
 },
 {
  "id": "mul-3-12",
  "prompt": "13 × 4",
  "answer": "52",
  "given": "52",
  "result": "correct",
  "ms": 3900,
  "limit_ms": 14000
 },
 {
  "id": "mul-3-27",
  "prompt": "3 × 37",
  "answer": "111",
  "given": "101",
  "result": "wrong",
  "ms": 6100,
  "limit_ms": 14000
 },
 {
  "id": "mul-3-35",
  "prompt": "16 × 9",
  "answer": "144",
  "given": "144",
  "result": "correct",
  "ms": 4700,
  "limit_ms": 14000
 },
 {
  "id": "mul-3-32",
  "prompt": "31 × 5",
  "answer": "155",
  "given": "155",
  "result": "correct",
  "ms": 6900,
  "limit_ms": 14000
 },
 {
  "id": "mul-3-28",
  "prompt": "16 × 8",
  "answer": "128",
  "given": "",
  "result": "timeout",
  "ms": 14000,
  "limit_ms": 14000
 },
 {
  "id": "mul-3-9",
  "prompt": "9 × 24",
  "answer": "216",
  "given": "216",
  "result": "correct",
  "ms": 5600,
  "limit_ms": 14000
 },
 {
  "id": "mul-3-31",
  "prompt": "3 × 36",
  "answer": "108",
  "given": "105",
  "result": "wrong",
  "ms": 7300,
  "limit_ms": 14000
 },
 {
  "id": "mul-3-37",
  "prompt": "15 × 4",
  "answer": "60",
  "given": "60",
  "result": "correct",
  "ms": 4400,
  "limit_ms": 14000
 }
]

stats, decoded:

{
 "answered": 10,
 "correct": 7,
 "wrong": 2,
 "timeouts": 1,
 "skipped": 0,
 "accuracy": 70,
 "best_streak": 3,
 "median_ms_right": 4700,
 "points": 924
}

Reply:

{
 "task": "debrief",
 "headline": "Both wrong answers came from the ones part of a split product, and the times-8 item ran out of clock.",
 "misses": [
  {
   "id": "mul-3-27",
   "slip": "You gave 101 for 111, so the 3 × 7 = 21 part most likely lost its carried ten, leaving 90 + 11 instead of 90 + 21.",
   "rule": "Split the two-digit number into tens and ones, multiply each, then add the two products in full.",
   "mnemonic": "Tens first, ones second, and the carry rides along.",
   "worked": "3 × 37 = 3 × 30 + 3 × 7 = 90 + 21 = 111"
  },
  {
   "id": "mul-3-28",
   "slip": "Nothing was entered, so the time likely went on recalling 16 × 8 as a single fact. Doubling is the faster route.",
   "rule": "Multiplying by 8 is doubling three times; each double is a quick, safe step.",
   "mnemonic": "Eight is double, double, double.",
   "worked": "16 × 8 = 32 × 4 = 64 × 2 = 128"
  },
  {
   "id": "mul-3-31",
   "slip": "You gave 105 for 108, which points to taking 3 × 6 as 15, the neighbouring fact 3 × 5.",
   "rule": "Split into tens and ones and say the ones fact aloud before adding it on.",
   "mnemonic": "Three sixes make eighteen, three fives make fifteen.",
   "worked": "3 × 36 = 3 × 30 + 3 × 6 = 90 + 18 = 108"
  }
 ],
 "speed": "Your right answers took a median of 4.7 seconds, but the two wrong ones took 6.1 and 7.3 seconds and 16 × 8 used the full 14. When a fact does not come at once, switch to splitting or doubling straight away rather than waiting for recall.",
 "next": [
  "Drill times tables at level 3 with items like 3 × 47 and 6 × 36 to make the split-and-add carry automatic.",
  "Practise times 4 and times 8 by repeated doubling on two-digit numbers until each takes under 5 seconds."
 ]
}

debrief: a clean round, nothing missed

Input:

{
 "task": "debrief",
 "topic": "Percentages",
 "level": 2,
 "mode": "ten",
 "round": "(a JSON string - decoded below)",
 "stats": "(a JSON string - decoded below)",
 "misses_count": 0
}

round, decoded:

[
 {
  "id": "pct-2-38",
  "prompt": "75% of 240",
  "answer": "180",
  "given": "180",
  "result": "correct",
  "ms": 3800,
  "limit_ms": 10000
 },
 {
  "id": "pct-2-36",
  "prompt": "30% of 360",
  "answer": "108",
  "given": "108",
  "result": "correct",
  "ms": 4200,
  "limit_ms": 10000
 },
 {
  "id": "pct-2-39",
  "prompt": "40% of 240",
  "answer": "96",
  "given": "96",
  "result": "correct",
  "ms": 3100,
  "limit_ms": 10000
 },
 {
  "id": "pct-2-30",
  "prompt": "15% of 200",
  "answer": "30",
  "given": "30",
  "result": "correct",
  "ms": 5900,
  "limit_ms": 10000
 },
 {
  "id": "pct-2-37",
  "prompt": "75% of 60",
  "answer": "45",
  "given": "45",
  "result": "correct",
  "ms": 2800,
  "limit_ms": 10000
 },
 {
  "id": "pct-2-2",
  "prompt": "30% of 60",
  "answer": "18",
  "given": "18",
  "result": "correct",
  "ms": 4600,
  "limit_ms": 10000
 },
 {
  "id": "pct-2-26",
  "prompt": "40% of 260",
  "answer": "104",
  "given": "104",
  "result": "correct",
  "ms": 3300,
  "limit_ms": 10000
 },
 {
  "id": "pct-2-6",
  "prompt": "30% of 80",
  "answer": "24",
  "given": "24",
  "result": "correct",
  "ms": 5100,
  "limit_ms": 10000
 },
 {
  "id": "pct-2-1",
  "prompt": "40% of 20",
  "answer": "8",
  "given": "8",
  "result": "correct",
  "ms": 3600,
  "limit_ms": 10000
 },
 {
  "id": "pct-2-27",
  "prompt": "40% of 160",
  "answer": "64",
  "given": "64",
  "result": "correct",
  "ms": 2900,
  "limit_ms": 10000
 }
]

stats, decoded:

{
 "answered": 10,
 "correct": 10,
 "wrong": 0,
 "timeouts": 0,
 "skipped": 0,
 "accuracy": 100,
 "best_streak": 10,
 "median_ms_right": 3700,
 "points": 1770
}

Reply:

{
 "task": "debrief",
 "headline": "A clean round: every percentage came out right, including the 15% and 30% items that need two steps.",
 "misses": [],
 "speed": "Your median right answer took 3.7 seconds against a 10-second limit; the slowest were 15% of 200 at 5.9 seconds and 30% of 80 at 5.1. For those, find 10% first and scale it, which saves the most time.",
 "next": [
  "Move up to Percentages at level 3, where percent increases, decreases and reversals add a step.",
  "Try a sprint round at level 2 to push the 15% and 30% items under 4 seconds."
 ]
}

The page re-checks every reply; over the API, so should you

Over the API you get the reply as JSON and nothing has checked it yet. The page's own code is plain scripts you can load in node after global.window = {}: checker.js (Checker.evaluate(expr), the evaluator for check; Checker.validateSet(items, {avoid}), which returns the accepted items and every rejection with its reason), coach.js (Coach.buildGenerate and Coach.buildDebrief build these exact run bodies; Coach.normalizeGenerate, Coach.normalizeDebrief and Coach.reconcile hold a reply to the round it is about) and sprint.js (the round engine and the scoring).

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://quickfire-sums.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"quickfire-sums"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

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

Step 4 — POST /estimate

Free. The body is the run input itself (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.

# input.json: the run input object - {"task": "generate", "mode": "topic", "topic": "...", "level": 2, "count": 12, ...}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the task, the mode and the input: a retry with the same key never bills twice. The page uses quickfire-sums:<task>:<mode>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="quickfire-sums:generate:topic:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}' 

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: quickfire-sums:generate:topic:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json