← FromJson Desk / API
Tokens

Drive FromJson Desk from your own code

The web page's two paid lanes are available over HTTP. You send one JSON object with a task (review or write) and a facts string. The model replies with one JSON object: a verdict and one finding per problem for a review, or the model file, its unit test and its fetch function for a write.

The model does not read your payload or your Dart code by itself. The page does the mechanical part in your browser, for free: dartkit.js decodes the payload the way jsonDecode does (keeping which numbers are int and which are double), merges every sample into one schema and generates a skeleton model, and dartcheck.js runs a pasted model's fromJson against every sample and records where it throws. facts.js then packs the result as facts. When you call the API directly there is no page to do this, so you build facts yourself, in the same shape (see The facts string).

Base URL and the envelope

Every endpoint below is under https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer YOUR_TOKEN. The SDK leaves the header out when it has no token.

HTTP 2xx   { "data":  { ... } }
non-2xx    { "error": { "code": "...", "message": "...", "details": { ... } } }

This is how the vendored SDK (sdk.js) reads a response. It checks the HTTP status (res.ok), not a field in the body. On a 2xx it returns data. Otherwise it throws an error carrying the HTTP status, plus code and details from the body's error object, with error.message as the message. If the body has no error.message it uses the HTTP status text, and if the body is not JSON it treats it as empty. So branch on the HTTP status first.

The body is the input object itself. The SDK sends JSON.stringify(input) as the body of /estimate, /run and /run-stream, with no wrapper. Before every estimate and run the page passes the input through Dartkit.mustBeObject, which throws run input must be a JSON object for anything that is not a non-array object.

Errors

Only the cases the SDK or the page actually handle are listed. The code has no special case for any other status (429 included): it is thrown like any other error.

What you seeWhat the code does with itWhat to do
HTTP 401 from a runThe page treats it as an expired session: it forgets the user it had and says "Your session expired - sign in again."Get a fresh token (step 2).
HTTP 402 from a runThe page says "Not enough credits for this run." and links to billing. Before a run it also compares the balance from /me with the estimate: it disables the button below min_credits and warns below hold_credits that the run may be cut short.Top up the signed-in account, then run again.
Any other non-2xxThe SDK throws with the status, error.code and error.message. The page shows "The run failed: " and the message, or, for /estimate, "Could not price this run: " and the message.Read error.code and error.message. Fix the body, or retry with the same Idempotency-Key.
An SSE error event on /run-streamWhen the stream ends, the SDK throws with the event's message (or "job failed"), code and job_id. This error has no HTTP status.Report it; the job id names the job.
A stream that breaks offIf some reply text had arrived, the page closes the cut-off JSON (Recon.closeJson), shows the sections that parsed and names how many arrived.Keep what parsed, or run again.
A job with status: "failed"The SDK's poller stops once the status is succeeded or failed.Read the job's error and decide whether to retry.

The task field: two lanes

task picks the lane. One system prompt serves both lanes and task decides which one runs. The page only ever sends one of these two values:

taskWhat it doesWhat facts is built from
reviewChecks a pasted Dart model against the payload. It explains what each problem the browser's check found does at runtime and gives the corrected Dart lines, with a verdict that is never looser than the browser's verdict_floor.The payload, the inferred schema and the check of your model: Facts.reviewFacts(P, M, check, code).
writeWrites the model file, plus its unit test and its fetch function when you ask for them, in null-safe Dart 3 on dart:convert (plus package:http for the fetch function and a test package for the test), with no code generation.The payload, the inferred schema and the browser's generated skeleton: Facts.writeFacts(P, M, skeleton, {platform, fetch, test, requests, existing}).

If task is missing or not recognised, SKILL.md tells the model to answer as review when facts has a check object and as write otherwise, and to set lane in the reply to the lane it chose. The page's review facts carry no key named check, so do not rely on this fallback. Always send task.

Input fields

These are the fields declared to the platform in input-schema.json, in order:

FieldTypeRequiredMeaning
taskstringyes"review" or "write".
factsstringyesA JSON string, not a nested object. The page builds it with JSON.stringify(facts), and the model is told to parse it first. Its keys depend on the lane (see below).
questionstringnoYour question for the model. Facts.buildInput trims it, cuts it to 600 characters and leaves it out when it is empty. The model answers it in summary.
retry_notestringnoOnly for a reformat retry. The page adds it to the body of the second attempt when the first reply could not be parsed. It is not part of the hashed input. Do not send it on a first attempt.

When the page cannot parse a reply it retries once, as one extra run, with this retry_note: Your previous reply was not the single valid JSON object the instructions require (<parse error>). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; Dart code inside JSON strings with newlines escaped as \n; every array present (empty arrays where there is nothing to say). If the second reply does not parse either, the page shows the raw text.

The facts string

Facts.buildInput(task, facts, question) wraps the lane's facts object as { task, facts: JSON.stringify(facts), question? }. SKILL.md tells the model that everything in facts is authoritative: the schema's types, the presence and null counts, the check's flags and which samples decode. It never recounts or re-infers them. As an API caller you must build the same shape.

review facts, top-level keys (in the order reviewFacts writes them):

KeyContents
titleThe root class name.
platform"vm" (Android, iOS, desktop) or "web". On the VM as int throws on 12.0 and as double throws on 12; on the web a whole double passes as int.
root_classThe class the checker ran the samples through, or "" when no class reads the payload.
samples, decodedHow many samples there are, and how many decode without an exception.
verdict_floorThe loosest verdict the browser allows: rewrite when no class reads the payload or the root class reads fewer than half of the payload's top-level keys, else fix_first when any error or warn flag exists, else decodes.
schemaOne row per JSON key: { class, key, field, type, seen, nulls, kinds }. seen is "present/objects"; kinds counts the JSON kinds seen, e.g. "int 1, double 1". At most 160 rows.
flagsThe check's findings, each { id, code, severity, cls, key, title, detail } and sometimes line. Ids are C1, C2, ... sorted errors first. severity is error (throws or loses data at runtime), warn or info.
classesEach pasted class: { name, line, fields: [{ name, type, line }], reads: [{ key, via, check, null_ok, line }], written: [keys] }.
payload_excerptWhole samples, first to last, up to about 6,000 characters. A sample is never cut in half unless the first one alone is over the budget.
model_codeThe pasted Dart with line numbers as NN| . Past 24,000 characters the middle is cut on whole lines, with a marker naming the lines left out.
notesPlain-text notes about what was cut or not sent.

write facts, top-level keys (in the order writeFacts writes them):

KeyContents
title, root_classThe root class name the browser chose.
platform"vm" or "web".
style"pattern" (a Dart 3 switch over a map pattern) or "cast" (json['key'] as T named arguments).
root"object", "list" or "ndjson" (one object per line).
samplesHow many samples there are.
schemaAs in review, plus optional: true when the key is missing in some samples.
classesThe class names the browser chose, root first.
skeletonThe browser's generated model file, which already decodes every sample. Past 30,000 characters it is cut after the last whole class that fits, with a comment naming the classes left out.
payload_excerptWhole samples, as in review.
options{ fetch, test, background }: write a fetch function, write a unit test, and parse with compute(). background is only true with fetch, and only when the payload has at least 200 samples or 100,000 characters.
notesPlain-text notes, including the schema's own notes.
requestsOnly when given: your own instructions, trimmed. Past 6,000 characters they are cut at a line end and notes says so.
existing_modelOnly when given: your current Dart code, line-numbered, whose class and field names the model keeps where they fit. The page sends whatever is in its model box.

The reply contract

The reply is one JSON object in the job's output text, output.output, with no prose and no code fences around it. Dart code sits inside JSON strings with newlines escaped. SKILL.md requires every array to be present (empty, never omitted or null) and caps note and why at 70 words, headline at 30 and summary at 100.

review

{
  "lane": "review",
  "verdict": "decodes" | "fix_first" | "rewrite",
  "headline": "one sentence the developer reads first",
  "findings": [ { "flag": "C2", "note": "what goes wrong at runtime and why", "fix": "the corrected Dart line or lines" } ],
  "summary": "what to change first, and what the model will do once it is changed"
}

verdict can match facts.verdict_floor or be stricter, never looser: decodes means every sample decodes and round-trips as it is, fix_first means line-level fixes, rewrite means the model does not describe this payload. findings answers every error and warn flag, one item per flag in the order given; a problem no flag covers gets flag: "". fix is "" only when no code change is the answer. The page's parser (Recon.normalizeReview) shows an invalid or missing verdict as fix_first, blanks a flag that is not of the form C plus a number, and drops findings with neither note nor fix.

write

{
  "lane": "write",
  "headline": "one sentence on what the files do",
  "files": [ { "path": "lib/models/order.dart", "role": "model", "code": "..." } ],
  "decisions": [ { "key": "placed_at", "choice": "DateTime", "why": "why the field is typed or named this way" } ],
  "questions": [ "what the developer must decide that the payload cannot tell you" ],
  "summary": "how to use the files"
}

files holds exactly one model file, then one fetch file when options.fetch is true and one test file when options.test is true, under lib/ and test/. The model file starts with import 'dart:convert';. decisions has two to eight items; its key is a JSON key exactly as the schema spells it, or "" for a choice about the file as a whole. The page's parser (Recon.normalizeWrite) guesses a missing role from the path and drops files with no code.

The page checks every reply against its own reading of your input before showing it (recon.js): for a review, that the verdict is not looser than the floor, that every error and warn flag is answered and that each fix reads keys the payload has with types the samples satisfy; for a write, it runs the written model file through the same checker against every sample. A direct API caller gets only the raw reply, so run your own checks.

Worked examples: one body per lane

These two bodies are short on purpose: a two-sample payload of products, one of which has a whole-number price and an extra sale key. Real payloads give much longer facts. Both were produced by running the page's own dartkit.js, dartcheck.js and facts.js on this payload, so the field names and values are what the page itself would send; in the page all of this is built in the browser. Save the one you want as body.json, exactly as shown, on one line. Steps 4 to 6 send that file unchanged.

The payload, pasted as a JSON list:

[{"id": 7, "price": 12.5, "name": "Mug"}, {"id": 8, "price": 4, "name": "Tea", "sale": true}]

review: a Product model that reads price as double

The pasted model reads json['price'] as double, which throws on the Dart VM for the second sample's 4 (flag C1, an error), and never reads sale (flag C2, info). One of two samples decodes, so the floor is fix_first. The body carries an optional question.

The model it was checked against (the page line-numbers it into model_code):

class Product {
  final int id;
  final double price;
  final String name;
  const Product({required this.id, required this.price, required this.name});
  factory Product.fromJson(Map<String, dynamic> json) => Product(
        id: json['id'] as int,
        price: json['price'] as double,
        name: json['name'] as String,
      );
  Map<String, dynamic> toJson() => {'id': id, 'price': price, 'name': name};
}

body.json for the review:

{"task":"review","facts":"{\"title\":\"Product\",\"platform\":\"vm\",\"root_class\":\"Product\",\"samples\":2,\"decoded\":1,\"verdict_floor\":\"fix_first\",\"schema\":[{\"class\":\"Product\",\"key\":\"id\",\"field\":\"id\",\"type\":\"int\",\"seen\":\"2/2\",\"nulls\":0,\"kinds\":\"int 2\"},{\"class\":\"Product\",\"key\":\"price\",\"field\":\"price\",\"type\":\"double\",\"seen\":\"2/2\",\"nulls\":0,\"kinds\":\"int 1, double 1\"},{\"class\":\"Product\",\"key\":\"name\",\"field\":\"name\",\"type\":\"String\",\"seen\":\"2/2\",\"nulls\":0,\"kinds\":\"string 2\"},{\"class\":\"Product\",\"key\":\"sale\",\"field\":\"sale\",\"type\":\"bool?\",\"seen\":\"1/2\",\"nulls\":0,\"kinds\":\"bool 1\"}],\"flags\":[{\"id\":\"C1\",\"code\":\"type\",\"severity\":\"error\",\"cls\":\"Product\",\"key\":\"price\",\"title\":\"wrong type for the decoded value\",\"detail\":\"4 decodes as an int, which is not a double on the Dart VM - fails in 1 of 2 samples.\",\"line\":8},{\"id\":\"C2\",\"code\":\"key_unread\",\"severity\":\"info\",\"cls\":\"Product\",\"key\":\"sale\",\"title\":\"payload key the model never reads\",\"detail\":\"'sale' is in 1 of 2 samples and fromJson never reads it, so it is dropped on decode and lost on the way back out.\"}],\"classes\":[{\"name\":\"Product\",\"line\":1,\"fields\":[{\"name\":\"id\",\"type\":\"int\",\"line\":2},{\"name\":\"price\",\"type\":\"double\",\"line\":3},{\"name\":\"name\",\"type\":\"String\",\"line\":4}],\"reads\":[{\"key\":\"id\",\"via\":\"json['id']\",\"check\":\"int\",\"null_ok\":false,\"line\":7},{\"key\":\"price\",\"via\":\"json['price']\",\"check\":\"double\",\"null_ok\":false,\"line\":8},{\"key\":\"name\",\"via\":\"json['name']\",\"check\":\"String\",\"null_ok\":false,\"line\":9}],\"written\":[\"id\",\"price\",\"name\"]}],\"payload_excerpt\":\"{\\n  \\\"id\\\": 7,\\n  \\\"price\\\": 12.5,\\n  \\\"name\\\": \\\"Mug\\\"\\n}\\n{\\n  \\\"id\\\": 8,\\n  \\\"price\\\": 4,\\n  \\\"name\\\": \\\"Tea\\\",\\n  \\\"sale\\\": true\\n}\",\"model_code\":\"1| class Product {\\n2|   final int id;\\n3|   final double price;\\n4|   final String name;\\n5|   const Product({required this.id, required this.price, required this.name});\\n6|   factory Product.fromJson(Map<String, dynamic> json) => Product(\\n7|         id: json['id'] as int,\\n8|         price: json['price'] as double,\\n9|         name: json['name'] as String,\\n10|       );\\n11|   Map<String, dynamic> toJson() => {'id': id, 'price': price, 'name': name};\\n12| }\",\"notes\":[]}","question":"Why does the second product fail?"}

write: the same payload, pattern style, with a test and a fetch function

No model is pasted, so there is no existing_model. options asks for the fetch function and the unit test; two samples are far below the background threshold. skeleton is the browser's own generated model: price is read as num and converted with toDouble(), and sale is read outside the pattern as bool?.

body.json for the write:

{"task":"write","facts":"{\"title\":\"Product\",\"platform\":\"vm\",\"style\":\"pattern\",\"root\":\"list\",\"root_class\":\"Product\",\"samples\":2,\"schema\":[{\"class\":\"Product\",\"key\":\"id\",\"field\":\"id\",\"type\":\"int\",\"seen\":\"2/2\",\"nulls\":0,\"kinds\":\"int 2\",\"optional\":false},{\"class\":\"Product\",\"key\":\"price\",\"field\":\"price\",\"type\":\"double\",\"seen\":\"2/2\",\"nulls\":0,\"kinds\":\"int 1, double 1\",\"optional\":false},{\"class\":\"Product\",\"key\":\"name\",\"field\":\"name\",\"type\":\"String\",\"seen\":\"2/2\",\"nulls\":0,\"kinds\":\"string 2\",\"optional\":false},{\"class\":\"Product\",\"key\":\"sale\",\"field\":\"sale\",\"type\":\"bool?\",\"seen\":\"1/2\",\"nulls\":0,\"kinds\":\"bool 1\",\"optional\":true}],\"classes\":[\"Product\"],\"skeleton\":\"import 'dart:convert';\\n\\n/// The response body is a JSON list of Product objects.\\nList<Product> parseProducts(String body) => [\\n      for (final e$0 in jsonDecode(body) as List<dynamic>) Product.fromJson(e$0 as Map<String, dynamic>),\\n    ];\\n\\nclass Product {\\n  final int id;\\n  /// the JSON has both whole numbers and decimals, so it is read as num and converted.\\n  final double price;\\n  final String name;\\n  /// missing in 1 of 2 samples.\\n  final bool? sale;\\n\\n  const Product({\\n    required this.id,\\n    required this.price,\\n    required this.name,\\n    this.sale,\\n  });\\n\\n  factory Product.fromJson(Map<String, dynamic> json) {\\n    return switch (json) {\\n      {\\n        'id': int id,\\n        'price': num price,\\n        'name': String name,\\n      } =>\\n        Product(\\n          id: id,\\n          price: price.toDouble(),\\n          name: name,\\n          sale: json['sale'] as bool?,\\n        ),\\n      _ => throw const FormatException('Failed to load Product.'),\\n    };\\n  }\\n\\n  Map<String, dynamic> toJson() {\\n    return {\\n      'id': id,\\n      'price': price,\\n      'name': name,\\n      if (sale != null) 'sale': sale,\\n    };\\n  }\\n}\\n\",\"payload_excerpt\":\"{\\n  \\\"id\\\": 7,\\n  \\\"price\\\": 12.5,\\n  \\\"name\\\": \\\"Mug\\\"\\n}\\n{\\n  \\\"id\\\": 8,\\n  \\\"price\\\": 4,\\n  \\\"name\\\": \\\"Tea\\\",\\n  \\\"sale\\\": true\\n}\",\"options\":{\"fetch\":true,\"test\":true,\"background\":false},\"notes\":[]}"}

Idempotency

/run and /run-stream accept an Idempotency-Key request header. The SDK sends it only when the caller passes a key. The page passes one on every run attempt, with the lane in it:

fromjson-desk:<lane>:<hash>:a<attempt>

For the two worked examples above, serialised the way JSON.stringify does it (no whitespace):
      fromjson-desk:review:41a9e83f-9ea:a1     (first attempt)
      fromjson-desk:review:41a9e83f-9ea:a2     (the one reformat retry)
      fromjson-desk:write:11a56c72-90a:a1

<hash> is computed over JSON.stringify(input), where the input is the one buildInput produced, before any retry_note is added. It is a djb2-style 32-bit hash (h = 5381; for each UTF-16 code unit, h = h*33 + c, modulo 232), written as lowercase hex, then -, then the string length in hex. <attempt> is 1 for a run and 2 for the reformat retry. The code comments give the reason: a reformat retry stays distinguishable from a deliberate re-run, and a network blip never double-bills. The SDK also notes that an idempotent replay of /run-stream may come back as a plain JSON result instead of an event stream (step 6 handles both).

The samples below compute the same formula over the exact bytes they send, so a body.json saved with a trailing newline gets a different key than the page would use. Your key only has to stay stable for one logical attempt, so any stable hash of your body works for your own calls.

1. A tiny client

This helper sends a JSON body that is already serialised. It sets Content-Type: application/json, adds Authorization: Bearer ... only when a token is set, returns data on a 2xx, and raises an error with the status, error.code and error.message otherwise, as sdk.js does. It takes the body as a string so that a run's Idempotency-Key is computed over the exact bytes it sends. The later steps assume the helper is in scope.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # from https://fromjson-desk.skillsafe.ai/tokens.html

# api METHOD PATH [BODY_FILE] [extra curl args...]
# Prints the response body; exits non-zero (--fail-with-body) on a non-2xx status.
api() {
  local method="$1" path="$2" body="${3:-}"
  shift 2; [ $# -gt 0 ] && shift
  local args=(-sS --fail-with-body -X "$method" "$BASE$path" -H "Content-Type: application/json")
  if [ -n "$TOKEN" ]; then args+=(-H "Authorization: Bearer $TOKEN"); fi
  if [ -n "$body" ]; then args+=(--data-binary "@$body"); fi
  curl "${args[@]}" "$@"
}

# idem_key LANE BODY_FILE ATTEMPT  ->  fromjson-desk:<lane>:<hash>:a<attempt>
# Hashes bytes, so it equals the page's UTF-16 hash only for an ASCII body.
idem_key() {
  local h=5381 n=0 b
  for b in $(od -An -v -tu1 "$2"); do h=$(( (h * 33 + b) & 0xFFFFFFFF )); n=$((n + 1)); done
  printf 'fromjson-desk:%s:%x-%x:a%s\n' "$1" "$h" "$n" "$3"
}

2. Get a token

There are two kinds of token, and the tokens page shows the one this browser holds for fromjson-desk. A guest token is minted automatically and is enough for free calls such as /me and /estimate. An AI review or write is metered, so it needs a signed-in (personal) token: the page sends a guest to sign in before it runs anything, unless /estimate reported sponsor_enabled: true.

# Guest token (needs jq). No Authorization header on this call.
TOKEN=$(curl -sS --fail-with-body -X POST "$BASE/guest" \
  -H "Content-Type: application/json" --data '{"slug":"fromjson-desk"}' | jq -r '.data.token')

# Or a personal token copied from https://fromjson-desk.skillsafe.ai/tokens.html
TOKEN="YOUR_TOKEN"

3. Check the session and the balance

GET /me returns the caller: subject_type, subject_id, credits and sometimes profile. The page counts the caller as signed in only when subject_type is "user", shows credits as the balance, and compares it with the estimate before a run. Call it before a run to make sure you hold a signed-in token.

api GET /me
# {"data":{"subject_type":"user","subject_id":"...","credits":...}}

4. Price it

POST /estimate takes the same body you would send to a run. The SDK describes it as the "worst-case credit cost of a run with this input (no charge, no job)". The page reads these fields from the response:

FieldHow the page uses it
hold_creditsShown as the credits the run "reserves" for the lane. If the balance is under it, the page warns that the run may be cut short.
min_creditsShown as the minimum, when present. The page disables its run button while the balance is below it.
sponsor_enabledThe page lets a guest start a run only when this is true; otherwise it asks the user to sign in.
warningsWhen present and non-empty, shown next to the price as "input warnings: " followed by the warnings joined with ; .

hold_credits is a reservation, not the price. The charge for a run is reported after it as charged_credits (steps 5 and 6), and the page shows that number with the result. The page converts credits to dollars at 10,000 credits per dollar, and prices each lane on its own input.

The response may carry a warnings array, which the page shows next to the price. A warning is not a rejection: the page still shows the hold and still lets the run go ahead, so check warnings yourself before you run. What we observed on this app on 2026-09-28, with a guest token: a body with both declared fields ({"task": "write", "facts": "{}"}) returned a hold with an empty warnings array and input_checked: true; an empty body {} still returned a hold, with the warnings "missing required field 'task'" and "missing required field 'facts'"; a body wrapped as {"input": {...}} also returned a hold, with those two warnings plus one naming the unknown field input and listing the declared fields; and a bare JSON string was rejected with HTTP 400 validation_error ("Body must be a JSON object of the app's input fields"). The estimate does not look inside the facts string, so a well-formed body with wrong facts prices cleanly: check warnings yourself, and build facts as the page does.

# body.json is one of the worked examples ("task": "review" or "write").
api POST /estimate body.json | jq '.data | {hold_credits, min_credits, sponsor_enabled, warnings}'

5. Run it, then poll the job

/run and /run-stream are metered. They spend the signed-in user's credits, so send a signed-in token (step 2). The page itself always uses /run-stream (step 6); /run plus polling is the SDK's other way to run the same body.

POST /run with the body and an Idempotency-Key header returns data.job_id. Then poll GET /jobs/{id} until status is succeeded or failed. The SDK's poller asks every second and gives up after 180 seconds by default. The SDK reads these fields from a finished job:

FieldMeaning
statussucceeded or failed once the job is finished.
output.outputThe reply text: the lane's JSON object (see the reply contract).
charged_creditsThe credits charged for this job.
truncatedtrue when the reply hit the run's output cap. The page reports it as "cut short by the available balance", shows the sections that arrived, and suggests topping up and running again.
errorWhy a failed job failed.

If the reply does not parse, do what the page does. Send the same body once more with a retry_note field (the text is in Input fields) and the key ending in :a2. Compute the hash over the body without the note. This is a second run and it is charged.

LANE=$(jq -r '.task' body.json)            # "review" or "write"
KEY=$(idem_key "$LANE" body.json 1)         # fromjson-desk:<lane>:<hash>:a1
JOB=$(api POST /run body.json -H "Idempotency-Key: $KEY" | jq -r '.data.job_id')

for i in $(seq 1 180); do
  api GET "/jobs/$JOB" > job.json
  STATUS=$(jq -r '.data.status' job.json)
  if [ "$STATUS" = succeeded ] || [ "$STATUS" = failed ]; then break; fi
  sleep 1
done

jq '.data | {status, charged_credits, truncated, error}' job.json
jq -r '.data.output.output' job.json | jq .   # the lane's JSON object

6. Or stream it

POST /run-stream takes the same body and Idempotency-Key header. It is metered like /run and needs a signed-in token. When the response's Content-Type is text/event-stream, the SDK reads it as server-sent events: events are separated by a blank line, each has an event: line and one or more data: lines, and the SDK joins the data: lines into one JSON value. This is how the SDK handles each event:

EventDataWhat the SDK does
jobThe job that was started.Passes it to onJob. The page uses it to move its progress display on.
delta{ "text": "..." }Passes text to onDelta.
done{ job_id, status, charged_credits, output, ... }Keeps it as the result. The reply text is output.output.
error{ message, code, job_id }Once the stream ends, throws with message (or "job failed"), code and job_id.

The SDK also keeps a pending event as the result, the same way as done, and skips events whose data is empty or is not valid JSON.

Read the reply from done. The page takes done.output.output and falls back to the joined delta texts only when that is missing; its code notes that browsers receive ticks, not deltas, from this endpoint. done.truncated means the reply was cut short, as on a job, and the page refreshes the balance from /me after a run that reports charged_credits.

A replay may not be a stream. According to the SDK, on an idempotent replay /run-stream can answer with a plain JSON envelope instead of an event stream. So check the Content-Type first. If it is not text/event-stream, read the response as { data } or { error }, exactly as in step 1.

LANE=$(jq -r '.task' body.json)            # "review" or "write"
KEY=$(idem_key "$LANE" body.json 1)

# -N turns off buffering so events print as they arrive.
api POST /run-stream body.json -N -H "Idempotency-Key: $KEY" | tee stream.txt

# Pull the reply out of the done event (a replay prints plain JSON instead: use .data).
awk '/^event:/ { ev = $2 } /^data:/ { if (ev == "done") { sub(/^data: ?/, ""); print } }' stream.txt \
  | jq -r '.output.output' | jq .

What the page does that you now do

Or skip all of this and use FromJson Desk in the browser. The schema, the generated model and the check against your samples are free there, even as a guest.