Drive Recall Desk from your own code
Everything the web page does is available over HTTP: send a student's goal and material with the calendar facts you computed, and get the same study plan back — or send a unit's notes and get the flashcards, each answer grounded in those notes. The natural use is a course pipeline that regenerates every student's plan when the exam date moves, or a deck builder that turns each chapter of notes into an Anki import and rejects any card whose answer the notes do not contain.
One thing to be clear about before the first call: the model never computes the schedule.
Review dates, phases, workload, fit and the forgetting-curve forecast are computed by the caller with FSRS-6
and sent as facts. The model's job is judgement over those facts — the goal, what kind of thing
each unit is and how to retrieve it, the pitfalls, the prose — and, in the cards lane, writing cards from the
notes. See computing the facts yourself; the engine the web page uses ships as plain
scripts (/srs.js, /cardlint.js, /recon.js) you can load in node.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest endpoint takes {"slug":"recall-desk"} in its body), so
no slug header is needed afterwards — send your token as Authorization: Bearer … on every call.
The input object IS the request body. There is no {"input": …} wrapper. A
body that wraps the object returns a cheerful 200 and the lane never sees your task,
goal or facts — post the object itself, exactly as the worked examples show it.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. Sign in for a personal token. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | The input object is missing a required field — task, goal and facts — or a field is the wrong type. A body that is not valid JSON at all comes back as a 400. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser already holds,
with Copy token and Copy shell export buttons, and a sign-in button for a
personal token. A guest token can call /me and /estimate; both
lanes are metered, so they need a personal token from signing in.
# The token page is the shortest path: https://recall-desk.skillsafe.ai/tokens.html shows the token this
# browser holds and hands you a shell export. From the command line, mint a guest token
# (enough for /me and /estimate; running a lane 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": "recall-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest",...}}
export SKILLSAFE_TOKEN="aut_..."
import json, urllib.request
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api/guest", data=json.dumps({"slug": "recall-desk"}).encode(), headers={"Content-Type": "application/json"}, method="POST")
token = json.load(urllib.request.urlopen(req))["data"]["token"] # guest: /me and /estimate only
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug: "recall-desk" }) });
const token = (await res.json()).data.token; // guest token: /me and /estimate only
resp, _ := http.Post("https://api.skillsafe.ai/v1/app-api/guest", "application/json", bytes.NewReader([]byte(`{"slug":"recall-desk"}`)))
var env struct{ Data struct{ Token string `json:"token"` } `json:"data"` }
json.NewDecoder(resp.Body).Decode(&env); token := env.Data.Token
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest")).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"recall-desk\"}")).build();
var body = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body();
String token = new ObjectMapper().readTree(body).at("/data/token").asText();
require "net/http"; require "json"
res = Net::HTTP.post(URI("https://api.skillsafe.ai/v1/app-api/guest"), { slug: "recall-desk" }.to_json, "Content-Type" => "application/json")
token = JSON.parse(res.body)["data"]["token"]
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode(["slug" => "recall-desk"]), CURLOPT_RETURNTRANSFER => true]);
$token = json_decode(curl_exec($ch), true)["data"]["token"];
var http = new HttpClient();
var res = await http.PostAsync("https://api.skillsafe.ai/v1/app-api/guest", new StringContent("{\"slug\":\"recall-desk\"}", Encoding.UTF8, "application/json"));
var token = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement.GetProperty("data").GetProperty("token").GetString();
2. A tiny client
# Every call is the same three things: the base URL, your bearer token, a JSON body.
call() { curl -sS -X "$1" "https://api.skillsafe.ai/v1/app-api/$2" -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" ${3:+--data-binary @"$3"}; }
# call GET me call POST estimate body.json call POST run body.json
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(method, path, body=None, token="YOUR_TOKEN", headers=None):
data = json.dumps(body).encode() if body is not None else None
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", **(headers or {})}
with urllib.request.urlopen(urllib.request.Request(f"{BASE}/{path}", data=data, headers=h, method=method)) as r:
env = json.load(r)
if not env.get("ok"): raise RuntimeError(env["error"])
return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
async function call(method, path, body, token = "YOUR_TOKEN", headers = {}) {
const r = await fetch(`${BASE}/${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...headers }, body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json(); if (!env.ok) throw Object.assign(new Error(env.error.message), env.error); return env.data;
}
const base = "https://api.skillsafe.ai/v1/app-api"
func call(method, path string, body any, token string, headers map[string]string) (map[string]any, error) {
var rd io.Reader; if body != nil { b, _ := json.Marshal(body); rd = bytes.NewReader(b) }
req, _ := http.NewRequest(method, base+"/"+path, rd)
req.Header.Set("Authorization", "Bearer "+token); req.Header.Set("Content-Type", "application/json")
for k, v := range headers { req.Header.Set(k, v) }
resp, err := http.DefaultClient.Do(req); if err != nil { return nil, err }; defer resp.Body.Close()
var env struct{ OK bool; Data map[string]any; Error map[string]any }; json.NewDecoder(resp.Body).Decode(&env)
if !env.OK { return nil, fmt.Errorf("%v", env.Error) }; return env.Data, nil
}
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static JsonNode call(String method, String path, Object body, String token, Map<String,String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + "/" + path)).header("Authorization", "Bearer " + token).header("Content-Type", "application/json");
headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(new ObjectMapper().writeValueAsString(body)));
var env = new ObjectMapper().readTree(HttpClient.newHttpClient().send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (!env.get("ok").asBoolean()) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(method, path, body = nil, token: "YOUR_TOKEN", headers: {})
uri = URI("#{BASE}/#{path}"); req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{token}"; req["Content-Type"] = "application/json"; headers.each { |k, v| req[k] = v }
req.body = body.to_json if body
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s unless env["ok"]; env["data"]
end
const BASE = "https://api.skillsafe.ai/v1/app-api";
function call($method, $path, $body = null, $token = "YOUR_TOKEN", $headers = []) {
$h = array_merge(["Authorization: Bearer $token", "Content-Type: application/json"], $headers);
$ch = curl_init(BASE . "/$path");
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true] + ($body === null ? [] : [CURLOPT_POSTFIELDS => json_encode($body)]));
$env = json_decode(curl_exec($ch), true); if (!$env["ok"]) throw new Exception(json_encode($env["error"])); return $env["data"];
}
const string Base = "https://api.skillsafe.ai/v1/app-api";
static async Task<JsonElement> Call(string method, string path, object? body, string token, Dictionary<string,string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), $"{Base}/{path}");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (headers != null) foreach (var kv in headers) req.Headers.Add(kv.Key, kv.Value);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var env = JsonDocument.Parse(await (await new HttpClient().SendAsync(req)).Content.ReadAsStringAsync()).RootElement;
if (!env.GetProperty("ok").GetBoolean()) throw new Exception(env.GetProperty("error").ToString());
return env.GetProperty("data");
}
3. Check the session and the balance
/me returns only subject_type, subject_id and credits; a signed-in caller is subject_type == "user".
call GET me
# -> {"ok":true,"data":{"subject_type":"user","subject_id":"...","credits":48210}}
# subject_type is "user" for a personal token and "guest" for a guest one.
me = call("GET", "me", token=token)
print(me["subject_type"], me["credits"]) # "user" or "guest"; balance in credits (1 credit = $0.0001)
const me = await call("GET", "me", undefined, token);
console.log(me.subject_type, me.credits); // "user" | "guest"
me, _ := call("GET", "me", nil, token, nil)
fmt.Println(me["subject_type"], me["credits"])
var me = call("GET", "me", null, token, Map.of());
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = call("GET", "me", token: token)
puts "#{me["subject_type"]} #{me["credits"]}"
$me = call("GET", "me", null, $token);
echo $me["subject_type"], " ", $me["credits"];
var me = await Call("GET", "me", null, token);
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
4. Price the run — free
/estimate creates no job and charges nothing. It returns the model binding (model,
model_alias, markup_bps) and hold_credits, the amount reserved for the
run — present it as reserved, never as the price; the settled charged_credits is
usually far lower. Its warnings array names a missing task, goal or
facts and any undeclared field, but a warning does not stop a run, so check
those three yourself before posting.
The fields both lanes take
| field | type | meaning |
|---|---|---|
task | "plan" | "cards" | The lane. Required. |
goal | string | What the student is preparing for, by when, and what counts as success. Required. |
material | string | Plan lane: one unit per line, or # headings with notes under each. The page clips the middle beyond 12,000 characters. |
unit, notes | string | Cards lane: the unit's name and the notes the cards are written from (and checked against). |
style, want | basic | cloze | mixed; integer | Cards lane: the card type and how many cards. |
own_cards | string | Cards lane, optional: the student's existing cards (Q:/A: pairs, front TAB back, or {{c1::cloze}} lines) to be reviewed. |
facts | string (JSON) | Everything the engine computed. Required; see below. |
Computing the facts yourself
Load https://recall-desk.skillsafe.ai/srs.js, /cardlint.js and /recon.js in node with a stub
window. For the plan lane call SRS.plan(spec) with { start, exam, units: SRS.parseUnits(material),
minutes_per_day, study_weekdays: [7 booleans, Monday first], new_minutes, review_minutes, retention, prior, final_pass },
then send JSON.stringify(Recon.planFacts(plan)) as facts. For the cards lane send
JSON.stringify(Recon.cardsFacts({ unit, notes, style, want, own_cards })). The engine is FSRS-6 with its
published default parameters and is deterministic: the same inputs give the same calendar on any machine. The model may only
quote these figures, so what you send here is what the reply is checked against.
The plan request body, in full
{
"task": "plan",
"goal": "Pharmacology midterm on 28 October 2026; I need at least 70% to keep my scholarship.",
"material": "# Autonomic pharmacology\nCholinergic agonists and antagonists ...\n# Antihypertensives\nACE inhibitors, ARBs ...",
"facts": "<the JSON string Recon.planFacts(SRS.plan(spec)) returns - see step 4>"
}
# Save the run body as body.json - the object ITSELF, no {"input": ...} wrapper.
call POST estimate body.json
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"hold_credits":3120,"min_credits":410,"sponsor_enabled":false}}
est = call("POST", "estimate", body, token=token)
print(est["hold_credits"], "reserved;", est["model_alias"], "->", est["model"]) # free, no job created
const est = await call("POST", "estimate", body, token);
console.log(est.hold_credits, "reserved;", est.model_alias, "->", est.model); // free
est, _ := call("POST", "estimate", body, token, nil)
fmt.Println(est["hold_credits"], est["model_alias"], est["model"])
var est = call("POST", "estimate", body, token, Map.of());
System.out.println(est.get("hold_credits") + " " + est.get("model"));
est = call("POST", "estimate", body, token: token)
puts "#{est["hold_credits"]} reserved on #{est["model"]}"
$est = call("POST", "estimate", $body, $token);
echo $est["hold_credits"], " reserved on ", $est["model"];
var est = await Call("POST", "estimate", body, token);
Console.WriteLine($"{est.GetProperty("hold_credits")} reserved on {est.GetProperty("model")}");
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until
status is succeeded, failed or cancelled. The reply is
data.output.output — the lane's JSON object as a string — and
data.charged_credits is what the run actually cost. If data.truncated is true the
balance sat between min_credits and hold_credits and the reply was cut short.
# Always send an Idempotency-Key derived from the input and the lane: a retried request with the
# same key is not billed twice. Then poll the job until it is terminal.
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: recall-desk:plan:<sha256 of body>:a1" --data-binary @body.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
until call GET "jobs/$JOB" | grep -qE '"status":"(succeeded|failed|cancelled)"'; do sleep 2; done
call GET "jobs/$JOB" # data.output.output is the reply JSON as a STRING; data.charged_credits is what it cost
import hashlib, time
key = "recall-desk:" + body["task"] + ":" + hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16] + ":a1"
job = call("POST", "run", body, token=token, headers={"Idempotency-Key": key})
while True:
j = call("GET", f"jobs/{job['job_id']}", token=token)
if j["status"] in ("succeeded", "failed", "cancelled"): break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], "credits;", reply["verdict"], "-", reply["headline"])
const key = `recall-desk:${body.task}:${await sha16(JSON.stringify(body))}:a1`; // any stable hash of the body
const job = await call("POST", "run", body, token, { "Idempotency-Key": key });
let j; do { await new Promise(r => setTimeout(r, 2000)); j = await call("GET", `jobs/${job.job_id}`, undefined, token); } while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, "credits;", reply.verdict, "-", reply.headline);
job, _ := call("POST", "run", body, token, map[string]string{"Idempotency-Key": "recall-desk:plan:<sha256 of body>:a1"})
var j map[string]any
for { j, _ = call("GET", "jobs/"+job["job_id"].(string), nil, token, nil); s := j["status"].(string); if s == "succeeded" || s == "failed" || s == "cancelled" { break }; time.Sleep(2 * time.Second) }
var reply map[string]any; json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], reply["verdict"], reply["headline"])
var job = call("POST", "run", body, token, Map.of("Idempotency-Key", "recall-desk:plan:<sha256 of body>:a1"));
JsonNode j;
do { Thread.sleep(2000); j = call("GET", "jobs/" + job.get("job_id").asText(), null, token, Map.of()); } while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
var reply = new ObjectMapper().readTree(j.at("/output/output").asText());
System.out.println(j.get("charged_credits") + " credits; " + reply.get("verdict").asText() + " - " + reply.get("headline").asText());
job = call("POST", "run", body, token: token, headers: { "Idempotency-Key" => "recall-desk:plan:<sha256 of body>:a1" })
loop do
j = call("GET", "jobs/#{job["job_id"]}", token: token)
if %w[succeeded failed cancelled].include?(j["status"])
reply = JSON.parse(j["output"]["output"]); puts "#{j["charged_credits"]} credits; #{reply["verdict"]} - #{reply["headline"]}"; break
end
sleep 2
end
$job = call("POST", "run", $body, $token, ["Idempotency-Key: recall-desk:plan:<sha256 of body>:a1"]);
do { sleep(2); $j = call("GET", "jobs/" . $job["job_id"], null, $token); } while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " credits; ", $reply["verdict"], " - ", $reply["headline"];
var job = await Call("POST", "run", body, token, new() { ["Idempotency-Key"] = "recall-desk:plan:<sha256 of body>:a1" });
JsonElement j;
do { await Task.Delay(2000); j = await Call("GET", $"jobs/{job.GetProperty("job_id").GetString()}", null, token); } while (j.GetProperty("status").GetString() is not ("succeeded" or "failed" or "cancelled"));
var reply = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine($"{j.GetProperty("charged_credits")} credits; {reply.GetProperty("verdict")} - {reply.GetProperty("headline")}");
The cards lane, in full
Same endpoints, same envelope, task: "cards", the notes instead of the material:
{
"task": "cards",
"goal": "Physiology end-of-module exam in three weeks; cardiovascular block.",
"unit": "The cardiac cycle",
"notes": "One cardiac cycle is one heartbeat: at a resting rate of 75 beats per minute it lasts about 0.8 seconds ...",
"style": "mixed",
"want": 10,
"own_cards": "Q: What is the stroke volume?\nA: 70 mL",
"facts": "<Recon.cardsFacts output - see step 4>"
}
6. Or stream it
# Server-sent events. To curl, each `delta` frame carries a chunk of the reply; a browser page
# receives `tick` heartbeats and one final `done` instead. The final frame carries the whole job.
curl -sS -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" -H "Idempotency-Key: recall-desk:plan:<hash>:a1" --data-binary @body.json
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api/run-stream", data=json.dumps(body).encode(), method="POST",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "text/event-stream", "Idempotency-Key": key})
buf, event = "", None
for raw in urllib.request.urlopen(req):
line = raw.decode().rstrip("\n")
if line.startswith("event:"): event = line[6:].strip()
elif line.startswith("data:"):
d = json.loads(line[5:])
if event == "delta": buf += d.get("text", "")
elif event == "done": print("charged", d.get("charged_credits")); break
const r = await fetch("https://api.skillsafe.ai/v1/app-api/run-stream", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "text/event-stream", "Idempotency-Key": key }, body: JSON.stringify(body) });
const reader = r.body.getReader(), dec = new TextDecoder(); let buf = "", text = "";
for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true });
let i; while ((i = buf.indexOf("\n\n")) !== -1) { const frame = buf.slice(0, i); buf = buf.slice(i + 2);
const ev = /^event: ?(.*)$/m.exec(frame)?.[1], data = /^data: ?(.*)$/m.exec(frame)?.[1];
if (ev === "delta" && data) text += JSON.parse(data).text || ""; if (ev === "done") console.log("done", JSON.parse(data).charged_credits); } }
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run-stream", bytes.NewReader(bodyJSON))
req.Header.Set("Authorization", "Bearer "+token); req.Header.Set("Content-Type", "application/json"); req.Header.Set("Accept", "text/event-stream"); req.Header.Set("Idempotency-Key", key)
resp, _ := http.DefaultClient.Do(req); sc := bufio.NewScanner(resp.Body); event := ""
for sc.Scan() { l := sc.Text(); if strings.HasPrefix(l, "event:") { event = strings.TrimSpace(l[6:]) } else if strings.HasPrefix(l, "data:") && event == "delta" { var d struct{ Text string }; json.Unmarshal([]byte(l[5:]), &d); text += d.Text } }
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/run-stream")).header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.header("Accept", "text/event-stream").header("Idempotency-Key", key).POST(HttpRequest.BodyPublishers.ofString(bodyJson)).build();
var lines = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofLines()).body();
String event = ""; var text = new StringBuilder();
for (String l : (Iterable<String>) lines::iterator) { if (l.startsWith("event:")) event = l.substring(6).trim(); else if (l.startsWith("data:") && event.equals("delta")) text.append(new ObjectMapper().readTree(l.substring(5)).path("text").asText()); }
uri = URI("https://api.skillsafe.ai/v1/app-api/run-stream"); req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json", "Accept" => "text/event-stream", "Idempotency-Key" => key)
req.body = body.to_json; event = ""; text = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| if l.start_with?("event:") then event = l[6..].strip elsif l.start_with?("data:") && event == "delta" then text << JSON.parse(l[5..])["text"].to_s end } } }
end
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/run-stream"); $text = ""; $event = "";
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json", "Accept: text/event-stream", "Idempotency-Key: $key"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$text, &$event) { foreach (explode("\n", $chunk) as $l) { if (str_starts_with($l, "event:")) $event = trim(substr($l, 6)); elseif (str_starts_with($l, "data:") && $event === "delta") $text .= json_decode(substr($l, 5), true)["text"] ?? ""; } return strlen($chunk); }]);
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream") { Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") };
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); req.Headers.Accept.ParseAdd("text/event-stream"); req.Headers.Add("Idempotency-Key", key);
using var s = await (await new HttpClient().SendAsync(req, HttpCompletionOption.ResponseHeadersRead)).Content.ReadAsStreamAsync(); using var rd = new StreamReader(s);
string? l, ev = ""; var text = new StringBuilder();
while ((l = await rd.ReadLineAsync()) != null) { if (l.StartsWith("event:")) ev = l[6..].Trim(); else if (l.StartsWith("data:") && ev == "delta") text.Append(JsonDocument.Parse(l[5..]).RootElement.GetProperty("text").GetString()); }
7. Parse the reply
The reply JSON is a string inside the envelope, so unwrap it twice. The page's normaliser tolerates a missing array (treated as empty) and an enum outside the contract (coerced to a safe value), but not a missing plan_statement (plan) or an empty cards array (cards) — those trigger one reformat retry under the same Idempotency-Key family.
# The reply JSON is a string inside the envelope, so unwrap it twice:
call GET "jobs/$JOB" | python3 -c 'import sys,json; j=json.load(sys.stdin)["data"]; r=json.loads(j["output"]["output"]); print(r["lane"], r["verdict"]); print(r.get("plan_statement") or "\n".join(c["front"] for c in r.get("cards", [])))'
r = json.loads(j["output"]["output"])
assert r["lane"] == body["task"]
for k in ("title", "headline", "verdict", "summary", "notes_on_input", "risks", "next_steps"): assert k in r, k
if r["lane"] == "plan": print(r["plan_statement"]); [print(u["unit"], "-", u["method"]) for u in r["units"]]
else: [print(c["type"], "|", c["front"], "|", c["back"]) for c in r["cards"]]
const r = JSON.parse(j.output.output);
if (r.lane !== body.task) console.warn("the model answered as", r.lane);
console.log(r.verdict, r.headline);
console.log(r.lane === "plan" ? r.plan_statement : r.cards.map((c) => c.front).join("\n"));
var r struct{ Lane, Verdict, Headline, PlanStatement string `json:"plan_statement"`; Cards []map[string]any }
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &r)
fmt.Println(r.Verdict, r.Headline, len(r.Cards))
var r = new ObjectMapper().readTree(j.at("/output/output").asText());
System.out.println(r.get("verdict").asText() + " - " + r.get("headline").asText());
r.withArray("risks").forEach(x -> System.out.println("risk: " + x.asText()));
r = JSON.parse(j["output"]["output"])
puts "#{r["verdict"]} - #{r["headline"]}"
puts r["lane"] == "plan" ? r["plan_statement"] : r["cards"].map { |c| c["front"] }.join("\n")
$r = json_decode($j["output"]["output"], true);
echo $r["verdict"], " - ", $r["headline"], "\n", $r["lane"] === "plan" ? $r["plan_statement"] : implode("\n", array_column($r["cards"], "front"));
var r = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine($"{r.GetProperty("verdict")} - {r.GetProperty("headline")}");
The output contract
Every reply carries the common envelope, then the lane body.
"lane": "plan" | "cards", "title": string, "headline": string, "verdict": enum, "summary": string,
"notes_on_input": [string], "risks": [string], "next_steps": [string]
The plan lane
"verdict": "fits" | "tight" | "does_not_fit" | "rework_goal",
"plan_statement": string,
"goal_check": { "status": "clear" | "partly" | "vague", "success_criterion": string, "note": string },
"units": [ { "unit": string, "kind": "facts" | "procedure" | "concept" | "mixed", "method": "flashcards" | "worked problems" | "free recall" | "explain it aloud" | "practice test", "why": string } ],
"phase_reading": string, "forecast_reading": string, "interleaving": string,
"pitfalls": [ { "pitfall": string, "applies": "yes" | "no" | "unclear", "note": string } ],
"tracking": string
The cards lane
"verdict": "ready" | "ready_with_fixes" | "thin_notes",
"cards": [ { "type": "basic" | "cloze", "front": string, "back": string, "tags": [string], "mnemonic": string } ],
"coverage": string,
"own_cards_review": [ { "card": string, "issue": string, "fix": string } ],
"study_tip": string
Invariants worth asserting
laneequals thetaskyou sent; a mismatch means the router chose the other lane and the body follows that lane's contract.- In the plan lane, every number in
plan_statement,phase_readingandforecast_readingappears in thefactsyou sent or in the student's own text - the model may only quote.verdictequalsfacts.fitunless it isrework_goal.unitshas one row perfacts.unitsrow, same names, same order;pitfallshas nine rows. The page'sRecon.reconcile(reply, facts, { goal, material })does these checks; call it yourself. - In the cards lane,
cards.lengthequalswantunless the verdict isthin_notes; every cloze front matches{{cN::...}}with an empty back; every basic back is 20 words or fewer; every answer's content words and every number appear innotes.CardLint.lintDeck(cards, notes)andRecon.reconcile(reply, facts, { notes })do these checks.
Truncation and partial results
When the balance covers min_credits but not hold_credits, the run executes with a
reduced output cap and returns "truncated": true. The reply may then be cut mid-object; the
page closes the JSON and shows whatever sections parsed, marked as partial. Do the same, or top up and
re-run with a new Idempotency-Key suffix (:a2) so the retry is a deliberate second run.