← Explain It Back
API
Tokens

Drive Explain It Back from your own code

Both metered lanes are available over HTTP. quiz takes a code change as a numbered listing and returns a walkthrough, four, six or eight questions pinned to lines - each with a kind (predict, why, break, edge, trace, test) and an answer key - and a few concrete tests. grade takes that quiz plus the written answers and returns one mark per question (solid, shaky, wrong, blank) and a verdict. A natural use is a pre-merge hook that asks the author of an AI-assisted pull request three questions before the merge button unlocks.

The division of labour first: the page reads the diff, numbers it and flags the hot spots; the model asks and marks; the page scores and re-checks the reply. All of it comes from quizkit.js, which runs in node as well as in the browser. QuizKit.buildInput parses a git or unified diff (or a bare code paste), numbers every row by its line in the new file, flags hot spots (removed guards, removed or skipped tests, error handling, auth, data, async, retries and timeouts, regexes, loop bounds, manifests, TODOs) and clips a long change on whole hunks, hot hunks first. QuizKit.checkQuiz and QuizKit.checkGrade hold a reply to the change: pins on files the change does not have, hot spots no question probes, marks on empty answer boxes, and the verdict recomputed from the marks.

Get a token on the token page (step 2 below also shows how to mint a guest token in code).

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":"explain-it-back"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field such as task or diff is missing.
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: both lanes are metered and need 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 and the verdicts

Every run sends task: quiz or grade. A missing or unknown task becomes grade when answers is present and quiz otherwise, and the reply names the task it did. Each lane's verdict comes from ordered, non-overlapping rules - the first that applies wins.

LaneOrderVerdictWhen
quiz1needs-diffFewer than three added or removed rows that contain code. No questions; tips says what to paste.
quiz2readyEverything else: count questions, at least half of them on hot spots when there are any.
grade1needs-answersMore than half the questions are marked blank.
grade2holdA question on a hot spot is marked wrong or blank, or two or more questions are wrong.
grade3rereadAt least one question is shaky, wrong or blank.
grade4shipEvery question is solid.

The declared input schema carries scalars only, so array-valued fields travel JSON-encoded as strings (QuizKit.toWire does this). Every value is a string.

Input fields

FieldLaneNotes
taskbothquiz or grade. Required.
diffbothThe numbered listing: === path (+a -r) per file, @@ lines A-B @@ per hunk, then NUM SIGN | code per row (removed rows carry no number). Required. Build it with QuizKit.buildInput.
askbothWhat the assistant was asked to do, up to 400 characters. Optional.
filesquizJSON [{path, added, removed}].
hot_spotsquizJSON [{id, file, line, kind, text}] - ids H1..; kinds guard-removed, tests-removed, error, auth, data, async, time, regex, boundary, deps, todo.
countquiz"4", "6" or "8".
focus, avoidquizWhat to aim at, and a JSON array of questions already asked (a retake never repeats them). Optional.
questionsgradeJSON [{id, kind, file, line, spot, ask, key}] - the quiz reply's questions, as returned.
answersgradeJSON [{id, answer, sure}] - one per question; sure is "sure", "guess" or "".
clip_note, retry_notebothWhat the page cut, when it cut something; and only on a reformat retry, what was wrong.

The replies

The job's output.output is a string holding one JSON object. quiz: task, verdict, title, summary, walkthrough ([{file, does, look}]), questions ([{id, kind, file, line, spot, ask, key}]), tests and tips. grade: task, verdict, title, summary, marks ([{id, mark, note, reread}], one per question in order) and next. Score a grade with QuizKit.score(quiz, grade): solid 1, shaky 0.5, wrong and blank 0.

Worked examples

Replies produced against the app's prompt for the samples - one made-up change (a fetch wrapper an assistant gave retries, a timeout, a removed guard and a removed test) with four answer sets, and a two-row change. Long fields are shortened; the full replies ship in example-result.js. Together they cover every verdict of both lanes.

quiz - a fetch wrapper with retries, a timeout, a removed guard and a removed test: ready

Input:

{
 "task": "quiz",
 "ask": "Add retries with exponential backoff and a timeout to fetchJson so flaky 503s stop failing the dashboard.",
 "diff": "=== src/http/fetchJson.js (+27 -7)\n@@ lines 1-28 @@\n   - | export async function fetchJson(url, options = {}) {\n   - |   if (!url) throw new Error(\"fetchJson: url is required\");\n   - |   const res = await fetch(url, options);\n   - |   if (!res.ok) {\n   - |     throw new Error(`HTTP ${res.status} for ${url}`);\n   - |   }\n   - |   return res.json();\n 1 + | const RETRY_STATUSES = new Set([429, 502, 503, 504]);\n 2 + | \n  ... (2217 characters)",
 "files": "[{\"path\":\"src/http/fetchJson.js\",\"added\":27,\"removed\":7},{\"path\":\"test/fetchJson.test.js\",\"added\":7,\"removed\":3}]",
 "hot_spots": "[{\"id\":\"H1\",\"file\":\"src/http/fetchJson.js\",\"line\":1,\"kind\":\"guard-removed\",\"text\":\"if (!url) throw new Error(\\\"fetchJson: url is required\\\");\"},{\"id\":\"H2\",\"file\":\"src/http/fetchJson.js\",\"line\":3,\"kind\":\"time\",\"text\":\"function sleep(ms) {\"},{\"id\":\"H3\",\"file\":\"src/http/fetchJson.js\",\"line\":4,\"kind\":\"async\",\"text\":\"return new Promise((resolve) => setTimeout(resolve, ms));\"},{\"id\":\"H4\",\"file\":\"src/http/fetchJson.js\",\"lin ... (732 characters)",
 "count": "6"
}

Reply:

{
 "task": "quiz",
 "verdict": "ready",
 "title": "Retry loop and timeout for fetchJson",
 "summary": "This change rewrites fetchJson to retry on a fixed set of HTTP statuses with exponential backoff and a per-attempt timeout, which is what the request asked for to stop flaky 503s from failing the dashboard, though it also drops the previous guard against an empty url without being asked to. The quiz checks whether you can trace the retry loop, the catch block's rethrow condition, and the backoff delay closely enough to catch a case where a non-retryable status ends up being retried anyway.",
 "walkthrough": [
  {
   "file": "src/http/fetchJson.js",
   "does": "Replaces the single fetch call with a loop that retries on specific HTTP statuses, waits with exponential backoff between attempts, and aborts each attempt after a timeout.",
   "look": "Look at the catch block starting at line 19 of src/http/fetchJson.js, where the condition decides which errors are rethrown right away and which get stored and retried."
  },
  {
   "file": "test/fetchJson.test.js",
   "does": "Removes the test for an empty url and adds a test that a 503 response is retried once before the call succeeds.",
   "look": "Look at line 14 of test/fetchJson.test.js, where baseDelayMs is set to 0 so the retry's backoff delay doesn't slow the test down."
  }
 ],
 "questions": [
  {
   "id": "Q1",
   "kind": "break",
   "file": "src/http/fetchJson.js",
   "line": 1,
   "spot": "H1",
   "ask": "The check that threw \"fetchJson: url is required\" for an empty url is no longer in this version. Looking at the code starting at line 1 of src/http/fetchJson.js, is there anywhere else in the new fetchJson that would produce that same message for an empty url?",
   "key": "No. That message only ever came from the removed guard; nothing else in the new code constructs that string, so calling fetchJson with an empty url no longer fails with \"url is required\" the way it used to."
  },
  {
   "id": "Q2",
   "kind": "edge",
   "file": "src/http/fetchJson.js",
   "line": 4,
   "spot": "H3",
   "ask": "The new test passes baseDelayMs: 0 into fetchJson. Looking at the sleep function on lines 3-4 of src/http/fetchJson.js, does a delay of 0 change whether the promise it returns resolves, or just how soon it does?",
   "key": "sleep's promise only ever calls resolve, through setTimeout; there is no reject path in it. A baseDelayMs of 0 doesn't change that it resolves, it just means setTimeout fires right away instead of after a wait, so the retry in the test proceeds immediately."
  },
  "... (6 in all)"
 ],
 "tests": [
  "Mock fetch to resolve {ok:false, status:503} once then {ok:true, json: async () => ({id:2})}; call fetchJson(\"/api/item\", {}, {baseDelayMs:0}) and expect it to resolve to {id:2} with fetch called twice.",
  "Mock fetch to resolve {ok:false, status:404}; call fetchJson and check whether it rejects after one fetch call or whether fetch gets called more than once.",
  "Call fetchJson(\"\") and check what it throws now, since the old \"url is required\" message and its test are both gone."
 ],
 "tips": []
}

quiz - a two-row change: needs-diff

Input:

{
 "task": "quiz",
 "ask": "Show more rows per page.",
 "diff": "=== src/config.js (+1 -1)\n@@ lines 3-3 @@\n  - | export const PAGE_SIZE = 20;\n3 + | export const PAGE_SIZE = 50;",
 "files": "[{\"path\":\"src/config.js\",\"added\":1,\"removed\":1}]",
 "hot_spots": "[]",
 "count": "6"
}

Reply:

{
 "task": "quiz",
 "verdict": "needs-diff",
 "title": "More of the change needed",
 "summary": "Two changed rows arrived - one removed and one added - and a quiz needs more of the change to work with.",
 "walkthrough": [],
 "questions": [],
 "tests": [],
 "tips": [
  "Paste the whole diff from your version-control tool.",
  "Include every file the change touched.",
  "Share the request that was given to the assistant."
 ]
}

grade - every answer right: ship

Input:

{
 "task": "grade",
 "ask": "Add retries with exponential backoff and a timeout to fetchJson so flaky 503s stop failing the dashboard.",
 "diff": "=== src/http/fetchJson.js (+27 -7)\n@@ lines 1-28 @@\n   - | export async function fetchJson(url, options = {}) {\n   - |   if (!url) throw new Error(\"fetchJson: url is required\");\n   - |   const res = await fetch(url, options);\n   - |   if (!res.ok) {\n   - |     throw new Error(`HTTP ${res.status} for ${url}`);\n   - |   }\n   - |   return res.json();\n 1 + | const RETRY_STATUSES = new Set([429, 502, 503, 504]);\n 2 + | \n  ... (2217 characters)",
 "questions": "[{\"id\":\"Q1\",\"kind\":\"break\",\"file\":\"src/http/fetchJson.js\",\"line\":1,\"spot\":\"H1\",\"ask\":\"The check that threw \\\"fetchJson: url is required\\\" for an empty url is no longer in this version. Looking at the code starting at line 1 of src/http/fetchJson.js, is there anywhere else in the new fetchJson that would produce that same message for an empty url?\",\"key\":\"No. That message only ever came from the removed guard; nothing ... (3456 characters)",
 "answers": "[{\"id\":\"Q1\",\"answer\":\"No. Only the removed guard threw that message, so an empty url now just goes into the loop and gets passed to fetch.\",\"sure\":\"sure\"},{\"id\":\"Q2\",\"answer\":\"It still resolves - sleep only ever calls resolve, there is no reject. With 0 the setTimeout fires straight away, so the test doesn't wait.\",\"sure\":\"sure\"},{\"id\":\"Q3\",\"answer\":\"4 tries: attempt goes 0, 1, 2, 3 because the loop uses <= retries a ... (1049 characters)"
}

Reply:

{
 "task": "grade",
 "verdict": "ship",
 "title": "Fetch retry answers all solid",
 "summary": "Six of six answers were solid: you correctly traced the removed guard, the sleep function's resolve-only behavior, the loop's four total attempts, the catch block's retry condition, and what the new test does and does not cover, so this is ready to ship.",
 "marks": [
  {
   "id": "Q1",
   "mark": "solid",
   "note": "You correctly said the message only ever came from the removed guard and that nothing in the new code produces it, so an empty url no longer fails that way and instead flows straight into the retry loop.",
   "reread": ""
  },
  {
   "id": "Q2",
   "mark": "solid",
   "note": "You correctly said the sleep function's promise only ever resolves, with no reject path, and that a zero delay just makes the timeout fire immediately rather than skip resolving.",
   "reread": ""
  },
  "... (6 in all)"
 ],
 "next": [
  "Run the new retry test locally to confirm it passes as written.",
  "Add a test for a non-retryable status like a 404 that checks how many times fetch is called, since the catch block as written retries it."
 ]
}

grade - two answers half right: reread

Input:

{
 "task": "grade",
 "ask": "Add retries with exponential backoff and a timeout to fetchJson so flaky 503s stop failing the dashboard.",
 "diff": "=== src/http/fetchJson.js (+27 -7)\n@@ lines 1-28 @@\n   - | export async function fetchJson(url, options = {}) {\n   - |   if (!url) throw new Error(\"fetchJson: url is required\");\n   - |   const res = await fetch(url, options);\n   - |   if (!res.ok) {\n   - |     throw new Error(`HTTP ${res.status} for ${url}`);\n   - |   }\n   - |   return res.json();\n 1 + | const RETRY_STATUSES = new Set([429, 502, 503, 504]);\n 2 + | \n  ... (2217 characters)",
 "questions": "[{\"id\":\"Q1\",\"kind\":\"break\",\"file\":\"src/http/fetchJson.js\",\"line\":1,\"spot\":\"H1\",\"ask\":\"The check that threw \\\"fetchJson: url is required\\\" for an empty url is no longer in this version. Looking at the code starting at line 1 of src/http/fetchJson.js, is there anywhere else in the new fetchJson that would produce that same message for an empty url?\",\"key\":\"No. That message only ever came from the removed guard; nothing ... (3456 characters)",
 "answers": "[{\"id\":\"Q1\",\"answer\":\"No. Only the removed guard threw that message, so an empty url now just goes into the loop and gets passed to fetch.\",\"sure\":\"sure\"},{\"id\":\"Q2\",\"answer\":\"It still resolves - sleep only ever calls resolve, there is no reject. With 0 the setTimeout fires straight away, so the test doesn't wait.\",\"sure\":\"sure\"},{\"id\":\"Q3\",\"answer\":\"4 tries: attempt goes 0, 1, 2, 3 because the loop uses <= retries a ... (832 characters)"
}

Reply:

{
 "task": "grade",
 "verdict": "reread",
 "title": "Strong retry understanding, two vague answers",
 "summary": "Four of six answers were solid, correctly tracing the retry count, the catch block's handling of a non-retryable status, and what happened to the removed url guard. Two answers landed on the right conclusion but stayed too vague to show you understood why, which is why this needs a reread rather than a ship.",
 "marks": [
  {
   "id": "Q1",
   "mark": "solid",
   "note": "Correctly says the message was only ever produced by the removed guard, and adds that an empty url now flows straight into the fetch call instead of being rejected early.",
   "reread": ""
  },
  {
   "id": "Q2",
   "mark": "solid",
   "note": "Correctly notes that sleep's promise only ever resolves, with no reject path, and that a zero delay just makes the timer fire immediately rather than changing that outcome.",
   "reread": ""
  },
  "... (6 in all)"
 ],
 "next": [
  "Reread the top of src/http/fetchJson.js alongside the deleted test in test/fetchJson.test.js to see exactly which removed line produced the message the old test checked for.",
  "Look again at what the one new test's mocked sequence actually covers versus what a test for a non-retryable status like 404 would need to assert, then take a fresh quiz."
 ]
}

grade - wrong on a hot-spot question: hold

Input:

{
 "task": "grade",
 "ask": "Add retries with exponential backoff and a timeout to fetchJson so flaky 503s stop failing the dashboard.",
 "diff": "=== src/http/fetchJson.js (+27 -7)\n@@ lines 1-28 @@\n   - | export async function fetchJson(url, options = {}) {\n   - |   if (!url) throw new Error(\"fetchJson: url is required\");\n   - |   const res = await fetch(url, options);\n   - |   if (!res.ok) {\n   - |     throw new Error(`HTTP ${res.status} for ${url}`);\n   - |   }\n   - |   return res.json();\n 1 + | const RETRY_STATUSES = new Set([429, 502, 503, 504]);\n 2 + | \n  ... (2217 characters)",
 "questions": "[{\"id\":\"Q1\",\"kind\":\"break\",\"file\":\"src/http/fetchJson.js\",\"line\":1,\"spot\":\"H1\",\"ask\":\"The check that threw \\\"fetchJson: url is required\\\" for an empty url is no longer in this version. Looking at the code starting at line 1 of src/http/fetchJson.js, is there anywhere else in the new fetchJson that would produce that same message for an empty url?\",\"key\":\"No. That message only ever came from the removed guard; nothing ... (3456 characters)",
 "answers": "[{\"id\":\"Q1\",\"answer\":\"No. Only the removed guard threw that message, so an empty url now just goes into the loop and gets passed to fetch.\",\"sure\":\"sure\"},{\"id\":\"Q2\",\"answer\":\"It still resolves - sleep only ever calls resolve, there is no reject. With 0 the setTimeout fires straight away, so the test doesn't wait.\",\"sure\":\"sure\"},{\"id\":\"Q3\",\"answer\":\"4 tries: attempt goes 0, 1, 2, 3 because the loop uses <= retries a ... (946 characters)"
}

Reply:

{
 "task": "grade",
 "verdict": "hold",
 "title": "One answer missed a retry bug",
 "summary": "Five of six answers were solid. The question about what happens to a 404 response was marked wrong even though it was answered with confidence, and because that question pointed at a flagged line, this one wrong answer is enough on its own to hold. The catch block's message-prefix check doesn't actually separate the manually thrown non-retryable error from a retryable failure, so a 404 gets retried instead of rejected immediately.",
 "marks": [
  {
   "id": "Q1",
   "mark": "solid",
   "note": "You correctly said that message only ever came from the removed guard and that nothing else produces it now, and you rightly added that an empty url just flows straight into the fetch call instead of being rejected up front.",
   "reread": ""
  },
  {
   "id": "Q2",
   "mark": "solid",
   "note": "You correctly identified that the sleep function's promise only ever resolves and never rejects, so a zero delay just makes the timeout fire immediately rather than changing whether it settles.",
   "reread": ""
  },
  "... (6 in all)"
 ],
 "next": [
  "Re-read the catch block starting at line 19 of src/http/fetchJson.js and trace what happens to the error thrown for a non-retryable status like 404 - it shares the same \"HTTP\" message prefix as a retryable failure, so it is not rethrown the way you'd expect.",
  "Work out how the condition at line 20 would need to change to tell a deliberately thrown non-retryable error apart from a genuine retryable failure.",
  "Then take a fresh quiz."
 ]
}

grade - most boxes left empty: needs-answers

Input:

{
 "task": "grade",
 "ask": "Add retries with exponential backoff and a timeout to fetchJson so flaky 503s stop failing the dashboard.",
 "diff": "=== src/http/fetchJson.js (+27 -7)\n@@ lines 1-28 @@\n   - | export async function fetchJson(url, options = {}) {\n   - |   if (!url) throw new Error(\"fetchJson: url is required\");\n   - |   const res = await fetch(url, options);\n   - |   if (!res.ok) {\n   - |     throw new Error(`HTTP ${res.status} for ${url}`);\n   - |   }\n   - |   return res.json();\n 1 + | const RETRY_STATUSES = new Set([429, 502, 503, 504]);\n 2 + | \n  ... (2217 characters)",
 "questions": "[{\"id\":\"Q1\",\"kind\":\"break\",\"file\":\"src/http/fetchJson.js\",\"line\":1,\"spot\":\"H1\",\"ask\":\"The check that threw \\\"fetchJson: url is required\\\" for an empty url is no longer in this version. Looking at the code starting at line 1 of src/http/fetchJson.js, is there anywhere else in the new fetchJson that would produce that same message for an empty url?\",\"key\":\"No. That message only ever came from the removed guard; nothing ... (3456 characters)",
 "answers": "[{\"id\":\"Q1\",\"answer\":\"No. Only the removed guard threw that message, so an empty url now just goes into the loop and gets passed to fetch.\",\"sure\":\"sure\"},{\"id\":\"Q2\",\"answer\":\"\",\"sure\":\"\"},{\"id\":\"Q3\",\"answer\":\"4\",\"sure\":\"guess\"},{\"id\":\"Q4\",\"answer\":\"\",\"sure\":\"\"},{\"id\":\"Q5\",\"answer\":\"no idea\",\"sure\":\"\"},{\"id\":\"Q6\",\"answer\":\"\",\"sure\":\"\"}]"
}

Reply:

{
 "task": "grade",
 "verdict": "needs-answers",
 "title": "Too many blank answers on retries",
 "summary": "Two of six answers were solid and four were left blank, which is more than half the quiz, so there isn't enough here yet to judge whether this is ready to ship. Answer the remaining questions in your own words, even briefly, before this can be graded further.",
 "marks": [
  {
   "id": "Q1",
   "mark": "solid",
   "note": "You correctly said no - that message only ever came from the removed guard, so an empty url no longer fails that way; you also correctly added that it now flows into the retry loop and on to fetch.",
   "reread": ""
  },
  {
   "id": "Q2",
   "mark": "blank",
   "note": "No answer was given. The sleep function's promise only ever calls resolve, through setTimeout, with no reject path; a baseDelayMs of 0 doesn't change that it resolves, it just makes the timeout fire immediately instead of after a wait.",
   "reread": "src/http/fetchJson.js line 4"
  },
  "... (6 in all)"
 ],
 "next": [
  "Answer the questions about what a zero delay does to the retry wait, how a 404 response is handled, why the old empty-url test would now fail, and whether the new test covers a 404 being retried - even briefly, in your own words."
 ]
}

Build the input yourself

Load the script in node (global.window = global; require("./quizkit.js")), then:

const K = QuizKit;
const form = K.form({ diff: require("fs").readFileSync("change.diff", "utf8"), ask: "Add retries to the API client", count: "6" });
console.log(K.flags(form), K.stats(form), K.read(form).spots);  // the flags, the counts, the hot spots
const quizWire = K.toWire(K.buildInput("quiz", form).input);     // POST this to /estimate and /run
// const quiz = K.normalize(K.parseReply(job.output.output), "quiz");
// K.checkQuiz(quiz, form).notes                                 // the page's re-check of the quiz
const answers = { Q1: { answer: "It retries a 404 too, because ...", sure: "sure" } };
// const gradeWire = K.toWire(K.buildInput("grade", form, { quiz, answers }).input);
// const grade = K.normalize(K.parseReply(job2.output.output), "grade");
// K.score(quiz, grade), K.ruleGrade(quiz, grade), K.checkGrade(grade, quiz, answers, form).notes

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://explain-it-back.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":"explain-it-back"}' | 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, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "quiz", "ask": "Show more rows per page.", "diff": "=== src/config.js (+1 -1)\n@@ lines 3-3 @@\n  - | export const PAGE_SIZE = 20;\n3 + | export const PAGE_SIZE = 50;", "files": "[{\"path\":\"src/config.js\",\"added\":1,\"removed\":1}]", "hot_spots": "[]", "count": "6"}
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 lane and the input: a retry with the same key never bills twice. The page uses explain-it-back:<task>:<inputHash>:a<attempt> (the lanes are quiz and grade), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="explain-it-back:quiz:$(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: explain-it-back:quiz:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json