← Recall Desk / API
Tokens

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

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Sign in for a personal token.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The 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_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA 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_..."

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

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.

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

fieldtypemeaning
task"plan" | "cards"The lane. Required.
goalstringWhat the student is preparing for, by when, and what counts as success. Required.
materialstringPlan lane: one unit per line, or # headings with notes under each. The page clips the middle beyond 12,000 characters.
unit, notesstringCards lane: the unit's name and the notes the cards are written from (and checked against).
style, wantbasic | cloze | mixed; integerCards lane: the card type and how many cards.
own_cardsstringCards lane, optional: the student's existing cards (Q:/A: pairs, front TAB back, or {{c1::cloze}} lines) to be reviewed.
factsstring (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}}

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

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

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", [])))'

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

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.