API v1Everything is a client of the same engine
The caregiver web app, the patient's phone, the pendant and this reference all talk to one service layer. A native app has nothing to reimplement: the run engine, the confirmation rules, the routing and the reports live on the server, and every state change is one JSON call.
Conventions
- Base URL
https://omi.projectz.xyz/api/v1. JSON in, JSON out. Envelope {"status":"success","data":…} or {"status":"error","error":"…"}.
- Three headers, three callers: X-API-Key (adults), X-Patient-Token (the patient's phone), X-Device-Token (a pendant).
- Codes: 201 created · 401 not authenticated · 403 not allowed for your role · 404 not found or not yours · 409 conflict (a routine is already running) · 422 validation.
- Times are UTC (
…_at) except start_time/end_time, which are wall-clock in the patient's timezone. weekday_mask bit 0 = Monday.
- Every response carries
X-Request-Id.
The shape a phone renders
GET /api/v1/patient X-Patient-Token: …
{
"status": "success",
"data": {
"patient": { "id": 1, "name": "Eli", "voice_name": "Omi", "listen_seconds": 8, … },
"run": {
"status": "running", "progress_label": "Step 3 of 6", "clock": "7:06 AM",
"omi_line": "Time to brush your teeth.",
"omi_hint": "Say \"Omi, done\" or press your button when you finish.",
"listening": true, "listening_remaining_sec": 6, "pendant": "listening",
"speech": { "event_id": 812, "text": "Time to brush your teeth.", "spoken": false, "audio_url": null,
"voice": { "preset": "warm", "lang": "en-US", "gender": "female", "rate": 0.95, "pitch": 1.0, "device_voice": null, "driver": "browser" } },
"current_step": { "label": "Brush teeth", "status": "waiting", "waiting_sec": 95, "max_min": 8, "confirm_mode": "voice_or_button", "awaiting_second": false, … },
"steps": [ { "label": "Wake up", "status": "done", "elapsed_sec": 40, "cleared_by": "button", "repeats": 0, "ambient_acks": 0 }, … ],
"on_duty": { "name": "Mom", "role": "primary", "window": { "label": "School morning", "start": "6:00 AM", "end": "8:00 AM" } }
},
"pendant": { "name": "Home pendant", "status": "active", "battery_pct": 82 },
"next": [ … ]
}
}
Auth
X-API-Key for adults. A mobile app logs in once and keeps the key.
| POST | /api/v1/auth | {email, password} → {api_key, user} |
| POST | /api/v1/auth?action=signup | {email, password, name, timezone?, sample_family?} → 201 {api_key, user, patient?} |
| GET | /api/v1/auth | me + unread_notifications |
| PATCH | /api/v1/auth | {name?, phone?, timezone?} |
| POST | /api/v1/auth?action=rotate | revoke | new key / no key |
Patients
Access is always a seat on the care team. Unknown or unowned ids are 404, never 403.
| GET | /api/v1/patients | everyone I care for, with my role |
| GET | /api/v1/patients?id=N | patient + care team + on duty + devices + running_run_id |
| POST | /api/v1/patients | {name, timezone?, self_mode?} → 201 · {sample: true} seeds the sample family |
| PATCH | /api/v1/patients?id=N | {name?, timezone?, voice_name?, listen_seconds?} primary |
| DELETE | /api/v1/patients?id=N | archive primary |
| POST | /api/v1/patients?id=N&action=join-code | {label?} → {join_code, expires_in_sec} primary |
| GET | /api/v1/patients?id=N&phones=1 | phones bound to the patient primary |
| DELETE | /api/v1/patients?id=N&phone=T | revoke a phone token primary |
Care team + coverage
Who is on duty is decided by coverage windows; a gap falls to the primary.
| GET | /api/v1/care-team?patient_id=N | members, windows, on_duty |
| POST | /api/v1/care-team | {patient_id, email, role: caregiver|educator|backup, label?, can_push?, can_edit_own_window?, copy_all?, backup_after_min?, ends_on?} → 201 primary |
| PATCH | /api/v1/care-team?id=N | same optional fields primary |
| DELETE | /api/v1/care-team?id=N | remove a seat primary |
| POST | /api/v1/care-team?action=window | {patient_id, member_id, label, weekday_mask, start_time, end_time} → 201 primary |
| DELETE | /api/v1/care-team?action=window&id=N&patient_id=N | primary |
Routines
Shared library routines are read-only; using one copies it into the patient's own routines.
| GET | /api/v1/routines?patient_id=N[&category=mornings|mine][&q=] | library with step counts |
| GET | /api/v1/routines?patient_id=N&counts=1 | category counts |
| GET | /api/v1/routines?id=N&patient_id=N | one routine with steps |
| POST | /api/v1/routines | {patient_id, name, category?, description?, steps?} → 201 |
| POST | /api/v1/routines?id=N&action=copy | {patient_id} → 201 copy |
| PATCH | /api/v1/routines?id=N&patient_id=N | {name?, description?, category?} |
| DELETE | /api/v1/routines?id=N&patient_id=N | archive |
| PUT | /api/v1/routines?id=N&patient_id=N&steps=1 | {steps: [...]} replace all steps, in order |
| POST | /api/v1/routines?id=N&patient_id=N&steps=1 | {label, prompt?, target_min?, max_min?, repeat_seconds?, confirm_mode?, …} append → 201 |
| PATCH | /api/v1/routines?id=N&patient_id=N&step=S | update one step |
| POST | /api/v1/routines?id=N&patient_id=N&step=S&action=move | {delta: -1|1} |
| DELETE | /api/v1/routines?id=N&patient_id=N&step=S | |
Schedules
Wall-clock in the patient's timezone. weekday_mask bit 0 = Monday. The server starts due runs every minute.
| GET | /api/v1/schedules?patient_id=N | all active |
| GET | /api/v1/schedules?patient_id=N&week=YYYY-MM-DD | the week grid |
| POST | /api/v1/schedules | {patient_id, routine_id, start_time, weekday_mask?|on_date?, starts_on?, ends_on?, auto_start?} → 201 |
| PATCH | /api/v1/schedules?id=N&patient_id=N | {…, status: active|paused} |
| DELETE | /api/v1/schedules?id=N&patient_id=N | archive |
Runs (the live moment)
One state object serves both views. Ticks are server-side; clients never keep time.
| GET | /api/v1/runs?patient_id=N | running run state + recent |
| GET | /api/v1/runs?id=N | state: steps with status, current_step, omi_line, omi_hint, listening, pendant, on_duty, clock |
| GET | /api/v1/runs?id=N&events=1 | activity log, newest first |
| POST | /api/v1/runs | {patient_id, routine_id, demo?} start → 201 |
| POST | /api/v1/runs?id=N&action=press | the button (phone token or caregiver) |
| POST | /api/v1/runs?id=N&action=heard | {text} — only addressed replies inside the listening window count |
| POST | /api/v1/runs?id=N&action=heard | multipart 'audio' instead of {text} when the server speech driver is on (transcribed first) |
| POST | /api/v1/runs?id=N&action=spoken | {event_id} — the device finished saying speech.event_id; the listening window starts now |
| POST | /api/v1/runs?id=N&action=more_time | repeat | |
| POST | /api/v1/runs?id=N&action=push | add_time | stop | caregiver: {minutes?} |
| POST | /api/v1/runs?id=N&action=tick | bring the state up to now |
| POST | /api/v1/runs?id=N&action=elapse | {seconds} demo runs only |
Patient phone
X-Patient-Token. No password on the patient side, ever.
| POST | /api/v1/patient?action=claim | {code, label?} → 201 {token, patient} (no auth) |
| GET | /api/v1/patient | patient, run (state), pendant, next schedules |
| POST | /api/v1/patient?action=press | heard | more_time | repeat | spoken | act on the running routine (heard: {text} or multipart audio; spoken: {event_id}) |
| POST | /api/v1/patient?action=start | {routine_id} self mode only |
Devices (pendant)
X-Device-Token for the pendant; pairing needs the primary.
| GET | /api/v1/devices?patient_id=N | pendants |
| POST | /api/v1/devices | {patient_id, name} begin pairing → 201 {pair_code} |
| POST | /api/v1/devices?id=N&action=confirm | {code} → {device_token} once |
| POST | /api/v1/devices?id=N&action=activate | unpair | chime | |
| PATCH | /api/v1/devices?id=N | {name?, volume?} |
| GET | /api/v1/devices?me=1 | pendant: my status + run state |
| POST | /api/v1/devices?action=button | pendant: {press: single|long|double} |
| POST | /api/v1/devices?action=heartbeat | pendant: {battery_pct} |
Speech
Every run state carries `speech`: {event_id, text, spoken, voice{preset, lang, gender, rate, pitch, device_voice, driver}, audio_url}. Driver "browser": the client speaks the text with that voice and listens itself. Driver "api": play audio_url, or upload audio to heard.
| GET | /api/v1/speech?event=N | audio/mpeg for one of Omi's lines (server driver; 501 otherwise) — any caller of that patient |
| GET | /api/v1/speech?patient_id=N | the voice this patient's clients should use |
| GET | /api/v1/speech?voices=1 | preset catalogue + languages, for a settings screen |
| PATCH | /api/v1/patients?id=N | {voice_preset, voice_lang, voice_device?, voice_rate?, voice_pitch?} — set the voice primary |
Notifications
Routed by coverage window. The mobile app polls; push can be layered on later.
| GET | /api/v1/notifications[?unread=1] | newest first + unread count |
| POST | /api/v1/notifications?id=N&action=read | ack | ack stops the 5-minute escalation |
| POST | /api/v1/notifications?action=read_all | |
Reports
Per step: prompt-to-done, repeats, ambient replies, cleared by; plus suggestions that can be applied.
| GET | /api/v1/reports?patient_id=N | routines that have run |
| GET | /api/v1/reports?patient_id=N&routine_id=R[&week=YYYY-MM-DD][&demo=1] | weekly report + suggestions |
| POST | /api/v1/reports?patient_id=N&routine_id=R&action=apply | {apply} from a suggestion primary |
The speaking / listening loop a client implements- Poll
GET /api/v1/patient. When run.speech.event_id changes, say speech.text in speech.voice (or play audio_url). - When speech finishes,
POST ?action=spoken {event_id}. The server starts the listening window from that moment and returns the state with listening: true. - Open the mic for
listening_remaining_sec. Send what you heard as heard {text} (or upload audio). The server decides whether it counts — addressed reply, ambient acknowledgement, or outside the window — and answers with Omi's next line. - The button (
press) works at any time and needs none of this.
Not in this proof of conceptPush notifications (the rows exist; delivery is polling + email) and any external vault or identity integration. Server-side speech is built but off until a key is configured; today each device speaks and listens itself. The engine does not care which client presses the button.