Skip to content

API Reference

Ramiro Cantu edited this page May 30, 2026 · 1 revision

API Reference

The complete HTTP surface of gradient-server. Every route is a JSON API under /api/v1/* (plus the root /healthz liveness probe and the root /media/* asset server). There is no in-repo UI — this backend is consumed by the Chrome capture extension, the desktop SPA, the MCP tutor host, and the scheduler (SPEC §C, V-D1).

This page is generated from live source under app/api/v1/. Where it disagrees with docs/openapi.json or docs/BACKEND_CORE.md, the code wins. For the function-level index (handler signatures + service seams), see Function Index; for the underlying tables, see Data Models.

Note: docs/openapi.json is the machine-readable contract. Earlier notes claimed it was missing an admin/.../recategorize route — there is no such route in the code, so nothing is missing on that front. Regenerate the contract after any router change:

uv run python -c "import json; from app.main import app; \
  open('docs/openapi.json','w').write(json.dumps(app.openapi(), indent=2)+'\n')"

Interactive docs (server running): http://localhost:8000/docs.


Authentication legend

Symbol Meaning
token Requires the X-Coach-Token header. Checked constant-time against settings.COACH_TOKEN by app/api/deps.py:verify_coach_token; a miss returns 401 {"detail": "invalid coach token"}. Applied per-route or as a whole-router dependency.
open No auth dependency at all. Reachable by anyone who can hit the port.
localhost-only (effective) No verify_coach_token and no in-code localhost guard — the route is effectively open at the application layer. The only gate is that the service is bound to localhost in deployment. Flagged inline where it applies.

Note: Two admin mutation routes — POST /api/v1/admin/questions/{question_id}/tags and DELETE /api/v1/admin/tags/{tag_id} — carry no verify_coach_token dependency and no in-code localhost guard. admin.py's module docstring says "No auth — localhost-only service," but that is a deployment assumption, not an enforced guard. Treat them as effectively open at the app layer. The read/job admin routes (/jobs, /jobs/{job_name}/trigger, /status) are token-gated per-route.

All /api/v1/* routes are mounted via an intermediate APIRouter in app/main.py. CORS is restricted via allow_origin_regex to chrome-extension://* and http(s)://localhost. A RequestValidationError handler logs capture-path 422s.


Master endpoint table

Grouped by feature. "Body" names the Pydantic model (or notes form/none); "Response" is the declared response_model or the actual returned shape when none is declared.

Capture & attempts → Feature: Capture

Method Path Auth Body Response Description
POST /api/v1/captures token CapturePayload IngestResponse Ingest a question capture from the extension into question/attempt/media tables.
GET /api/v1/attempts/{attempt_id}/notes token list[NoteOut] List notes on an attempt.
POST /api/v1/attempts/{attempt_id}/notes token NoteIn NoteOut (201) Create an attempt note, optionally flagged for review.
DELETE /api/v1/attempts/notes/{note_id} token — (204) Delete an attempt note by id.

Outline / domain model → Feature: Outline

Method Path Auth Body Response Description
GET /api/v1/courses token list[dict] List all courses (slug-ordered) for the course picker.
POST /api/v1/courses token CreateCourseBody dict (201) Create a course from slug/name/description.
POST /api/v1/courses/{course_id}/outline:import token raw outline dict dict Validate-then-materialize an outline schema atomically (V-O2/V-O3).
GET /api/v1/courses/{course_id}/outline token dict Materialized outline node tree for a course.
GET /api/v1/outline/nodes/{node_id}/mastery token dict Per-node/subtree mastery rollup (T44, V-O5).
GET /api/v1/outline/courses/{course_id}/mastery token dict Course-level mastery rollup (T44).

Knowledge base → Feature: Knowledge Base

Method Path Auth Body Response Description
POST /api/v1/pdf/ingest token multipart form (course_id, file) PdfIngestResponse Upload a lecture-notes PDF and run it synchronously through vision-ingest.
GET /api/v1/concept-edges token list[dict] Concept-edge feed for the connections view (T45, V-E2).
GET /api/v1/atomic-facts token list[dict] Grounded atomic facts filtered by node or PDF source (T46, V-KB1).
GET /api/v1/notion/pages token list[dict] Notion pointer index (T47, V-N1/V-N2).
GET /api/v1/pdf-sources token list[dict] Ingested-PDF inbox with per-source fact counts (T48).
GET /media/{file_path:path} open FileResponse Serve PDF/image assets from MEDIA_ROOT (path-traversal guarded).

Tutor / MCP → Feature: Tutor MCP

Method Path Auth Body Response Description
GET /api/v1/tutor/questions/by-qid/{qid} token dict Fetch a question by UWorld qid.
GET /api/v1/tutor/questions/by-attempt-id/{attempt_id} token dict Fetch the question tied to an attempt.
GET /api/v1/tutor/captures/recent token list[dict] N most recent captures.
GET /api/v1/tutor/sessions/latest token dict Latest session test_id (or null).
GET /api/v1/tutor/sessions/recent token list[dict] N most recent sessions.
GET /api/v1/tutor/sessions/{test_id}/summary token dict Per-topic accuracy + attempt breakdown for one session.
GET /api/v1/tutor/attempts/flagged token list[dict] Flagged attempts for the review queue.
GET /api/v1/tutor/outline/nodes/search token list[dict] Search outline nodes by name, optionally scoped to a course.
GET /api/v1/tutor/outline token dict Full outline tree for a course (by slug).
GET /api/v1/tutor/outline/nodes/{node_id}/subtree token dict A node + its descendant subtree.
GET /api/v1/tutor/healthz token dict Token-gated DB-touching health check for the MCP seam.
POST /api/v1/pkm/discriminators token DiscriminatorIn DiscriminatorOut Persist-only (V-M1) discriminator factor write, append-only deduped (V-M3).

Anki → Feature: Anki

Method Path Auth Body Response Description
POST /api/v1/anki/sync token dict (error envelope) One-off sync of the configured deck against AnkiConnect.
GET /api/v1/anki/review-queue token list[AnkiReviewQueueCardOut] Due/overdue cards, soonest first, with retention + retrievability (T43).
GET /api/v1/anki/cards/by-qid/{qid} token list[AnkiCardOut] Cards carrying the uworld::qid::{qid} tag.
POST /api/v1/anki/assignments token AnkiAssignmentCreateIn AnkiAssignmentOut (201) Create an assignment + snapshot resolved card_ids (V52).
GET /api/v1/anki/assignments token list[AnkiAssignmentOut] List assignments by unlock time; status/window filters.
PATCH /api/v1/anki/assignments/{assignment_id} token AnkiAssignmentPatchIn AnkiAssignmentOut Human-driven transition: mark skipped / completed (V51).
POST /api/v1/anki/reviews token AnkiReviewCreateIn AnkiReviewOut (201) Create a standalone pending review for AnKing card_ids on a date (V53).
GET /api/v1/anki/reviews token list[AnkiReviewOut] List reviews by date; status/window filters.
GET /api/v1/anki/load-config token AnkiLoadConfigOut Read singleton load-config; auto-seeds V59 default on miss.
POST /api/v1/anki/load-config token AnkiLoadConfigIn AnkiLoadConfigOut Upsert the singleton load-config (V59).
GET /api/v1/anki/load-adherence token AnkiLoadAdherenceOut Deterministic V54 adherence payload + T43 reviewed-count series.

Platform / admin → Feature: Platform Admin

Method Path Auth Body Response Description
GET /healthz open {"status":"ok"} Root liveness probe; no DB, no auth.
POST /api/v1/admin/questions/{question_id}/tags open (effective)¹ ManualTagBody dict (201) Create a manual override tag (exactly one of topic_id/content_category_id/skill).
DELETE /api/v1/admin/tags/{tag_id} open (effective)¹ — (204) Hard-delete a manual tag / soft-override an llm tag; 403 otherwise.
GET /api/v1/admin/jobs token list[dict] Snapshot of scheduler job state.
POST /api/v1/admin/jobs/{job_name}/trigger token dict (202) Nudge a named scheduler job to run now (allowlisted).
GET /api/v1/admin/status token dict Probe Anki/OpenAI/Notion + fold last TaskRun per job (T39).

¹ No verify_coach_token, no localhost guard — effectively open at the app layer. See the auth-legend note above.


Endpoint detail

Detail subsections for the non-trivial routes (those with body params, filters, or notable error contracts). Trivial fixed-shape reads (/healthz, the simple tutor reads) are fully described by the master table above. Each handler links to its entry in Function Index.

POST /api/v1/captures

Ingest a UWorld (or other-source) question capture from the Chrome extension.

  • Auth: token. Depends: get_session, verify_coach_token.
  • Handler: post_captureapp/api/v1/captures.py:17.
  • Body model: CapturePayload (app/schemas/captures.py, extra="forbid").
Field Type Notes
source str Source discriminator (§A); defaults to "uworld".
course_slug str | None Resolved to course_id via courses.slug at ingest (V-CAP2). NULL falls back to the single-course tagging rule.
qid str Required.
uworld_test_id str | None
captured_at datetime Required.
html str Raw capture HTML.
parsed ParsedCapture Stem/choices/explanation/correctness/timing (nested model).
media list[MediaCapture] Default [].
parse_warnings list[ParseWarning] Default [].
extension_version str Required.

Success — 200 IngestResponse:

{
  "capture_id": 412,
  "question_id": 95,
  "attempt_id": 738,
  "passage_id": null,
  "media_ids": [1201, 1202]
}

Errors:

Status Condition
422 UnknownSourceError / UnknownCourseError from ingest_capture, re-raised as HTTPException with str(exc) detail.
422 RequestValidationError on malformed CapturePayload (extra="forbid"); logged by the capture-path handler in main.py.
401 Invalid/missing X-Coach-Token.

POST /api/v1/attempts/{attempt_id}/notes

  • Auth: token. Handler: add_noteapp/api/v1/attempts.py:44.
  • Body model: NoteIn (local to attempts.py).
Field Type Notes
note_text str Required, non-blank (field_validator).
flag_for_review bool Default False.
source Literal["user","mcp"] Default "user".

Success — 201 NoteOut:

{
  "id": 88,
  "attempt_id": 738,
  "note_text": "Mixed up Le Chatelier direction.",
  "flag_for_review": true,
  "source": "user",
  "created_at": "2026-05-30T14:02:11Z"
}

Errors: 404 AttemptNotFoundError ("attempt not found"); 422 blank note_text or invalid source literal.

The GET …/notes (list, 200 list[NoteOut]) and DELETE …/notes/{note_id} (204; 404 "note not found") routes share this model family; see get_notes (app/api/v1/attempts.py:35) and remove_note (:63).

POST /api/v1/pdf/ingest

Upload a lecture-notes PDF for an explicit course and run it synchronously through the vision-ingest pipeline (atomic facts with node_id NULL).

  • Auth: token. Handler: post_pdf_ingestapp/api/v1/pdf.py:33.
  • Body: multipart/form-datano Pydantic model.
Part Type Notes
course_id Form(int) The course this PDF belongs to.
file File(UploadFile) The PDF.

Success — 200 PdfIngestResponse:

{
  "pdf_source_id": 17,
  "pages": 12,
  "new_facts": 34,
  "dup_facts": 5,
  "reused_pdf": false,
  "extractor_version": "vision-ingest-v3",
  "input_tokens": 18422,
  "output_tokens": 2210,
  "cached_tokens": 0
}

Errors: 404 "course id={course_id} not found"; 422 missing/invalid form course_id or file.

POST /api/v1/courses & the outline routes

The outline router is whole-router token-gated (dependencies=[verify_coach_token]) and tagged outline. None of these declare a response_model; the shapes below are the actual returned dicts.

POST /api/v1/coursescreate_course (app/api/v1/outline.py:65), 201. Body CreateCourseBody (local): slug and name required (min_length=1), description optional. Returns {id, slug, name, description}.

{ "id": 3, "slug": "biochem-101", "name": "Biochemistry I", "description": null }

Errors: 409 CONFLICT "course slug {slug} already exists (id=...)"; 422 missing slug/name.

POST /api/v1/courses/{course_id}/outline:importimport_outline (:87), 200. Body is a raw dict[str, Any] in the §I shape {course: {slug, name, description?}, nodes: [{path, kind, name, position?}]}, validated by validate_outline_schema (not a Pydantic body model). Returns the course payload plus a nodes_imported count. Atomic validate-then-materialize: it wipes the existing outline for the course and inserts the new tree (V-O2/V-O3).

Errors:

Status Condition
404 "course id={course_id} not found".
409 CONFLICT Upload course.slug mismatch vs the course_id's slug (checked twice — pre-validate on body_course.slug, post-validate on validated.course.slug).
422 UNPROCESSABLE_ENTITY { "errors": [...] } from OutlineSchemaValidationError (whole-upload rejection, V-O2).
500 INTERNAL_SERVER_ERROR OutlineImportError during materialize.

GET /api/v1/courses/{course_id}/outlineread_outline (:164), 200. Returns {course, nodes: [{id, parent_id, kind, name, depth, position}]} ordered by depth/position/id. 404 if the course is missing.

GET /api/v1/outline/nodes/{node_id}/masterynode_mastery (:188) and GET /api/v1/outline/courses/{course_id}/masterycourse_mastery (:199) return compute_node_mastery / compute_course_mastery dicts (T44, V-O5 set rollup). 404 NodeNotFoundError / CourseNotFoundError respectively.

Knowledge-base reads (kb_reads.py)

The kb-reads router is whole-router token-gated and tagged kb-reads. All three filtered lists share a limit query param: int, default 100, ge=1 le=500 (out-of-range → 422).

GET /api/v1/concept-edgeslist_concept_edges (app/api/v1/kb_reads.py:54). Query: node_id (int|None, matches src or dst), kind (str|None, "similarity"|"manual"), limit. Returns rows of:

{
  "id": 51,
  "from": { "node_id": 12, "name": "Glycolysis" },
  "to":   { "node_id": 19, "name": "Gluconeogenesis" },
  "kind": "similarity",
  "score": 0.83,
  "created_at": "2026-05-29T09:12:00Z"
}

GET /api/v1/atomic-factslist_atomic_facts (:92). Query: node_id (int|None), pdf_source_id (int|None), limit. Rows:

{
  "id": 904,
  "text": "Hexokinase phosphorylates glucose using ATP.",
  "node_id": 12,
  "page": 4,
  "pdf_source": { "id": 17, "filename": "lecture-03.pdf" },
  "created_at": "2026-05-29T09:10:00Z"
}

GET /api/v1/notion/pageslist_notion_pages (:131). Query: node_id (int|None). Reads our notion_pages rows only — never reads back Notion content (T47, V-N1/V-N2). Rows: {node_id, title, url, notion_page_id, tags, last_synced_at}. No declared errors beyond auth.

GET /api/v1/pdf-sourceslist_pdf_sources (:162). Query: course_id (int|None), status (str|None), limit. Rows: {id, filename, sha256, status, facts_count, course_id, ingested_at, created_at} (T48).

Tutor / MCP reads (tutor.py)

All tutor routes are token-gated (get_session, verify_coach_token) and return service-layer dicts (no declared response_model). The MCP seam detail lives in Intelligence Layer; these are the data-exposure endpoints behind it (V-M1).

Route Handler Query Notable errors
GET questions/by-qid/{qid} get_question_by_qid (tutor.py:32) 404 {reason:"question_not_found", qid}
GET questions/by-attempt-id/{attempt_id} get_question_by_attempt_id (:44) 404 {reason:"attempt_not_found"…}, 404 {reason:"question_not_found"…}
GET captures/recent get_recent_captures (:61) n (default 5, ge=1 le=50) 422 n out of range
GET sessions/latest get_latest_session_id (:70) — (returns {"test_id": null} if none)
GET sessions/recent get_recent_sessions (:78) n (default 5, ge=1 le=50) 422 n out of range
GET sessions/{test_id}/summary get_session_summary (:87) 404 {reason:"session_not_found", test_id}
GET attempts/flagged get_flagged_attempts (:99) limit (default 20, ge=1 le=100) 422 limit out of range
GET outline/nodes/search search_outline_nodes (:110) q (required), course (`str None), limit(default 20,ge=1 le=100`)
GET outline get_outline_tree (:128) course (required) 404 {reason:"course_not_found"…}, 422 missing course
GET outline/nodes/{node_id}/subtree get_node_subtree (:142) 404 {reason:"node_not_found", node_id}
GET healthz healthcheck (:156)

Note: Outline search is domain-blind (T22) — it ranks node names without privileging any one uploaded schema (V-O3).

POST /api/v1/pkm/discriminators

Persist-only (V-M1) discriminator factor write from the MCP host; append-only, deduped by (question_id, factor_text) (V-M3).

  • Auth: token. Handler: post_discriminatorapp/api/v1/pkm.py:23.
  • Body model: DiscriminatorIn.
Field Type Notes
question_id int Required.
factor_text str Stripped then min_length=1 — whitespace-only → 422.
node_id int | None Optional.

Success — 200 DiscriminatorOut:

{
  "id": 22,
  "question_id": 95,
  "factor_text": "Distinguish competitive vs noncompetitive inhibition by Vmax.",
  "node_id": 12,
  "notion_block_id": null,
  "created_at": "2026-05-30T14:05:00Z"
}

Errors: 404 {reason:"question_not_found", question_id}; 404 {reason:"node_not_found", node_id}; 422 blank factor_text.

Note: This is the only write under /pkm; the seam persists data fields only — no verdict, grade, or heuristic (V-M1). The MCP host does the reasoning.

Anki routes (anki.py, anki_assign.py, anki_load.py, anki_review.py)

All Anki routes are per-route token-gated. The shared list filters on assignments and reviews are status (query alias statusstatus_filter) and window_days (int|None, ge=1 le=365; out of range → 422).

POST /api/v1/anki/syncsync_anki (anki.py:76), 200. Stays soft on AnkiConnect being down — no 500; returns the §V4 error envelope:

{ "synced_cards": 0, "linked_qids": 0, "reviews_synced": 0, "error": "anki unreachable" }

GET /api/v1/anki/review-queueget_review_queue (anki.py:110), 200. Query limit (default 50, ge=1 le=200). Returns list[AnkiReviewQueueCardOut] (extends AnkiCardOut with per-card retention + retrievability, both nullable):

{
  "id": 30, "anki_card_id": 1500300, "deck_name": "AnKing::MCAT",
  "note_id": 88001, "model_name": "Cloze",
  "fields_json": {"Text": "..."},
  "due_date": "2026-05-31", "interval_days": 12, "ease": 2500,
  "lapses": 1, "queue": 2, "sync_at": "2026-05-30T08:00:00Z",
  "tags": [{"tag_raw": "uworld::qid::9921", "parsed_kind": "qid",
            "topic_id": null, "question_qid": "9921"}],
  "retention": 0.91, "retrievability": 0.86
}

GET /api/v1/anki/cards/by-qid/{qid}get_cards_for_qid (anki.py:132), 200. Returns list[AnkiCardOut] (same shape minus the two metric fields) for cards tagged uworld::qid::{qid}.

POST /api/v1/anki/assignmentscreate_assignment_route (anki_assign.py:48), 201. Body AnkiAssignmentCreateIn:

Field Type Notes
scope_kind Literal["cc","topic"]
scope_value str 1–64 chars; a topic_id (as str) or a CC code.
scheduled_unlock_at datetime
max_cards int | None > 0 if present.
priority Literal["most_specific_first","random","mature_first","young_first"] Default "most_specific_first".

Creates the assignment and snapshots resolved card_ids (V52). Returns AnkiAssignmentOut:

{
  "id": 7, "scope_kind": "topic", "scope_value": "12",
  "scheduled_unlock_at": "2026-06-01T06:00:00Z", "actual_unlock_at": null,
  "card_ids": [1500300, 1500301], "max_cards": 25,
  "priority": "most_specific_first", "status": "scheduled",
  "error_text": null, "failure_count": 0,
  "created_at": "2026-05-30T14:00:00Z", "updated_at": "2026-05-30T14:00:00Z"
}

Errors: 422 AssignmentError (e.g. V52 invalid scope), re-raised with str(exc); 422 schema validation on scope_kind/scope_value/max_cards.

GET /api/v1/anki/assignmentslist_assignments_route (anki_assign.py:71), 200. list[AnkiAssignmentOut] ordered by scheduled_unlock_at; filters status, window_days.

PATCH /api/v1/anki/assignments/{assignment_id}patch_assignment_route (anki_assign.py:97), 200. Body AnkiAssignmentPatchIn (status: Literal["skipped","completed"]) — the two human-driven transitions (mark_skipped / mark_completed_manual). Errors: 404 AssignmentNotFoundError; 409 AssignmentTerminalError (V51 terminal-state transition); 422 invalid status.

POST /api/v1/anki/reviewscreate_review_route (anki_review.py:30), 201. Body AnkiReviewCreateIn: card_ids: list[int] (min_length=1, AnKing-native BIGINT ids), review_date: date. Standalone pending review (V53 amended, no FK to assignments). Returns AnkiReviewOut:

{
  "id": 4, "review_date": "2026-06-02", "card_ids": [1500300, 1500301],
  "deck_name": "gradient::review::4", "status": "pending",
  "error_text": null, "failure_count": 0,
  "created_at": "2026-05-30T14:10:00Z", "pushed_at": null
}

Errors: 422 empty card_ids or missing review_date.

GET /api/v1/anki/reviewslist_reviews_route (anki_review.py:47), 200. list[AnkiReviewOut] ordered by review_date; filters status, window_days.

GET /api/v1/anki/load-configget_load_config_route (anki_load.py:56), 200 and POSTupsert_load_config_route (:68), 200. The singleton id=1 row (V59); GET auto-seeds the default (card budget 200, minutes 60) on miss. POST body AnkiLoadConfigIn: daily_card_review_budget: int > 0, daily_minutes_budget: Decimal > 0 (non-positive → 422). Both return AnkiLoadConfigOut:

{ "daily_card_review_budget": 200, "daily_minutes_budget": "60", "updated_at": "2026-05-30T14:12:00Z" }

GET /api/v1/anki/load-adherenceget_load_adherence_route (anki_load.py:93), 200. Query window_days (default 30, ge=1 le=365). Deterministic V54 adherence (data-only, no recommendations per V60) plus the T43 reviewed-count series. Returns AnkiLoadAdherenceOut:

{
  "window_days": 30, "projected_daily_load": 180, "projected_daily_minutes": 54.0,
  "daily_card_review_budget": 200, "daily_minutes_budget": 60.0,
  "headroom_card_review_pct": 10.0, "headroom_minutes_pct": 10.0,
  "status_label": "within_budget",
  "reviewed_series": [ { "date": "2026-05-29", "reviewed": 175 } ]
}

Admin routes (admin.py)

Note: admin.py exposes no recategorize route. The only mutation routes are the manual-tag create and the tag delete below.

POST /api/v1/admin/questions/{question_id}/tagscreate_manual_tag (admin.py:119), 201. Effectively open (no verify_coach_token, no localhost guard). Body ManualTagBody: topic_id: int|None, content_category_id: int|None, skill: int|Noneexactly one must be non-null (model_validator, else 422 "exactly one of topic_id, content_category_id, skill must be provided"). Returns:

{
  "tag_id": 1201, "question_id": 95, "topic_id": 12,
  "content_category_id": null, "skill": null, "confidence": 1.0,
  "source": "manual", "rationale": null, "extractor_version": "manual"
}

Errors: 404 ManualTagQuestionNotFoundError; 422 ManualTagValidationError; 409 CONFLICT ManualTagConflictError.

Note: the catalog described skill loosely; in code ManualTagBody.skill is typed int | None (a skill id), like the other two scope fields.

DELETE /api/v1/admin/tags/{tag_id}delete_tag (admin.py:236), 204. Effectively open (no auth dependency, no localhost guard). Behavior keys off source: source="manual" → hard delete; source="llm" → soft-override (is_overridden=True, overridden_at stamped); anything else → 403. Errors: 404 "tag_id={tag_id} not found"; 403 "refusing to delete tag with source={source!r}; only manual and llm tags can be removed".

GET /api/v1/admin/jobslist_jobs (admin.py:194), 200. Token-gated. list[{job_id, next_run_time}] from the live scheduler.

POST /api/v1/admin/jobs/{job_name}/triggertrigger_job (admin.py:203), 202. Token-gated. Validates job_name against the _VALID_JOBS allowlist (run_anki_sync, run_anki_assignment_unlock, run_anki_assignment_complete, run_anki_review) and nudges the scheduler. Returns {"status": "triggered", "job": "<name>"}.

Errors: 404 "unknown job: {job_name}"; 409 "{job_name} already running" (in _inflight); 503 "scheduler not running" (scheduler.get_job returns None).

Note: _VALID_JOBS is the Anki-job subset only — the ingest/embed/grounded jobs are not triggerable here. See the full scheduler catalog in Architecture.

GET /api/v1/admin/statussystem_status (admin.py:217), 200. Token-gated. Probes AnkiConnect / OpenAI / Notion reachability (each via its own overridable dep client, V16) and folds each scheduler job's last TaskRun outcome into a settings-panel health payload (T39). Returns collect_system_status(...).

GET /media/{file_path:path}

Serve PDF/image media from MEDIA_ROOT. Open, mounted at root (not under /api/v1). file_path is a path-converter and may contain slashes.

  • Handler: serveapp/web/media.py:12.
  • Success — 200: FileResponse with Cache-Control: public, max-age=86400.
  • Errors: 404 "not found" on OSError/ValueError resolving the path, on path-traversal (resolved target not relative to MEDIA_ROOT via resolve() + is_relative_to), or when the target is not a file.

See also

Clone this wiki locally