HTTP API¶
For tool authors. The contract that the notebook widget, the web app, and the publish command use. Base path /api/v1 unless noted.
Base path: /api/v1. JSON everywhere. All timestamps are ISO 8601 UTC from the server clock.
Principals and authentication¶
| Caller | Credential | How |
|---|---|---|
| Browser (Svelte app) | HttpOnly session cookie grader_session + header X-CSRF-Token on mutating requests |
GET /auth/login → SAML → cookie set; GET /auth/csrf returns the token for the current session |
| Notebook widget / CLI | Authorization: Bearer <jwt> |
obtained through the device handshake below |
Errors: {"error": {"code": "not_enrolled", "message": "..."}} with 401 (unauthenticated), 403 (forbidden), 404, 409 (conflict), 422 (validation).
Session endpoints (browser)¶
GET /auth/login?next=/o/abc→ 302 to Dartmouth SAML (or, in dev mode, to/auth/dev-login).POST /auth/saml/acs→ SAML assertion consumer; sets cookie; 302 tonext.GET /auth/saml/metadata→ SP metadata XML.POST /auth/logout→ clears cookie; 302 tohttps://logout.dartmouth.eduin prod.GET /auth/me→{"netid": "f00abc1", "display_name": "...", "platform_admin": false, "enrollments": [{"offering_id": "...", "course_slug": "neuroimaging", "term": "2026-fall", "title": "...", "role": "student|ta|instructor"}]}GET /auth/csrf→{"csrf_token": "..."}-
Dev only (
GRADER_AUTH_MODE=dev):GET /auth/dev-login?netid=f00abc1&next=/sets a session for that NetID (creating the user if needed). -
PATCH /auth/me{"preferred_name": "..."}→ sets the name the caller is called by. Edits only the caller's own row — there is no netid parameter. Writespreferred_name, neverdisplay_name, which belongs to the identity provider and is overwritten at each login; blank or null clears it.
Sign-in links (no session)¶
GET /auth/exchange?code=…&next=/→ consumes a one-time login code minted bygrader login-linkor mailed by/auth/email-login, sets a session cookie, redirects. Works in every auth mode, includingdisabled. Codes are single use./auth/exchangeaccepts only login-link rows, andPOST /auth/device/tokenrefuses rows minted bygrader login-link.POST /auth/email-login{"email": "netid@example.edu", "next": "/"}→ 202 with a fixed body, always. 404 whenGRADER_EMAIL_LOGIN_ENABLEDis false. The response is identical whether the address matched an enrolled account, was rate limited, or could not be delivered — see Email sign-in links.
Device handshake (notebook)¶
POST /auth/devicebody{"client": "dartbrains-tools/0.2.0"}→{"device_code": "...", "user_code": "ABCD-1234", "verification_url": "https://.../auth/device/verify?code=ABCD-1234", "expires_in": 600, "interval": 3}GET /auth/device/verify?code=ABCD-1234→ browser page; requires session (redirects through/auth/loginif none); on success binds the code to the NetID and renders "You can close this tab."POST /auth/device/tokenbody{"device_code": "..."}→{"status": "pending"}(200) while waiting;{"status": "approved", "access_token": "<jwt>", "token_type": "bearer", "expires_in": 28800, "netid": "f00abc1"}once approved;{"status": "expired"}after expiry.- JWT claims:
sub= NetID,scope="notebook",iat,exp(8 h),jti. Signed Ed25519 (EdDSA).
Health¶
GET /api/health(no prefix, no auth) →{"ok": true, "env": "prod", "auth_mode": "saml", "submissions_enabled": true, "email_login_enabled": false}. The notebook widget checkssubmissions_enabledbefore starting a handshake, so a student is told submission is unavailable rather than watching one fail.
Offerings¶
GET /offerings→ offerings the caller is enrolled in (same shape as/auth/me.enrollments, plussettings).GET /offerings/{offering_id}→ offering detail incl.sectionsand the caller'srole.GET /offerings/{offering_id}/assignments→[{"id", "slug", "title", "latest_version", "settings": {"due_at", "attempts_allowed", "grade_policy": "latest|highest|first|selected", "environments": ["molab","wasm","discovery"], "log_checks": true}, "questions": [{"id", "qid", "title", "max_points", "grading_mode": "auto|manual|hybrid", "order"}]}]
Assignments (instructor)¶
POST /offerings/{offering_id}/assignments{slug, title, settings}→ assignment.PATCH /offerings/{offering_id}/assignments/{assignment_id}{title?, settings?}(audited).POST /offerings/{offering_id}/assignments/{assignment_id}/versionsmultipart:instructor_notebook(file),student_notebook(file),questions(JSON string:[{"qid","title","max_points","grading_mode","check_keys":[...]}]),cell_hashes(JSON string) →{"version": 3, "student_url": "/api/v1/assignment-versions/{id}/student.py"}GET /assignment-versions/{version_id}/student.py→ student notebook source (public if the offering allows, else enrolled).
Public alias routes (no prefix, no auth; gated by offering.settings.public_student_notebooks, default on)¶
GET /a/{course}/{term}/assignments.json→{"course","term","offering_id","title","assignments":[{"slug","title","assignment_id","version","version_id","published_at","due_at","points","environments","questions":[...],"student_url","molab_url"}]}. Whatmarimo-book sync-assignmentsreads.GET /a/{course}/{term}/{slug}/student.py[?v=N]→ the distributed student notebook, exact bytes the server finalized at publish (headersX-Grader-Version,X-Grader-Version-Id).GET /a/{course}/{term}/{slug}/molab[?v=N]→ 302 tohttps://molab.marimo.io/new/#code/<lzstring>.
Publishing (POST .../versions) is finalized server-side: the server injects grader-server, grader-course, grader-term, grader-offering-id, grader-assignment, grader-assignment-id, grader-assignment-version, grader-version into the student notebook's PEP 723 block and stores those bytes. It refuses (422 solution_leak) if solution or hidden-test markers remain, and returns 200 {"unchanged": true} when the instructor notebook and question list are identical to the latest version.
Submissions¶
POST /submissions(Bearer) body (optionalclient_submission_id: an id per click; a retry with the same id returns the same attempt with"duplicate": true):→ 201{"assignment_version_id": "uuid", "question_id": "glm-q03", "notebook": "<source>", "check_results": [], "outputs": {}, "client": {"package": "dartbrains-tools", "version": "0.2.0", "env": "molab"}}{"id", "attempt_no", "submitted_at", "status": "received", "version": 2, "latest_version": 3, "stale": true, "duplicate": false}. Questions resolve against the submitted version's snapshot, so an older copy keeps working after a republish;staletells the widget to suggest the latest copy. 403not_enrolledif the NetID has no active student enrollment in the version's offering; 409attempts_exhausted.GET /submissions/{id}(owner, or TA/instructor of the offering) → submission incl.status,score(if any),feedback,render_url.GET /offerings/{offering_id}/me/submissions?assignment_id=(student) → own attempts.POST /check-events(Bearer){"assignment_version_id", "question_id", "check_key", "passed": true}→ 202. Best effort; ignored iflog_checksis off.
Grading (instructor/TA)¶
GET /offerings/{offering_id}/triage→{"awaiting_manual": [{"question_id","qid","count","oldest_submitted_at"}], "inactive_students": [...], "failing_checks": [{"qid","check_key","fail_rate","n"}], "grader_failures": [...]}GET /offerings/{offering_id}/grid?assignment_id=→{"students": [{"netid","display_name","section","last_activity","cells": {"<question_id>": {"state": "none|checking|submitted|graded", "points": 4, "max": 5}}}]}GET /offerings/{offering_id}/queue?question_id=→{"question": {"id","qid","title","max_points","auto_points_max","grading_mode","rubric": {"id","name","items":[{"key","label","points","description"}]} | null}, "items": [submission...]}. One item per student (their latest attempt),needs_gradingfirst, oldest first. Each submission carriesoutputs(the structured answers the widget sent, e.g. text-area values) andrender_url(worker-rendered HTML, served with a strict CSP and meant for a sandboxed iframe).POST /submissions/{id}/score{"manual_points": 3.5, "rubric_scores": {"r1": 2, "r2": 1.5}, "feedback": "...", "final": true}→ score row (audited). With a rubric, eachrubric_scoreskey must be one of its items and within0..points(422unknown_rubric_item/out_of_range);manual_pointsdefaults to their sum.GET /offerings/{offering_id}/questions/{question_id}/rubric(instructor) →{"question_id", "items": [{"key","label","points","group"}], "total"}.PUT /offerings/{offering_id}/questions/{question_id}/rubric(instructor){"items": [{"key": "3.1", "label": "...", "points": 1, "group": "..."}]}→ same shape (audited). An empty list removes it. Keys are unique; points > 0. On thenotebookquestion the total becomesnotebook_points(and a settings change to another value is refused withpoints_set_by_rubric); elsewhere the total must fit in the manual points (rubric_over).GET /offerings/{offering_id}/students/{netid}→ attempt history across assignments.
Roster (instructor)¶
POST /offerings/{offering_id}/roster/previewmultipartfile+source=canvas_csv|banner→{"adds": [...], "drops": [...], "moves": [...], "unmatched": [...]}with rows{netid, display_name, section}.POST /offerings/{offering_id}/roster/applybody = the preview payload (possibly edited) → applied counts (audited).-
GET /offerings/{offering_id}/roster→ enrollments. -
POST /offerings/{offering_id}/roster/staff(instructor){netid, role: ta|instructor, ta_sections: [...], display_name?}→ adds or updates a teaching-staff enrollment (audited). Students come from roster import, never from this route.
Audit¶
GET /offerings/{offering_id}/audit?limit=(instructor) → every audited action in the offering, newest first:[{id, at, actor (netid), entity, entity_id, action, before, after, reason}]. Includes grade changes.GET /admin/audit?limit=(platform admin) → platform-level entries only (course, offering, user, enrollment, assignment, assignment_version, roster, export) with anofferingalias; grade rows are excluded because admins do not see student work.
Operator commands (server shell)¶
grader token <netid> [--offering course/term --role instructor --admin]→ notebook/CLI token, works in any auth mode.grader login-link <netid>→ one-time browser sign-in URL (30 min, single use) exchanged atGET /auth/exchange?code=.
Export¶
GET /offerings/{offering_id}/export/canvas?assignment_ids=a,bwith an uploaded Canvas gradebook on file → CSV.
Admin (platform admin)¶
GET/POST /admin/courses,POST /admin/courses/{id}/offerings,POST /admin/offerings/{id}/instructors{netid}.
Events¶
GET /offerings/{offering_id}/events→ Server-Sent Events:submission.received,submission.graded,score.updated, each with{"submission_id","question_id","netid"}.
Storage (notebook token; 404 storage_disabled when the broker is off)¶
GET /offerings/{offering_id}/storage/me→{"bucket", "mounts": [{"logical": "/private", "prefix": "users/<off>/<uid>/", "mode": "rw", "cred": "rw"}, …], "public": [{"logical": "/data/localizer", "backend": "hf", "repo": "dartbrains/localizer"}]}. What the caller may reach, without minting anything.POST /offerings/{offering_id}/storage/session{"client": "dartbrains-tools/molab"}→ the samemounts/publicplusendpoint,bucket,region,expires_atandcredentials: {"ro": {access_key_id, secret_access_key, session_token}, "rw": {…}}— two Cloudflare R2 temporary credentials scoped to exactly the read-only and read-write prefixes inmounts. Recorded instorage_grants. 404 for non-members.POST /offerings/{offering_id}/storage/presign{"path": "/private/results/model.pkl", "method": "GET|HEAD|PUT", "expires": 900}→{"url", "key", "mount", "expires_in"}. One presigned URL under the parent key, for runtimes that cannot sign requests. 403 outside the caller's mounts or forPUTon a read-only one; 400 for..or empty segments.