← Core-Hour Check / API
Tokens

Drive Core-Hour Check from your own code

A review of a plan, not of a simulation. Nothing is run: there is no pilot, no output and no measured budget. The model reads the plan and what the skill's tools found; it never computes a new number, and it never calls a plan "converged", "validated" or "physically correct". A passing validator and a resource fit establish neither numerical convergence nor physical validity.

Core-Hour Check plans bounded fluidsim 0.9.0 computational-fluid-dynamics runs. The three planning tools of the agent skill @k-dense-ai/fluidsim (k-dense-ai/scientific-agent-skills, K-Dense Inc.) run free in your browser, as JavaScript ports with the same stdout and exit codes as the Python: solver_config_validator.py (the plan against the skill's static fluidsim 0.9 profile: errors and warnings), grid_resource_estimator.py (a conservative memory, storage and output-file envelope against the plan's declared bounds; never a runtime) and simulation_dry_run.py (the opt-in launch script, its SHA-256 and the command that would run it after approval).

Those free tools are not on the API - they run in the page (or on your machine, from the skill's own scripts/). The API is the two metered lanes, on model gpt-terra: draft turns a description of the study into one strict plan the validator accepts, and review rates a plan against the skill's scientific acceptance gate using the facts the three tools computed. The natural loop: draft, run the tools, review, revise, re-check, then one tiny serial pilot.

Two lanes: the task field

task is the one required field and picks the lane. An unknown or missing value makes the model pick the closest lane and report it in the reply's task.

taskneedsreturns
draftbrief (a new plan), or plan + facts + notes to revise an existing plan (notes then carries the review to act on; a brief may come too)One complete strict plan (schema 1.1, serial, fluidsim 0.9.0, fluidfft 0.4.5) sized as the first bounded step - a tiny serial pilot - with every value the brief did not state listed in assumptions, the physics still to decide in open_questions, and, for a revision, each change in changes.
reviewplan + factsA verdict (ready_for_pilot, revise_first, not_plannable), a response to every browser flag, the twelve gate items rated addressed / thin / missing, concerns with fixes, what to inspect after the pilot, questions for you, and what nothing here establishes.

Worked example: draft

The request the page builds from a description of a study (no plan yet, so no plan or facts):

{
 "task": "draft",
 "brief": "Freely decaying two-dimensional turbulence in a doubly periodic square box of side 2*pi. Nondimensionalise lengths by L0 = box side / (2*pi) and velocities by U0 = the initial rms velocity, so times are in units of L0/U0. Start from seeded random noise with velo_max 0.5. Viscosity nu_2 = 1e-4 (Re = U0 L0 / nu = 10000). Observables: kinetic energy and enstrophy decay curves, and the energy spectrum at t = 10 and t = 20. I want a small pilot on my laptop first (8 cores, 16 GB RAM, 20 GB free disk, 30 minutes), then a production run at 1024x1024."
}

An abbreviated reply in the lane's contract. The real plan is the complete object (every group the validator needs, all nine scientific statements); here parts are cut and marked "...":

{
  "task": "draft",
  "headline": "A serial 128x128 doubly periodic ns2d pilot of freely decaying turbulence (seeded noise, nu_2 = 1e-4, t_end = 20), sized for the laptop budget ahead of the 1024x1024 study.",
  "plan": {
    "schema_version": "1.1",
    "solver": "ns2d",
    "parameters": {
      "nu_2": 0.0001,
      "oper": {"nx": 128, "ny": 128, "Lx": 6.283185307179586, "Ly": 6.283185307179586, "coef_dealiasing": 0.6666666666666666},
      "time_stepping": {"USE_CFL": true, "USE_T_END": true, "cfl_coef": 0.5, "deltat0": 0.01, "deltat_max": 0.05, "max_elapsed": "00:25:00", "t_end": 20, "type_time_scheme": "RK4"},
      "init_fields": {"type": "noise", "noise": {"length": 1.0, "velo_max": 0.5}},
      "forcing": {"enable": false, "...": "..."},
      "output": {"HAS_TO_SAVE": true, "ONLINE_PLOT_OK": false, "periods_save": {"phys_fields": 5, "spatial_means": 0.1, "spectra": 10, "...": "..."}, "...": "..."}
    },
    "scientific": {
      "units_or_nondimensionalization": "Lengths by L0 = box side / (2*pi), velocities by U0 = the initial rms velocity, times by L0/U0.",
      "boundary_conditions": "Doubly periodic square box of side 2*pi in x and y; no walls.",
      "...": "seven more statements in the user's physics"
    },
    "resources": {"cpu_cores": 1, "mpi_ranks": 1, "threads_per_rank": 1, "ram_gib": 16, "disk_gib": 20, "wall_time_minutes": 30, "max_output_files": 200},
    "execution": {"mode": "serial", "output_root": "fluidsim-runs", "random_seed": 12345, "script_name": "run_decay2d_pilot.py"},
    "provenance": {"config_id": "replace-with-config-id", "created_utc": "replace-with-created-utc", "dependency_lock_sha256": "replace-with-uv-lock-sha256", "fluidfft": "0.4.5", "fluidsim": "0.9.0", "python": "3.11"}
  },
  "assumptions": [
    {"field": "parameters.oper.nx", "value": "128", "why": "The brief gives no pilot grid; 128 fits a serial laptop run and leaves 1024x1024 for later."},
    {"field": "parameters.init_fields.noise.length", "value": "1.0", "why": "The brief gives velo_max but not the noise length."},
    {"field": "provenance.config_id", "value": "replace-with-config-id", "why": "Unknown provenance; fill before running."},
    "... one entry per chosen value: grid, domain, viscosity, time bounds, save periods, resources"
  ],
  "open_questions": [
    "Which intermediate grids (for example 256x256 and 512x512) should precede the 1024x1024 run?"
  ],
  "changes": []
}

Worked example: review

A real request the page builds for a 128x64 ns2d.strat pilot of freely evolving internal gravity waves. The validator passes with no errors or warnings, the estimate fits every bound and the browser raises no flags, so its verdict is ready_for_pilot. Below, plan and facts are abbreviated with ... inside the strings; a real call sends the whole plan text (3,439 characters here) and the whole facts string (1,797 characters). Do not hand-write facts: let the page (or the fluidsim skill's three tools, through the page's own deskkit.js - see Building the body) produce it from the exact plan you send.

{
 "task": "review",
 "plan": "{\n  \"schema_version\": \"1.1\",\n  \"solver\": \"ns2d.strat\",\n  \"parameters\": {\n    \"nu_2\": 0.001,\n    ... the whole plan: parameters, scientific, resources, execution, provenance ...\n    \"python\": \"3.11\"\n  }\n}",
 "facts": "{\"page\":\"core-hour-check\",\"skill_tools\":\"solver_config_validator.py, grid_resource_estimator.py, simulation_dry_run.py (fluidsim skill, run in the browser)\",\"plan_sha256\":\"0f9e49ad...\",\"validator\":{\"exit\":0,\"ok\":true,\"errors\":[],\"warnings\":[],\"numerical_convergence_established\":false,\"physical_validity_established\":false},\"estimator\":{\"exit\":0,\"ok\":true,\"limits\":{\"files_within_bound\":true,\"memory_within_bound\":true,\"storage_within_bound\":true},\"estimates\":{\"peak_memory_gib\":0.005196,\"storage_gib\":0.005466,\"estimated_output_files\":13,...},\"grid_points\":8192,...},\"dryrun\":{\"exit\":0,\"script_name\":\"run_strat_pilot.py\",\"script_sha256\":\"3475f8d6...\",...,\"job_submitted\":false},\"derived\":{\"solver\":\"ns2d.strat\",\"dimensions\":2,\"grid\":[{\"axis\":\"x\",\"n\":128,\"L\":6.28319,\"dx\":0.04909,\"kmax_after_truncation\":42.67},{\"axis\":\"y\",\"n\":64,\"L\":3.14159,\"dx\":0.04909,\"kmax_after_truncation\":42.67}],\"min_steps\":200,...},\"flags\":[],\"browser_verdict\":\"ready_for_pilot\"}",
 "notes": "Freely evolving internal gravity waves from noise; this pilot comes before a 256x128 run."
}

An abbreviated reply in the lane's contract (a real reply rates all twelve gate items, each once, in this order):

{
  "task": "review",
  "verdict": "ready_for_pilot",
  "headline": "The plan passes the validator with wide resource margins; it can go to its one tiny serial pilot, but it names no benchmark to interpret results against.",
  "flag_responses": [],
  "gate": [
    {"item": "units", "status": "addressed", "evidence": "Lengths scaled by Ly/pi and time by 1/N with N = 1", "note": "Tied to parameters.N and parameters.oper.Ly."},
    {"item": "boundaries", "status": "addressed", "evidence": "Periodic in x and y", "note": "Matches the solver's periodic box."},
    {"item": "initial_conditions", "status": "addressed", "evidence": "Seeded low-amplitude noise, velo_max=0.01, seed 12345", "note": "Matches parameters.init_fields.noise.velo_max and execution.random_seed."},
    {"item": "forcing", "status": "addressed", "evidence": "Unforced", "note": "Matches parameters.forcing.enable = false."},
    {"item": "resolution", "status": "addressed", "evidence": "128x64 pilot with cubic 2/3 truncation", "note": "facts.derived gives dx 0.04909 and kmax_after_truncation 42.67 on both axes."},
    {"item": "timestep", "status": "addressed", "evidence": "Adaptive CFL 0.3 with deltat_max 0.01", "note": "facts.derived.min_steps 200 is a floor; the CFL may take smaller steps."},
    {"item": "budgets", "status": "addressed", "evidence": "check the residual in spatial means", "note": "periods_save.spatial_means 0.01 is on."},
    {"item": "refinement", "status": "addressed", "evidence": "Repeat at 256x128 and with half deltat_max", "note": "Concrete refinement targets."},
    {"item": "benchmark", "status": "missing", "evidence": "", "note": "No analytical, manufactured, published or independent comparison is named."},
    {"item": "acceptance", "status": "addressed", "evidence": "Energy residual trend below 1 percent", "note": "Quantified and tied to the refinement run."},
    {"item": "resources", "status": "addressed", "evidence": "peak_memory_gib 0.005196 vs ram_gib 4.0", "note": "Storage 0.005466 GiB vs disk_gib 2.0 and 13 files vs 64 also fit."},
    {"item": "provenance", "status": "addressed", "evidence": "config_id strat-pilot-2026-09-27", "note": "No placeholder ids; fluidsim 0.9.0 and fluidfft 0.4.5 pinned."}
  ],
  "concerns": [
    {"severity": "medium", "issue": "No benchmark comparison appears in any scientific statement.", "fix": "Name the comparison target in scientific.convergence_and_refinement before results are interpreted."}
  ],
  "pilot_checks": [
    "CFL and deltat history against deltat_max 0.01.",
    "Energy budget residual from spatial_means, divergence, spectral tail near kmax 42.67, file count against 13, NaN/Inf."
  ],
  "questions": ["Which benchmark should the 256x128 results be compared with?"],
  "not_established": ["Numerical convergence.", "Physical validity.", "Any runtime: facts.estimator.runtime_estimated is false."]
}

Input fields

Every field is a string - plan and facts included, each a JSON text, not an object. This matches the app's declared input schema.

fieldtyperequiredmeaning
taskstringyes"draft" or "review".
planstringno (review: yes)The plan as strict JSON text, exactly as the tools read it. Review: always. Draft: only when revising an existing plan. The page sends up to 40,000 characters and never cuts a plan.
factsstringno (with every plan)A JSON-encoded string of what the three tools found on that plan - see below. The page builds it with DeskKit.buildInput; it goes with every plan.
briefstringno (draft: yes, unless revising)Draft only: your description of the study, in your words (the page sends up to 6,000 characters, cut on a sentence and marked as cut).
notesstringnoYour context, or, for a revision draft, the review to act on (up to 6,000 characters).
questionstringnoSomething you want answered inside the lane's fields (up to 2,000 characters).
retry_notestringnoSent by the page only on its one reformat retry after a reply that did not parse.

The facts string

facts is a JSON string, not an object. It holds page, skill_tools and plan_sha256; validator (exit, ok, errors, warnings, and numerical_convergence_established and physical_validity_established, both always false - or crash / failure when the tool stopped); estimator (exit, ok, limits with memory_within_bound, storage_within_bound and files_within_bound, estimates with peak_memory_gib, idealized_peak_bytes_per_rank, storage_gib, estimated_output_files, state_snapshots and resident_state_bytes, grid_points, declared_bounds, assumptions and runtime_estimated false - or crash / failure); dryrun (exit, script_name, script_sha256, execute_argv_after_approval, mpi_launch_preview_only, job_submitted false - or crash / failure); derived (solver, dimensions, grid with dx and kmax_after_truncation = coef_dealiasing x pi / dx per axis, min_steps = t_end / deltat_max rounded up, forcing, output_records); flags (F1.. with kind - for example validator_error, validator_warning, estimator_memory, estimator_storage, estimator_files, tool_crash, forbidden_label, placeholder_provenance - and text); and browser_verdict.

Produce it with the page, not by hand: every number in it is one the reconciler checks the reply against, and a facts that does not belong to the exact plan you send makes the review meaningless. The page computes it in the browser from the plan in its editor.

Building the body

To build a body that matches the page byte for byte, run the page's own modules in Node 18 or later: save fluidkit.js (the port of the three tools) and deskkit.js next to the script below. Both export themselves with module.exports, and deskkit.js loads ./fluidkit.js itself.

// make-body.js - build the exact body the page sends, with the page's own code.
//   node make-body.js review plan.json "notes" "question"             > body.json
//   node make-body.js draft  brief.txt "notes" "question"             > body.json
//   node make-body.js draft  brief.txt "review text" "" plan.json     > body.json   (revise plan.json)
const fs = require("fs");
const K = require("./deskkit.js");                     // runs fluidkit.js: validator, estimator, dry run
const [lane, file, notes = "", question = "", revisePlan = ""] = process.argv.slice(2);
const read = (p) => (p ? fs.readFileSync(p, "utf8") : "");
let A = null;
const set = { notes, question };
if (lane === "review") {
  A = K.analyze(read(file), { name: "config.json" });
} else {
  set.brief = read(file);
  if (revisePlan) { A = K.analyze(read(revisePlan), { name: "config.json" }); set.revise = true; }
}
const body = K.buildInput(lane, A, set);
if (!body) throw new Error("nothing to send: review needs a plan, draft a brief or a plan (plans up to 40,000 characters)");
K.mustBeObject(body);
if (A) console.error("browser verdict:", A.verdict, "| flags:", A.flags.map((f) => f.id + " " + f.kind).join(", ") || "none");
console.error("idempotency key: core-hour-check:" + body.task + ":" + K.hashInput(body) + ":a1");
process.stdout.write(JSON.stringify(body));
# Or build the body in any language. Only a draft from a brief needs no facts;
# every plan must travel with the facts the page computed for it, as a STRING.
import json

body = {
    "task": "draft",
    "brief": open("brief.txt").read(),
    "notes": "Laptop first; production later.",
}
json.dump(body, open("body.json", "w"))

The output

A finished run's output text is one JSON object in the lane's contract, every key present ([] where there is nothing to say). Parse it yourself (step 7). The model is told to send no prose and no code fences; still, extract the outermost object defensively.

lanekeycontent
bothtask"draft" or "review": the lane the model answered.
draftheadlineOne sentence: what the plan sets up.
draftplanThe complete plan object (not a string): schema_version "1.1", solver, parameters, scientific, resources, execution (mode serial), provenance (fluidsim "0.9.0", fluidfft "0.4.5", python "3.11"; unknown ids start with replace-with-).
draftassumptions[{field, value, why}]: every value the brief did not state, field a dotted path such as parameters.oper.nx.
draftopen_questionsStrings: what you must decide (physics the brief does not give).
draftchangesStrings: each change against a given plan; [] unless revising.
reviewverdictready_for_pilot | revise_first | not_plannable.
reviewheadlineOne sentence.
reviewflag_responses[{ref, stance, note}], one per flag in facts.flags; stance is confirmed | dismissed | explained.
reviewgate[{item, status, evidence, note}], the twelve items once each in this order: units, boundaries, initial_conditions, forcing, resolution, timestep, budgets, refinement, benchmark, acceptance, resources, provenance; status is addressed | thin | missing.
reviewconcerns[{severity, issue, fix}]; severity is high | medium | low.
reviewpilot_checksStrings: what to inspect after the tiny serial pilot (CFL and deltat history, divergence, budgets, spectral tails, output growth, NaN/Inf).
reviewquestionsStrings: what you must decide or supply (always includes a missing benchmark or refinement).
reviewnot_establishedStrings: always includes numerical convergence and physical validity.

The page does more than parse: its recon.js reconciles every reply against the input. For a review it checks that every flag is answered (and nothing else), that the twelve gate items are each rated once, that the verdict is not looser than facts.browser_verdict (unless every flag behind it was dismissed) and never ready_for_pilot with a blocking item missing, that no forbidden label (DNS, converged, validated, steady, physically correct) is asserted, and that every number is grounded in the plan, the facts or your notes. For a draft it re-runs the skill's validator, estimator and dry run on the returned plan, and checks that every assumption names a field that is in the plan and, for a revision, that changes is not empty. Over the API you get the raw reply; run the same checks, or load recon.js in Node (Recon.reconcile(Recon.normalize(Recon.parseResult(text), task), {input: body})).

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": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}

The token is minted for this app (the guest endpoint takes {"slug":"core-hour-check"} in its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ....

The input object IS the request body. There is no {"input": ...} wrapper. A wrapped body is answered with an unknown field 'input' warning, and the model never sees your text.

Error codes

statuscodewhat to do
400validation_errorA field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that sends the token, unwraps data and raises on ok: false. The token comes from the token page (Copy token or Copy shell export); step 2 covers the kinds of token and minting one from code.

# 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="core-hour-check"
TOKEN="$SKILLSAFE_TOKEN"   # from https://core-hour-check.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
}

2. 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. A guest token, minted with POST /guest and {"slug":"core-hour-check"}, can call /me and /estimate; the run is metered, so /run and /run-stream need a personal token.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://core-hour-check.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; a run 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":"core-hour-check"}'
# {"ok":true,"data":{"token":"...","subject_type":"guest"}}

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run (free)

/estimate returns the model binding and the credits a run would reserve. It creates no job and charges nothing. Expect model_alias gpt-terra and markup_bps 1000 (a 10% markup). hold_credits is a reservation, not the price: it is held against your balance while the run executes and released afterwards. min_credits is the least balance that can start a run. What you actually pay is charged_credits, reported on the finished job and in the done event, and it is usually far lower than the hold. The body is the input object itself, with no {"input": ...} wrapper. /estimate does not validate the body, so check the shape yourself: an object whose every value is a string, task equal draft or review, a review with a non-empty plan and facts, a draft with a non-empty brief or a plan, and every plan travelling with a facts that is a JSON string parsing to an object (the page's own guard, DeskKit.mustBeObject, refuses to spend on a non-object body).

# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or by hand. estimate does not validate it, so check the shape first:
python3 -c '
import json
b = json.load(open("body.json"))
assert isinstance(b, dict) and b.get("task") in ("draft", "review")
assert all(isinstance(v, str) for v in b.values())
if b["task"] == "review" or b.get("plan"):
    assert b.get("plan", "").strip() and isinstance(json.loads(b.get("facts", "")), dict)
else:
    assert b.get("brief", "").strip()
'
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
#   "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is the actual cost, usually far lower.

5. Run it, then poll

POST /run returns a job_id; poll GET /jobs/{id} until it is terminal. The reply is a string at data.output.output: JSON.parse it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the attempt number, core-hour-check:<lane>:<hash>:a<attempt> (for example core-hour-check:review:112ce8u0i1o:a1 for the review example above), so a retried request returns the same job instead of billing a second run. Use one key per distinct input: an edited plan (so changed facts), brief, notes or question is a new hash, the same plan in the other lane is a new key, and replaying an old key with a different body is a 409. The page uses DeskKit.hashInput(body) for the hash (a digest of the body with its keys sorted, covering task, plan, facts, brief, notes and question; make-body.js prints the key); any stable digest of the body works from other languages. Leave retry_note out of the hash and bump the attempt instead.

# 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.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')   # draft or review
KEY="core-hour-check:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"task\":\"review\",\"verdict\":\"ready_for_pilot\",\"headline\":\"...\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

POST /run-stream takes the same body and headers and answers with server-sent events: job (the job id), delta (chunks of the reply) and done (the status, charged_credits, truncated and, when present, the full output). A browser page may receive only tick heartbeats and then done, never a delta, so take the reply from done.output.output when it is there, fall back to the concatenated deltas, and fall back again to GET /jobs/{id}.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
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":"{\"task\":\"review\",\"verdict\":\"ready_for_pilot\",\"headline\":\"The"}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

The reply is one JSON object serialised as a string. Parse it, check that task is the lane you asked for, then use it: for a review, the verdict and the gate; for a draft, save plan as a file and run the skill's tools on it again (the page does this for you).

# The reply is a JSON string inside data.output.output. Pull it out and parse it:
printf '%s' "$OUT" | python3 -c 'import sys,json;r=json.loads(json.load(sys.stdin)["data"]["output"]["output"]);print(r["task"],r.get("verdict",""),r["headline"])'
# A draft: save the plan and re-check it with the skill's own tools.
python3 -c 'import json;r=json.load(open("reply.json"));json.dump(r["plan"],open("config.json","w"),indent=2)'
python3 scripts/solver_config_validator.py --config config.json

Costs

Invariants worth asserting