API reference
<!-- generated by python -m norboten_api.reference; do not edit by hand -->
The API is served at https://api.<domain> (http://localhost:8000 with make stack-up).
Requests and responses are JSON unless noted. The interactive form of the same document is at
/docs on any running API.
Signing requests. Endpoints marked token need Authorization: Bearer <token> — a web or
cli token from signing in with GitHub (/auth/github/poll in a terminal,
/auth/github/exchange on the site), which is the only way in: no password anywhere. On a server
with NORBOTEN_REQUIRE_AUTH=false (tests, make stack-up) X-Debug-User: <id> stands in for
one. Endpoints marked public need nothing.
Errors are {"detail": "…"} with a status: 401 no or bad token, 403 not yours (or denied on
GitHub), 404 unknown, 409 conflict (a taken nick), 410 gone (a sign-in expired or already used),
422 invalid body, 428 still waiting for GitHub, 429 rate-limited, 502/503 a model provider, GitHub
or Discord failed, or this deployment has none.
Health and metrics
GET /healthz
public
The process is up. Docker's health check and Caddy use this.
GET /readyz
public
The process can do its job: the database and Redis answer. A deploy waits on this and rolls back when it never turns ready.
Accounts and signing in
GET /auth/config
public
How to sign in on this server. The TUI and the site ask, so a self-hosted server needs no new release; discord says whether the account page offers linking Discord.
POST /auth/github/device
public
Start signing a terminal in. GitHub's device code stays here; the terminal gets the code a person types, the page to type it on, and a handle to poll with.
Body — DeviceIn:
label· string · optional · min 1 · max 120remember· boolean · optional
POST /auth/github/poll
public
Has the person approved on GitHub yet? One call to GitHub per poll, so a terminal that polls faster than interval is told to slow down by GitHub itself.
Body — PollIn:
poll_id· string · min 8 · max 128remember· boolean · optional
GET /auth/github/go
public
Send the browser to GitHub. The page's state comes back to it in the fragment; GitHub is given this server's own state, which names the flow.
| Parameter | In | Type | Required |
|---|---|---|---|
state |
query | string | yes |
remember |
query | boolean | no |
return |
query | string | no |
GET /auth/github/callback
public
GitHub sends the browser here. Whatever happens, the browser goes back to the page it came from, with a one-time code or an error in the fragment.
| Parameter | In | Type | Required |
|---|---|---|---|
state |
query | string | no |
code |
query | string | no |
error |
query | string | no |
POST /auth/github/exchange
public
The one-time code from the callback, for a web token. It works once, for sixty seconds.
Body — ExchangeIn:
once· string · min 8 · max 128
POST /auth/logout
token
Revoke the token this request was made with.
GET /auth/tokens
token
Where this account is signed in: the site and each terminal, with when each expires.
GET /auth/me
token
Who this account is on GitHub, and what it has linked and asked for — for its own account page. 404 for a debug identity, which has no credentials.
GET /auth/preferences
token
What this account has asked to be sent. Everything is off until the learner turns it on.
PUT /auth/preferences
token
Turn the weekly digest on or off. It is a Discord direct message, so it can only be turned on once Discord is linked (409 before that).
Body — PreferencesIn:
digest· boolean
POST /auth/discord/start
token
Where to send the browser to link Discord to this account.
Body — norboten_api__routers__discord__StartIn:
join· boolean · optional
GET /auth/discord/callback
public
Discord sends the browser here after the consent screen.
| Parameter | In | Type | Required |
|---|---|---|---|
state |
query | string | no |
code |
query | string | no |
error |
query | string | no |
DELETE /auth/discord
token
Forget the Discord user, and stop the digest that went to it.
The lab catalogue
GET /labs
public
Every lab the server knows: id, short id, title, track, difficulty, estimated minutes, base images and topics.
GET /labs/{lab_id}
public
One lab's manifest and briefing, by id or short id. Hints come with the lab package; the reference solution never leaves the server.
| Parameter | In | Type | Required |
|---|---|---|---|
lab_id |
path | string | yes |
Theory
GET /quiz/topics
public
The theory banks: every topic bank and every lab's own, with how many questions each has.
Rated labs and rated theory
GET /rated/labs
token
The rated labs this server grades: id, title, track, topics, difficulty, clock and images. Nothing about what is broken. Empty on a server with no rated content.
GET /rated/labs/{lab_id}
token
One rated lab's manifest and briefing — what a learner reads before starting. The faults, the collectors, the judges, the hints and the solution are not in it.
| Parameter | In | Type | Required |
|---|---|---|---|
lab_id |
path | string | yes |
POST /rated/attempts
token
Start a rated attempt: an id, a nonce, the key that signs this attempt's records, the clock, and the break bundle. Any attempt still open for this account is closed as a loss first — seeing the faults and walking away is not free.
Body — norboten_api__routers__rated__StartIn:
lab_id· stringimage· string · optional
GET /rated/attempts/{attempt_id}
token
Where an attempt stands: open, or its outcome with each check's verdict and the rating change.
| Parameter | In | Type | Required |
|---|---|---|---|
attempt_id |
path | string | yes |
GET /rated/attempts/{attempt_id}/collect
token
The collect bundle for an open attempt: which facts to gather, never what they must be.
| Parameter | In | Type | Required |
|---|---|---|---|
attempt_id |
path | string | yes |
POST /rated/attempts/{attempt_id}/abandon
token
Give an attempt up. It is rated as a loss, like running out of time.
| Parameter | In | Type | Required |
|---|---|---|---|
attempt_id |
path | string | yes |
POST /rated/attempts/{attempt_id}/facts
token
A signed fact record from the guest, before or after the reboot. The last one the lab needs closes the attempt: judged here, rated, and answered pass or fail per check.
| Parameter | In | Type | Required |
|---|---|---|---|
attempt_id |
path | string | yes |
Body — FactsIn:
record· objectsignature· string · min 64 · max 64
GET /rated/quiz/banks
token
The rated question banks: topic, title, description, topics and how many questions a run asks. Empty on a server with no rated content.
POST /rated/quiz/sessions
token
Start a rated run and serve its first question. A run still open for this account is closed first, on what it had answered — a question seen and not answered counts as wrong.
Body — StartQuiz:
topic· string
POST /rated/quiz/sessions/{session_id}/answers
token
Answer the question on screen. Graded here, against the time it was served: the verdict, the right answer, the explanation and the references. The last answer closes and rates the run. An empty selection is how a client reports that its clock ran out.
| Parameter | In | Type | Required |
|---|---|---|---|
session_id |
path | string | yes |
Body — AnswerIn:
question_id· stringselected· list of string · optional
POST /rated/quiz/sessions/{session_id}/next
token
Serve the next question; its clock starts now. Asked for when the learner has read the last explanation, so reading it costs nothing.
| Parameter | In | Type | Required |
|---|---|---|---|
session_id |
path | string | yes |
Anonymous progress
POST /progress
public
Store an anonymous, opt-in progress event under a random client id. No account involved.
Body — Attempt:
client_id· string · min 8 · max 64lab_id· stringscore_percent· integer · ≥ 0.0 · ≤ 100.0passed· booleanminutes· integer · optionalbase_image· string · optional
GET /progress/{client_id}
public
Read back the progress events recorded under a client id.
| Parameter | In | Type | Required |
|---|---|---|---|
client_id |
path | string | yes |
Profiles, attempts and the board
POST /me
token
Make the profile: a nick, chosen once, and a country. After that the country is the one thing that changes — a different nick is 409 "the nick is chosen once".
Body — SignUp:
nick· string · optionalcountry· string
GET /me
token
The signed-in account's profile: nick, country, overall and per-topic ratings, a year of contributions and recent attempts. 404 until a nick is chosen.
DELETE /me
token
Delete this account and everything on it: the profile and its nick, every attempt and rating, open rated attempts and theory runs, the Play recordings it made, the GitHub and Discord links, and every token — so the call ends the session that made it. Nothing is kept and nothing can be restored; the nick becomes free for someone else. Backups age out on their own schedule (the privacy policy says how long). The account need not have a nick yet.
GET /geo/country
public
The country this request probably comes from, to pre-fill a form — {"country": "PL"}, or "" when the address is unknown or private. Looked up offline (geo.py, DB-IP Lite); nothing is sent anywhere and nothing is stored.
GET /profile/{nick}
public
Anyone's public profile by nick, in the same shape as /me, with the GitHub account it signs in with.
| Parameter | In | Type | Required |
|---|---|---|---|
nick |
path | string | yes |
POST /attempts
token
Record an attempt on the profile. It never moves a rating: only a rated lab or rated theory, graded on this server (/rated), does — an unrated lab's checks and answer are public, so a reported pass proves nothing. rated in the body is accepted from older clients and ignored. The server still decides the difficulty, topics and clock, which the profile shows.
Body — AttemptIn:
lab_id· stringkind· string · optionalstarted_at· numberduration_seconds· integer · ≥ 0.0score_percent· integer · ≥ 0.0 · ≤ 100.0passed· booleanrated· boolean · optionaldifficulty· integer · optionaltopics· list of string · optional
GET /leaderboard
public
Sorted on the conservative rating, so a lucky first win does not top the board. Cached for thirty seconds: every profile page and TUI screen asks, and it changes one attempt at a time.
| Parameter | In | Type | Required |
|---|---|---|---|
topic |
query | string | no |
limit |
query | integer | no |
Live and recorded sessions
POST /play/sessions
token
Open a session. Anyone who can see the site can watch it from here on, and the Telegram channel is told (announce.py).
Body — norboten_api__routers__play__StartIn:
lab_id· stringwidth· integer · ≥ 20.0 · ≤ 400.0height· integer · ≥ 5.0 · ≤ 200.0
POST /play/sessions/{session_id}/frames
token
Append one batch of frames, commands and diffs to your own live session; it is stored and published to viewers at once. 409 once the session has ended.
| Parameter | In | Type | Required |
|---|---|---|---|
session_id |
path | string | yes |
Body — AppendIn:
seq· integer · ≥ 0.0at· numberevents· list of list of · optionalcommands· list of object · optionalchanges· list of object · optional
POST /play/sessions/{session_id}/end
token
End your own session, optionally with whether the lab was passed; viewers receive end.
| Parameter | In | Type | Required |
|---|---|---|---|
session_id |
path | string | yes |
Body — EndIn:
passed· boolean · optional
GET /play/live
public
Who is working right now, and what finished recently.
| Parameter | In | Type | Required |
|---|---|---|---|
limit |
query | integer | no |
GET /play/sessions/{session_id}
public
The whole recording: an asciicast header, its events, the commands and the diffs.
| Parameter | In | Type | Required |
|---|---|---|---|
session_id |
path | string | yes |
GET /play/sessions/{session_id}/stream
public
Server-sent events: what is stored, then every batch as it is published, then end.
| Parameter | In | Type | Required |
|---|---|---|---|
session_id |
path | string | yes |
after |
query | integer | no |
GET /play/leaderboard-context/{nick}
public
What a viewer sees next to a live terminal: whose it is, and how they are rated.
| Parameter | In | Type | Required |
|---|---|---|---|
nick |
path | string | yes |
The consultant
GET /chat/faq
public
The chips under the input: what people actually arrive wanting to know.
POST /chat
public
Ask the consultant once and wait for the whole answer: the reply, the passages it used, and whether the guard blocked it. The streaming form is /chat/stream.
Body — Ask:
question· string · min 3 · max 500lab_id· string · optional
GET /chat/stream
public
sources first — so a reader can start checking before the answer lands — then tokens.
| Parameter | In | Type | Required |
|---|---|---|---|
question |
query | string | yes |
lab_id |
query | string | no |
Donations
POST /donations/checkout
public
Start a donation. Returns the Stripe page to send the donor to.
Body — Donation:
amount· integer · ≥ 2.0 · ≤ 5000.0interval· string · optional
Telemetry
POST /telemetry
public
Record an opt-in stuck point: which check a learner was on and at which hint level. 404 when the server has telemetry turned off.
Body — StuckPoint:
lab_id· stringbase_image· string · optionalcheck_id· stringfailed_attempts· integer · ≥ 1.0 · ≤ 1000.0hint_level· integer · optional · ≥ 0.0 · ≤ 4.0minutes· integer · optional · ≥ 0.0 · ≤ 600.0
GET /telemetry/stuck-points
public
What the weekly digest reads: the checks people fail most.
OAuth for MCP clients
GET /.well-known/oauth-authorization-server
public
Authorization server metadata (RFC 8414): where to authorize and exchange codes, PKCE S256 only, public clients, and client ID metadata documents instead of registration.
GET /.well-known/oauth-protected-resource
public
Protected resource metadata for the MCP server (RFC 9728): its identifier, and that this API issues the tokens it accepts.
GET /.well-known/oauth-protected-resource/mcp
public
Protected resource metadata for the MCP server (RFC 9728): its identifier, and that this API issues the tokens it accepts.
GET /.well-known/oauth-protected-resource/mcp/account
public
The same, for the door that asks for a token on every request (/mcp/account).
GET /oauth/authorize
public
Start an authorization: check the client, its redirect URI and the PKCE challenge, then send the browser to the site, where the account approves it. Errors before the redirect URI is trusted are answered here; after, they go back to the client.
GET /oauth/requests/{request_id}
public
What the site shows before the account approves: who is asking, where the browser goes back to, and what the token will allow.
| Parameter | In | Type | Required |
|---|---|---|---|
request_id |
path | string | yes |
POST /oauth/approve
token
The signed-in account's answer. Returns where to send the browser: back to the client, with a one-time code (five minutes) or access_denied.
Body — Decision:
request· string · min 16 · max 64allow· boolean
POST /oauth/token
public
Exchange a code and its PKCE verifier, or a refresh token, for an mcp access token (an hour) and a new refresh token (thirty days; the old one stops working). Form-encoded.