# MARMAR Context: the clinician API as Context calls it
The clinician screens in `web/context` call the MARMAR backend under `/api/clinician` and the clinician Better Auth instance under `/api/auth/clinician`. The backend's shapes win: [`src/api/wire.ts`](../src/api/wire.ts) mirrors its JSON field for field (from `src/clinician/**` in marmar-cds), and the adapters ([`adapt.ts`](../src/api/adapt.ts), [`adapt-checkins.ts`](../src/api/adapt-checkins.ts)) turn it into the view types the screens read ([`types.ts`](../src/api/types.ts) and its parts). The typed client is [`client.ts`](../src/api/client.ts). The backend's own guides are `docs/context-backend.md`, `docs/context-api.md` and `docs/clinician-sharing-and-check-ins.md`; its machine-readable contract is `GET /api/clinician/openapi.json`.
[`test/fixture`](../test/fixture) answers every route below in the same wire shapes with fictional data (`npm run fixture`), so fixture mode runs through the same client and adapters. If this page and `wire.ts` disagree, the backend wins: change `wire.ts`, the adapter and the fixture together.
## Conventions
- **Origin.** Same origin by default: the Vite dev server proxies `/api` (`CONTEXT_API_PROXY`), and a hosted site proxies `/api/clinician` and `/api/auth/clinician` to the backend. Or set `VITE_CONTEXT_API_ORIGIN` at build time to call the backend directly; it then needs the site's origin in `CLINICIAN_ALLOWED_ORIGINS` (the dev server's `http://127.0.0.1:4190` and `http://localhost:4190` are allowed outside production). Every call sends cookies (`credentials: "include"`); every write needs a Context `Origin`.
- **Errors** are `{ code, message }`; validation is 400 `VALIDATION_ERROR` with `details: [{ path, message }]`; some sharing errors add fields (`eligibility`, `phone`), which the client puts in `ApiError.details`. A switched-off area answers 404 `_NOT_ENABLED`; the client reads the invitations, alerts and check-in lists as empty then.
- **The gate.** 401 `SIGN_IN_REQUIRED`, `SESSION_EXPIRED` or `ACCOUNT_CLOSED` go to sign-in. 403 `STEP_UP_REQUIRED` or `SESSION_LOCKED` show the lock screen; 403 `TERMS_REQUIRED` shows the terms; 403 `CLINICIAN_SUSPENDED` or `VERIFICATION_LAPSED` go to sign-in, which says why.
- **Background requests** (polling `/check-ins/:id/updates`, `/alerts`) send `X-Context-Background: 1`, so they do not keep the session from locking.
- **Days.** The backend counts weekdays 1 to 7 from Monday; Context counts 0 to 6 from Sunday. The adapters convert both ways.
## Sign-in, the step-up and the terms (ACC-7, ACC-10)
| Method and path | Body | Answer | View |
|---|---|---|---|
| `POST /api/auth/clinician/email-otp/send-verification-otp` | `{ email, type: "sign-in" }` | `{ success }`, whoever the email is | |
| `POST /api/auth/clinician/sign-in/email-otp` | `{ email, otp }` | sets the session cookie | |
| `POST /api/auth/clinician/sign-out` | `{}` | ends this session | |
| `GET /me` | | `{ clinician, session: { stepUpDone, locked, idleLockMinutes, startedAt, endsAt }, secondFactor: { totp }, features }` | `ClinicianSession` (`stepUp`: `enrol` with no authenticator, `done`, else `required`) and `ClinicianProfile` |
| `POST /step-up/totp` | | 201 `{ secret, otpauthUrl, qrSvg }` (the QR is drawn by the backend) | `TotpEnrolment` |
| `POST /step-up/totp/confirm` | `{ code }` | `{ stepUpDone, recoveryCodes }`: ten codes, shown once | `TotpConfirmed` |
| `POST /step-up/verify` | `{ code }`: 6 digits or a recovery code | `{ stepUpDone, method }` | the session again |
| `POST /session/lock` | | `{ locked: true }` | the session again |
| `POST /me/terms` | `{ termsVersion, dpaVersion }` | `{ clinician }` | the session again |
| `POST /sessions/sign-out-everywhere` | | `{ signedOut }` | |
## Profile and clinic (ACC-8, ACC-9)
`PATCH /me` (step-up) takes `{ title, displayName, pronoun, mobilePhone, idleLockMinutes }` and answers `{ clinician }`. The name on the register (`fullName`) is not editable; Context splits it after the title. Browser notifications are kept in this browser, not on the server. `GET /clinic` answers `{ clinic }` with `addressLine`, `timezone`, `openingHours` and `alertWatchHours` (days 1 to 7), `backupAlertName`/`Phone`/`Email`, `emergencyNumbers` (`source: clinic | registry`, `testDialledAt`), `role` and `version`. `PATCH /clinic` is the owner's only (403 `CLINIC_OWNER_ONLY`), takes `expectedVersion` (409 `CLINIC_CHANGED`), and never gets the registry's numbers back.
## Access requests and invites (ACC-3, ACC-4, INV-9 to INV-14)
| Method and path | Body or answer |
|---|---|
| `POST /access-requests` (public) | `{ fullName, profession, licenceBody, licenceNumber, countryCode, facilityName, facilityPhone, workEmail, mobilePhone, heardFrom, heardFromDetail?, acceptTerms: true, termsVersion, inviteCode? }` → 202 `{ status: "received", message }` whatever is on file |
| `GET /invite-preview/:token` (public) | `{ status, patientName, withPerson, doctorName, clinicName, doctorEmail, expiresAt }`; no pronoun, so Context says they/them |
| `GET /invitations` | `{ invitations: [{ id, patientName, patientAge, withPerson, scopes, allowsCheckInCalls, withheldSome, sentAt, expiresAt, possibleMatch }] }` |
| `POST /invitations/:id/accept` | `{ confirmedMyPatient: true, linkToPatientId }` → `{ patientId, shareId }` |
| `POST /invitations/:id/decline` | `{ reason: not_my_patient \| not_for_context \| other \| null, note }` → `{ declined: true }`; the note is not kept |
## Patients (PAT-2 to PAT-7, SHR-1)
| Method and path | Answer |
|---|---|
| `GET /patients` | `{ items, asOf }`, sorted by what needs attention. Each connected patient carries the tile: `sharing.sharedBy`, `medicines: { count }`, `doses: { taken, expected, onTime }` and `lastCheckIn: { at, kind, status }`, each null for an area not shared (the tile reads "Not shared") and all null while paused |
| `POST /patients/search` | `{ query }` → the same `{ items, asOf }` (names never go in a URL) |
| `POST /patients` | `{ displayName, dateOfBirth \| ageYears, sex?, attestation: { careRelationship: true, patientAgreement: true, version: "2026-10-30" } }` → 201 `{ patient }` (the bare row); the client then reads the detail |
| `GET /patients/:id` | `{ patient }`: the item plus `firstName`, `dateOfBirthIso`, `identityOnFile`, `timeZone` (the clinic's), `hasOwnAccount`, `phone`, `pronoun`, `activeCheckInId` |
| `GET /patients/:id/shared` | The tile: `{ asOf, sharing: { sharedBy, scopes, allowsCheckInCalls, withheldSome, … }, medicines, doses, lastCheckIn: { at, kind, status }, phone }`. `GET /patients`, search and `GET /patients/:id` carry the same `medicines`, `doses`, `lastCheckIn` and `sharing.sharedBy` for each connected patient while sharing is on |
| `POST /patients/:id/viewed` | `{ section }` → `{ lastLookedAt }` (the previous look) |
| `GET /patients/:id/activity` | `{ events: [{ id, at, actor, actorKind, action }] }` |
For a connected patient `GET /patients/:id` carries `hasOwnAccount`, `phone`, `firstName`, `language` and `activeCheckInId`; the client still reads the tile, `GET /patients/:id/check-ins/plan` (eligibility, limits, quiet hours, blocked times, the patient's zone) and `GET /patients/:id/check-ins` for the rest.
`GET /patients/:id/shared/medicines | doses | check-ins | records` answer as `SharedMedicines` and friends (`doses` adds `complete`); 403 `SHARING_PAUSED`, `SHARING_STOPPED` or `SCOPE_NOT_SHARED`, 404 `PATIENT_NOT_VISIBLE`. `prescriber` is null until the app stores one.
## Records (REC-1 to REC-7)
| Method and path | Body | Answer |
|---|---|---|
| `GET /patients/:id/records` | | `{ records }`: each with `reading`, `readingError`, `readingErrorCode`, `heldBack: { reason } \| null`, `clarification`, and `fileName: null` (names are not kept) |
| `POST /patients/:id/records/uploads` | `{ fileName, contentType, sizeBytes }` | 201 `{ uploadId, uploadUrl, method, headers, expiresAt, maxBytes }` |
| `PUT ` | the file | storage's own answer |
| `POST /patients/:id/records` | `{ text }` or `{ uploadId }`, `title`, `type`, `place`, `recordDate`, `origin` | 201, the record |
| `PATCH /records/:id` | `{ title, type, place, recordDate }` | the record |
| `POST /records/:id/confirm-patient` | | the record, no longer held back |
| `POST /records/:id/read-again` | | the record (a failed file) |
| `DELETE /records/:id` | | `{ deleted: true, removedAt, reading: read_again \| set_aside \| unchanged }`: a soft delete; its facts leave review and the next check. Context asks to confirm first |
| `GET /records/:id/lines` | | `{ recordId, lines: [{ number, text, page, box, confidence, hardToRead, handwritten }] }` |
## Reading and review (REV)
`POST /patients/:id/extractions` (201), `GET /patients/:id/extractions/latest` (`{ extraction }`), `GET /extractions/:id`: the preview's `Draft` plus `revision`, `records: [{ recordId, status (incl. held_back), error, inReading }]` (no titles: the client reads them from the records list), `recordsNotReady`, `decisions`, `coverageReview`, `acknowledgedOriginals`, `confirmedHardToRead`, `stale`. Facts carry `hardToRead`; coverage candidates carry `kind` (`medicine`, `allergy`, `kidney`).
`PUT /extractions/:id/decisions` takes `{ revision, decisions, coverageReview: { id: { choice: not_current \| not_relevant, reason? } }, acknowledgedOriginals, confirmedHardToRead }` → `{ revision, savedAt }`; 409 `STALE_REVISION` from an older tab. A reason is needed for an allergy (10 characters or more) or with no choice. An included Hard to read fact must be confirmed one by one, never in bulk. `POST /extractions/:id/clarifications` (201) answers the new reading.
## The check (CHK, FND)
| Method and path | Answer |
|---|---|
| `POST /patients/:id/checks` `{ extractionId, revision }` | 201 `CheckRun`: the two-pass `assessment`, `summary`, `details`, `marks`, `missingFactors`, `monitoring`, `notChecked` (with `lines`), `urgentReview`, `versions` (with `signedOffOnly`), `checkedOn`, `latest`, `newInformation`, `informationTool`, `recordTitles`, `sourceLines`. 409 `RECORDS_NOT_READY`, `STALE_EXTRACTION`, `STALE_REVISION`; 400 with the open gate's code |
| `GET /patients/:id/checks` | `{ checks: [{ id, ranAt, ranBy, findings, added }] }` |
| `GET /checks/:id` | `CheckRun` |
| `POST /checks/:id/findings/:findingId/mark` `{ kind, note, reason }` | 201 `FindingMark`; 400 `REASON_REQUIRED` or `DESCRIPTION_REQUIRED` |
| `GET /checks/:id/export?format=json\|html\|pdf` | JSON as an attachment; `html` and `pdf` both answer the printable page (`X-Export-Format: html`), which Context opens in a new tab to print or save as a PDF |
## Check-ins (YCI)
| Method and path | Body | Answer |
|---|---|---|
| `GET /patients/:id/check-ins/plan?checkId=&findingId=` | | The plan for the latest check, or `checkId`'s, with `findingId`'s questions first: `eligibility { ok, code, message }`, `urgentFirst`, the fixed lines, `suggestions`, `closings` (`sent`, `read_today`, `see_in_clinic`, `call_by_5`, `safety_net`), `window` (days 1 to 7; `quietHours` null when off; only zones for a clinic-added patient), `limits` (null when not eligible), `fairUse`, `maxQuestions` (10, own included). Linked findings go in the create body |
| `POST /check-ins/questions/validate` | `{ patientId, text }` | `{ ok: true, text }` or `{ ok: false, rule, reason }` |
| `POST /patients/:id/check-ins` | `{ questions, closingIds, window, expiresInHours, linkedFindingIds, checkId, urgentCallConfirmedAt, readToday }` | 201 `CheckIn`. 409 `CHECK_IN_NOT_ALLOWED` (`eligibility`), `URGENT_CALL_FIRST` (`phone`), `NO_WINDOW`; 429 `LIMIT_REACHED` |
| `GET /patients/:id/check-ins` | | `{ checkIns }` for this clinician |
| `GET /check-ins/:id` | | `CheckIn`: `plan` (with `linkedTiers`), `script`, `status`, `history`, `window`, `later`, `urgent`, `urgentAcknowledgedAt`, `notes` (with `validation`, `endedEarly`), `summary`, `transcript`, `alerts` (already sent: urgent sent, escalated, not reached), `lastEventId` |
| `GET /check-ins/:id/events` | | Server-sent events, replayed from `Last-Event-ID` or `?lastEventId=` |
| `GET /check-ins/:id/updates?after=N` | | `{ events, lastEventId }` |
| `POST /check-ins/:id/read` | | `{ readAt }` |
| `POST /check-ins/:id/notes/:noteId/acknowledge` | | The note; every urgent note of the check-in is acknowledged |
| `POST /check-ins/:id/add-to-check` | `{ noteIds }` | `{ recordId, notes, note }`: the chosen notes become one record from the call. Context sends its whole choice here; 409 `NOTE_NOT_CONFIRMED` for a note not found in the call text or disputed |
| `POST /check-ins/:id/notes/:noteId/add-to-check` | | `{ note, recordId }`, one note on its own (a record each time) |
| `POST /check-ins/:id/cancel` | | `CheckIn`; 409 `CHECK_IN_NOT_OPEN` |
| `GET /alerts` | | `{ alerts }`, one per check-in with an urgent note not yet acknowledged (`noteId` may be null) |
Statuses (YCI-28): `sent`, `ringing`, `missed`, `later`, `in_progress`, `completed`, `declined`, `not_reached` (expired without a call), `identity_not_confirmed`, `ended_early`, `cancelled`. Urgent is a flag (`urgent`, with the urgent note), never a status. Status `detail` is a code (`call_started`, `cancelled_by_clinician`).
Events: `status`, `note`, `note_updated`, `transcript`, `summary`, `urgent`, and `alert` (`{ kind: urgent_sent | escalated | not_reached, message }`), which Context shows as a banner on the live panel. Context folds events with `applyEvent` (`src/logic/checkin-events.ts`), ignoring any id at or below the last one seen; two stream errors in a minute switch to polling every 3 seconds.