The API¶
Everything the web app does goes through this JSON API, and the Mac and Android apps are native clients of it. This is the shape of each request and answer as of October 2026. Field names are stable: new fields can appear at any time (ignore what you don't know), but a field that changes shape or goes away gets announced first.
Signing in¶
POST /api/login{"user": "...", "password": "..."}sets thebinahcookie. Wrong passwords are throttled (5 per address per 15 minutes, 20 an hour in all) and send me an alert.GET /api/me→{"signed_in": true, "has_password": true, "user": "..."}POST /api/logout{},POST /api/account{"current", "password"}(10+ characters; signs out every other browser).- An app signs in once with the cookie and then uses a bearer token of its own; see Devices. Every
/api/route takesAuthorization: Bearer <token>instead of the cookie.
Any 401 means "sign in again". Errors are {"detail": "a sentence a person can read"} with a 4xx status.
Today¶
GET /api/today →
{
"edition": {
"day": "2026-10-20", "finished": false,
"slots": [{"slot": 0, "game": "gist", "name": "Gist & Infer", "pillar": "u", "skill": "gist", "minutes": 2,
"items": ["gist:p15393"], "done": false, "answered": 0, "scores": [], "source": "On Writing Well…"}],
"recall": {"item": "gist:p361", "book": "…", "reference": "…", "key_points": ["…"], "mark": null}
},
"week": {"active": 1, "goal": 4, "days": [{"day": "2026-10-20", "answers": 0, "rest": false, "future": false, "today": true}]},
"counts": {"editions": 1, "answers": 3, "passages": 1, "arguments": 1, "minutes_spoken": 0.0, "active_weeks": 1},
"ratings": {"gist": {"rating": 1180, "n": 1, "calibrating": true}},
"skills": {"gist": {"name": "Gist", "pillar": "u"}},
"games": {"gist": {"name": "Gist & Infer", "skill": "gist", "lane": "graded", "minutes": 2, "blurb": "…"}}
}
- A slot is one game in today's edition.
itemsis usually one id; Technique or Fair Move has six. Play a slot by passingdayandslotwith each answer, so it counts for today. pillarisu(Understand),r(Reason),e(Express) ors(Speak).recallis yesterday's passage, ornull. Once it has amark, don't show it again.POST /api/today/recall{"answer": "what I remembered", "mark": "yes" | "partly" | "no"}.GET /api/nav→{"today": {"done", "total"}, "golden": {"n", "target"}, "bench_due": false, "week": {"active", "goal"}, "rest_weekday": 5}, for a sidebar or menu bar.GET /api/widgetis the at-a-glance version, for a home-screen widget or a menu bar (below).
Practice¶
GET /api/practice→[{"game": "gist", "name", "skill", "lane", "minutes", "blurb", "live": 11}]GET /api/practice/{game}→{"items": ["gist:p…"]}, picked so you'd get about 75% right. Six ids fortech. A404with adetailwhen there's nothing to play.
Items¶
GET /api/item/{id} → the item without its key. Every item has id, game, skill and source. For items from my books, source is {"book", "author", "chapter", "passage" or "sentence", "highlighted"}; for the written-for-Binah ones it's {"set": "technique"} or {"set": "jam topics"}. Then, by game:
| Game | Fields | The answer you send |
|---|---|---|
gist Gist & Infer |
passage, inference_q |
{"gist": "20 words or fewer", "inference": "…"} |
subtext |
passage, question |
{"text": "…"} |
bloat Cut the Bloat |
padded, par (the author's word count) |
{"text": "my cut"} |
weak Find the Weak Step |
passage, sentences (the passage split into sentences; "¶" marks a paragraph break), flaws (the labels to choose from; "none (sound)" means it holds up) |
{"index": 3, "flaw": "false cause", "why": "optional, one line"} (index may be null when the flaw is "none (sound)") |
word The Author's Word |
sentence (with _____ for the gap), options |
{"choice": "the word"} |
tech Technique or Fair Move? |
text, context, techniques (the labels) |
{"kind": "technique" | "fair", "technique": "label" or null, "confidence": 50–100} |
jam Just a Minute |
topic, level, kind |
a recording, below |
Answering¶
POST /api/attempts {"item_id", "answer", "self_rating": 0–4 or null, "ms": time taken, "day": "YYYY-MM-DD" or null, "slot": 0 or null} → an attempt:
{"id": 41, "item_id": "weak:p4057", "game": "weak", "skill": "weak", "status": "done",
"self_rating": null, "answer": {…}, "instant": {…}, "grade": null, "revision": null, "grade2": null,
"score": 0.5, "score2": null, "rating_before": 1200, "rating_after": 1184, "revising": false, "disputed": null,
"key": {…}}
- Instant games (
weak,word,tech) come back"status": "done"with the score, the rating change and thekey, all at once.instantholds what code scored: - weak:
{"score", "spot_ok", "flaw_ok", "key_index"}, wherekey_indexis the sentence the key points at (nullwhen the argument is sound). If you sent awhy, Claude reads it afterwards: poll untilgradeis set (orgrading_lateris true), which gives{"reason": 0–4, "work_on"}. That doesn't change the score. - word:
{"score"}. - tech:
{"score", "kind_ok", "label_ok", "brier"}. - Graded games (
gist,subtext,bloat,jam) come back"status": "grading". PollGET /api/attempts/{id}(every 1.5 s, then every 3 s) until the status isdoneorlater.latermeans Claude couldn't grade it now: say so, don't invent a score, and offer the reference. Past the day's grading cap it turnslaterwithin a second or two, and the grade arrives the next day (the status then becomesdone). grading_later(true or false) is on every attempt: a grade, a revision's grade or the feedback on a reason is waiting for tomorrow because of the cap. Stop polling when you see it.- The
gradefor gist hasmain_idea,accuracy,concision,inference(each 0–4),covered(which key points it got, counting from 1),strength,work_onandhint. Subtext hasinference,precision,strength,work_onandhint. Bloat hasmeaning,strengthandwork_on, and itsinstanthas{"words", "par", "diff", "golf", "par_factor"}. Every grade also carriesevidence, which is Claude's own working, not for display. - Show
strengthandwork_on. Show the criteria as chips (every integer field exceptcovered). Showhintonly before the revision. - One revision:
POST /api/attempts/{id}/revise{"answer": {same shape}}, allowed once and only when the status isdone. Poll untilgrade2is set, orgrading_lateris true.score2is the revised score. The rating moves only on the first answer. - The reference: the
keyappears on the attempt once it has been revised. To skip the revision,POST /api/attempts/{id}/reveal{}(statusdoneorlater) returns the attempt with itskey. Keys by game: - gist
{"gist", "key_points", "inference_a"} - subtext
{"answer", "evidence_quote"} - bloat
{"original"} - weak
{"quote", "flaw", "explain", "assumption", "conclusion"} - word
{"target", "why", "sentence"} - tech
{"kind", "technique", "tell", "why"}(tellis the phrase that gives it away; highlight it in the text) - Disputing a grade:
POST /api/attempts/{id}/dispute{"note": "…"}sends it to the golden set's disputed list.
Just a Minute (voice)¶
POST /api/audio as multipart/form-data with item_id, day (or empty), slot (or -1), ms, and file. The file must be at least 1 KB and at most 15 MB. The server decodes it with PyAV, which reads what's actually in the file, so the container doesn't really matter. AAC in MP4/M4A (audio/mp4, what Safari records), Opus in WebM or Ogg, and WAV all work, with 16 kHz or more and mono being plenty. Send the right Content-Type on the part anyway. The answer is an attempt with "status": "grading". When it's done:
instant.metricshaswords,speaking_time,wpm,wpm_speaking,fillers,fillers_per_min,filler_words,crutch,crutch_words,long_pauses,pauses,longest_pause,silence_ratio,first_sentence_clean,repeats([{"word", "count"}]) andstart_delay.instant.hesitationandinstant.repetition(0–4) are scored by code.gradehasdeviation,structure(0–4),deviations(the phrases that wandered),strengthandwork_on.answer.textis the transcript.
Skills¶
GET /api/skills →
ratings({skill: {"rating", "n", "calibrating"}}) andskills(names and pillars).recent({skill: [scores 0–1, oldest first]}for sparklines).discern({"n"}alone, until there's enough; then"dprime", "bias", "hit_rate", "false_alarms", "brier").self_gap(the average distance between the self-rating and the grade, on 0–4, or null),bench_due,countsandweek.
The golden set¶
GET /api/golden→{"report": {"target", "total", "games": {game: {"criteria": {criterion: {"n", "exact", "within1", "lenient", "qwk"}}}}}, "next": [item ids to score], "rubrics": {game: {"criteria": {criterion: ["what 0 means", …, "what 4 means"]}}}, "disputed": [attempts]}- Answer the item yourself first (same fields as above), then
GET /api/golden/key/{item_id}→{"key"}to see the key, thenPOST /api/golden{"item_id", "answer", "mine": {criterion: 0–4}}. Claude grades the same answer blind in the background.
The monthly benchmark¶
GET /api/benchmark→{"forms": [{"id", "created", "sections"}], "runs": [{"id", "form", "started", "finished", "results"}]}POST /api/benchmark/start{}→{"run", "form", "answers"}. It continues an open sitting if there is one; otherwise it opens the next unused form (a404with adetailwhen none is left). The form comes without answers. Its sections, in order:argument{items: [{id, type, stimulus, stem, options[5]}]}discern{items: [{id, text, context}]}inference{passages: [{source, text, questions: [{id, stem, options[4]}]}]}essay{prompt, minutes}andtalk{prompt, seconds}vocab{items: [{id, word, sentence, options[4]}]}- Save each answer as it's given:
POST /api/benchmark/{run}/answer{"key", "value"}. - Multiple choice:
keyissection:item_id,valueis{"choice": index, "confidence": 20–100}. - Fair or trick:
{"kind": "technique" | "fair", "confidence": 50–100}. - The essay: key
essay, value{"text", "started": ms epoch, "done": bool}. Save the draft as you go;donecloses it. - Skipping the talk: key
talk, value{"skipped": true}. POST /api/benchmark/{run}/talk(multipart,file) uploads the two-minute talk.POST /api/benchmark/{run}/finish{}→{"run", "results"}.resultshas per-section{"right", "of"},discern(hits,of_tricks,false_alarms,of_fair,dprime,bias),brier,essay(words,grade) andtalk(grade). The essay and talk grades arrive later, so poll the next call until they're filled in, or show{"failed": true}as "couldn't grade".GET /api/benchmark/{run}(after finishing) →{"form" (with answers and explanations), "answers", "results", "started", "finished"}, for the review. A form is never sat twice.
Settings and the rest¶
GET /api/settings→{"weekly_goal_days", "rest_weekday" (0 = Monday … 6, or null), "grading_tone" ("soft" | "balanced" | "strict")};POST /api/settingswith any of those.
Devices¶
POST /api/devices{"name": "android-sm-s926u"}with the cookie →{"name", "token"}. The token is shown only this once, so keep it in the Keychain (Mac) or encrypted storage (Android). Posting the same name again replaces that device's token. The apps name themselvesmacandandroid-<model>, so two phones don't replace each other's token.GET /api/devices→[{"name", "created", "last_seen"}](seconds since 1970). Settings in the web app lists them.DELETE /api/devices/{name}disconnects one. A changed password signs out every browser but leaves device tokens alone.
The widget¶
GET /api/widget (bearer) →
{"day": "2026-10-19", "done": 1, "total": 3, "finished": false, "minutes_left": 3,
"next": {"game": "weak", "name": "Find the Weak Step"}, "week": {"active": 2, "goal": 4},
"url": "https://binah.tiresia.xyz/#today", "deeplink": "binah://go/today"}
Poll it gently: once when the widget is shown, at most every 15 minutes in the background, and once after the app comes to the front.
Train on this¶
POST /api/save with either {"url": "https://…", "title": "…"} or {"text": "a paragraph or more", "title": "…"} → {"ok": true, "id": 12, "open": "today", "message": "Saved for later. Binah doesn't turn saved passages into practice yet."}. Show the message. Text under 20 words without a URL is refused with a 400 and a plain message.
Deep links¶
The apps open binah://go/<room>, where a room is today, practice, skills, golden, bench or settings, for example binah://go/practice. On the web the same rooms are the page's hash routes (/#practice).