Protocol reference
Shared Sessions v1 — objects, headers, every HTTP route, the lease rules, idempotency, errors and limits.
This is the normative surface of codai Shared Sessions v1.0.0 as served by https://ai.codai.ro. Field names are snake_case on the wire; timestamps named *_at are ISO-8601 strings; ts and last_seen are integer milliseconds since the Unix epoch. JSON Schemas for every object live in the protocol repository. Realtime frames are on the streaming page; shares and orgs have their own page.
Transport and headers
All routes are HTTPS under the gateway base URL and are JSON unless stated. Authentication is a Bearer API key; the principal is the user the key belongs to.
Authorization: Bearer codai_xxxxxxxxDevice headers
| Header | Required | Meaning |
|---|---|---|
x-codai-device | On every write, lease, stream and WebSocket; optional on reads. | A UUID chosen by the client, stable per installation. Registered lazily under the caller's user on first sight; last_seen_at bumped on every request. Anything that is not a UUID is 400. |
x-codai-device-platform | Recommended | android · ios · web · desktop · cli · agent. Anything else (or absent) is stored as agent. |
x-codai-device-name | Recommended | Human label, truncated to 120 chars. Default "<platform> device". Used only at first registration. |
x-codai-push-token | Optional | FCM registration token, 8 – 4096 ASCII chars, upserted onto the device on any HTTP request that carries x-codai-device. A malformed value is silently ignored. |
x-codai-share-token | When exercising a link share | Plaintext link-share token. Alternative: ?share=<token> (for SSE/WS from browsers). |
A device id already registered to another user is refused with 403 forbidden — Device id is registered to another account. — rather than reused.
Selecting v2 on overlapping paths
GET /v1/sessions and GET /v1/sessions/:id share their paths with a legacy read-only memory API. The shared-sessions handler is selected when the request carries x-codai-device or ?v=2. Always send x-codai-device; ?v=2 is for quick reads without a device.
Session references
Wherever a path contains :id you may pass either the server UUID id or your own session_key. When a session_key collides across users, your own session wins.
Objects
Session
{
"id": "5b3e…-uuid",
"session_key": "phone-7f1c…",
"owner_user_id": "…-uuid",
"title": "Book the dentist",
"created_at": "2026-09-13T10:00:00.000Z",
"last_event_at": "2026-09-13T10:04:12.512Z",
"last_seq": 42,
"executor_device_id": "…-uuid or null",
"lease_expires_at": "2026-09-13T10:04:40.000Z or null",
"e2e": false,
"archived": false
}session_key is client-chosen (the phone uses the same UUID it sends as x-codai-session-id for inference); id is server-assigned. List responses add role (yours) and presence_count; the detail response adds role, members[], lease and presence[]; shared-with-me adds role and share_id.
Device
{
"id": "…-uuid",
"name": "Pixel 9",
"platform": "android",
"capabilities": ["screen", "local_llm", "terminal", "contacts"],
"has_push_token": true,
"lastSeenAt": "2026-09-13T10:04:12.512Z",
"createdAt": "2026-09-01T08:00:00.000Z"
}capabilities is a free list of ≤ 64 strings of ≤ 64 chars. The push token is never returned — clients see has_push_token. The two camelCase timestamps on this object are part of v1 and will not be renamed.
Event
{
"seq": 7,
"kind": "tool_call",
"ts": 1757757852512,
"sender_device_id": "…-uuid",
"turn_id": "t-…",
"client_event_id": "c9f2…",
"payload": { "name": "open_app", "args": { "package": "com.whatsapp" } }
}seqis server-assigned, dense per session, starting at 1. Clients never send it.kind: 1 – 64 chars, opaque to the server; unknown kinds are never rejected. The executor's trace vocabulary isturn_start, step_start, req_start, local_gen, tool_start, tool_call, tool_result, ask, ask_resolved, wait_user, deadline, error, usage, turn_end, plusassistant,userand the executor-emittedscreen_wait.ts: client clock; defaults to server time when omitted.payload: any JSON object (default{});{ "hide": "<base64>" }in E2E mode.
Server-emitted kinds:
| kind | Emitted when | Payload |
|---|---|---|
control | A control is accepted | { "control": Control, "applied": false } |
lease_transferred | A lease is force-claimed from a live holder | { "from_device_id", "to_device_id", "forced": true } |
Control
{
"id": "ctl-8d1a…",
"kind": "steer",
"text": "Use the second search result instead.",
"turn_id": "t-…",
"ask_id": null,
"from_device_id": "…-uuid",
"target_device_id": null,
"seq": 8,
"applied": false,
"created_at": "2026-09-13T10:04:13.000Z"
}kind ∈ send | answer | approve | deny | cancel | steer | inject. id is the client idempotency key (1 – 128 chars); text ≤ 100 000 chars. target_device_id is set only by dispatch and is null otherwise; a targeted control is meant for that one device and every other executor should skip it.
| kind | The executor should… |
|---|---|
send | Start a new turn with text as the user message. |
answer | Resolve the pending ask identified by ask_id with text. |
approve / deny | Let the pending confirmation (ask_id) proceed / refuse it. |
cancel | Stop the turn turn_id (or the current one). |
steer | Inject text as guidance into the running turn without ending it. |
inject | Append text to the context for the next model call. |
Lease
{ "session_id": "…-uuid", "device_id": "…-uuid", "expires_at": "2026-09-13T10:04:40.000Z" }TTL 30 s; heartbeat every 10 s. Realtime lease frames use holder_device_id (nullable) instead of device_id.
Presence
{ "device_id": "…-uuid", "user_id": "…-uuid", "role": "editor", "executor": false, "driving": false, "last_seen": 1757757852512, "online": true, "remote": true }Ephemeral, never persisted. remote is computed for the caller and appears only in GET /v1/sessions/:id; online appears only in presence frames (false on the leave frame).
Share and Org
See sharing and orgs. Share: { id, session_id, principal_type: user|org|link, principal_id, role: viewer|editor|owner, has_token, expires_at, created_by_user_id, created_at } (+ token once). Org: { id, name, ownerUserId, createdAt } — camelCase on the wire — with membership roles owner | admin | member.
Roles
ownerwhen the session'sowner_user_idis you.- Otherwise the maximum role over non-expired shares matching you directly, any org you belong to, or the link token you present.
- Otherwise no role →
403 not_a_member. An unknown id is404 not_found.
| Action | viewer | editor | owner |
|---|---|---|---|
| GET session / events / controls / stream / WS | ✓ | ✓ | ✓ |
| POST control, POST dispatch | ✓ | ✓ | |
| PATCH / DELETE session | ✓ | ||
| POST / PUT / DELETE lease | ✓ | ||
| GET / POST / DELETE shares | ✓ | ||
| POST events, POST control/:cid/applied | lease holder only (in practice an owner device) |
HTTP routes
Every mutation follows validate → authenticate → authorize → audit → execute. A failed validation is 400 bad_request with details describing the fields.
Sessions
| Method | Path | Min role | Request | Response | Errors |
|---|---|---|---|---|---|
POST | /v1/sessions | any user | { session_key?: string ≤256, title?: string ≤500, e2e?: bool }. session_key defaults to a random UUID. | 201 Session (new) · 200 Session (existing session_key) | 400 |
GET | /v1/sessions?v=2&limit=&archived= | any user | limit default 50 max 200 (applied to own and shared lists separately); archived=1 includes archived | 200 { sessions: [Session & { role, presence_count }] } — own first, then shared | — |
GET | /v1/sessions/:id | viewer | — | 200 Session & { role, members[], lease: Lease | null, presence[] } | 403, 404 |
PATCH | /v1/sessions/:id | owner | { title?: string | null, archived?: bool } (≥ 1 field) | 200 Session | 400, 403, 404 |
DELETE | /v1/sessions/:id | owner | — | 200 { deleted: true, id } — cascades events, controls, lease, shares | 403, 404 |
members[] = { user_id, role, remote, devices: [{ device_id, name, platform, last_seen_at, remote }] }, one entry per member user (owner, direct user shares, members of org shares, and you if you came via a link).
Events
| Method | Path | Min role | Request | Response | Errors |
|---|---|---|---|---|---|
GET | /v1/sessions/:id/events?after=&limit= | viewer | after default 0; limit default 200 max 1000 | 200 { last_seq, events: Event[] } ascending by seq | 403, 404 |
POST | /v1/sessions/:id/events | lease holder | { events: IncomingEvent[1..200], expected_last_seq?: int } | 200 { last_seq, accepted: int, events: [{ client_event_id, seq }] } | 400, 403, 404, 409 lease_held, 409 seq_conflict |
IncomingEvent = { kind, ts?, turn_id?, client_event_id?, payload? } — never seq. accepted counts newly stored events; duplicates (by client_event_id) are reported with their original seq.
Controls and dispatch
| Method | Path | Min role | Request | Response | Errors |
|---|---|---|---|---|---|
GET | /v1/sessions/:id/controls?applied=&target= | viewer | applied default false; target=me (needs x-codai-device) or a device UUID | 200 { controls: Control[] } (≤ 500, oldest first). Not lease-gated. | 400, 403, 404 |
POST | /v1/sessions/:id/control | editor | { id, kind, text?, turn_id?, ask_id? } | 202 { accepted: true, seq, duplicate: false } · 200 { …, duplicate: true } | 400, 403, 404 |
POST | /v1/sessions/:id/control/:cid/applied | lease holder | — | 200 { id, applied: true } | 403, 404, 409 lease_held |
POST | /v1/sessions/:id/dispatch | editor | { device_id: uuid, text: string 1..100000, turn_id?, control_id? } | 202 { control_id, seq, target_device_id, queued: true, pushed, duplicate: false, push_reason? } · 200 with duplicate: true | 400, 403, 404 (session or target device) |
GET | /v1/devices/me/dispatch?limit= | own sessions | limit default 200 max 500; needs x-codai-device | 200 { device_id, sessions: [{ id, session_key, title, controls: Control[] }] } — every pending control targeted at this device across your non-archived sessions, one call | 400 |
Lease
| Method | Path | Min role | Request | Response | Errors |
|---|---|---|---|---|---|
POST | /v1/sessions/:id/lease | owner | { device_id?: uuid, force?: bool } (empty body allowed; device_id defaults to x-codai-device) | 200 { session_id, device_id, expires_at } | 400, 403, 404, 409 lease_held |
PUT | /v1/sessions/:id/lease | owner | — (heartbeat) | 200 { session_id, device_id, expires_at } | 403, 404, 409 lease_held |
DELETE | /v1/sessions/:id/lease | owner | — | 200 { session_id, released: bool } | 403, 404 |
Devices
| Method | Path | Request | Response | Errors |
|---|---|---|---|---|
GET | /v1/devices | — | 200 { devices: Device[] } (yours, most recent first) | — |
PATCH | /v1/devices/:id | { push_token?: string | null, capabilities?: string[] } (≥ 1 field) | 200 Device | 400, 404 |
DELETE | /v1/devices/:id | — | 200 { deleted: true, id } | 400, 404 |
Lease semantics
- TTL 30 s, renewed by
PUT …/leaseevery 10 s.expires_atis returned on claim and every heartbeat. - Claim is compare-and-set.
POST …/leasesucceeds when the lease is free, expired, or already held by the calling device. If another device holds a live lease →409 lease_held; the holder is named inmessageas(holder_device_id=…). - Force.
{ "force": true }(owner) takes a live lease from another device. The server appends alease_transferredevent and audits it. The previous holder learns at its next heartbeat (409 lease_held, Lease lost to another device.) and must stop executing, become a viewer, and show who is driving. - Heartbeat on an expired lease is
409 lease_heldwith Lease expired. and no holder. - Release is idempotent:
released: falsewhen you did not hold it. - Every claim/heartbeat/release publishes a
leaseframe and mirrorsexecutor_device_id/lease_expires_atonto the session. - Appending events or marking a control applied without the live lease →
409 lease_held.
Idempotency
| Operation | Key | Repeat behaviour |
|---|---|---|
POST /v1/sessions | session_key (per user) | 200 with the existing session instead of 201. A session_key that equals an existing session's UUID id also resolves to that session. |
POST …/events | (session, sender_device, client_event_id) | Duplicates are not stored; the response maps them to their original seq; accepted excludes them. Elements without client_event_id are never deduplicated — always send one. |
POST …/control, WS control | (session, control.id) | 200 { accepted: true, seq, duplicate: true }; no new event, no new audit row. |
POST …/dispatch | (session, control_id); server-generated dispatch:<uuid> when absent | 200 with duplicate: true; no push is re-sent. |
POST …/lease | device | Re-claiming your own lease refreshes expires_at. |
POST …/control/:cid/applied | — | Re-marking is a no-op 200. |
Optimistic append. expected_last_seq on an events body makes the append conditional: if the session's last_seq differs → 409 seq_conflict and nothing is written. Seq allocation happens in one transaction — last_seq is bumped by the count of fresh events and they are inserted as base+1 … base+n, so seq is dense and gap-free.
Dispatch
Start a task on a named device from any editor+ client. Only the owner's devices can hold the lease, so device_id must belong to the owner — anything else is 404 not_found (Target device not found for this session.). Pick the id from GET /v1/sessions/:id → members[].devices.
On POST …/dispatch the server persists a send control whose echoed event carries payload.control.target_device_id, audits it, and — if the target has a push token — sends an FCM HTTP v1 data-only, high-priority message { "type": "dispatch", "session_id", "control_id" }. Push is fail-open: a failure never fails the request; the control is durable either way. pushed: false with push_reason ∈ no_push_token | no_token | auth_failed | network_error | unregistered | http_<status> means queued, not delivered yet — not an error. On unregistered the server clears the device's push token.
Wake flow on the device: push → headless executor starts → GET /v1/devices/me/dispatch (or GET …/controls?applied=false&target=me, before holding the lease) → POST …/lease (force: true if policy allows) → execute the drained send → POST …/control/:cid/applied → normal loop. Dispatch never touches the lease.
Errors
{ "error": { "message": "Another device holds a live executor lease. (holder_device_id=1c2f…)", "type": "invalid_request_error", "code": "lease_held" } }details is included for bad_request (field-level validation output). For lease_held and seq_conflict the useful value is embedded in message; re-GET the session for the current last_seq.
| HTTP | code | When |
|---|---|---|
| 400 | bad_request | Invalid JSON, schema violation, missing/invalid x-codai-device, invalid ?target, bad id |
| 401 | invalid_api_key | Missing or invalid Bearer key |
| 403 | not_a_member | Session exists, you have no (sufficient) role |
| 403 | forbidden | Device id owned by another account; org role insufficient; not a member of the org |
| 404 | not_found | Unknown session / share / org / member / device / control; dispatch target not the owner's |
| 409 | lease_held | Claim, heartbeat, event append or applied-mark without the live lease |
| 409 | seq_conflict | expected_last_seq mismatch |
| 500 | internal_error | Storage failure — this surface is not fail-open |
WebSocket errors on up-messages use { v: 1, t: "error", ref?, error: {…} } and do include details.
Limits
| Limit | Value |
|---|---|
Events per POST …/events batch | 1 … 200 |
| Recommended client flush | ≤ 200 ms or 20 events |
Event kind | 1 … 64 chars |
turn_id, client_event_id, control id, ask_id | ≤ 128 chars |
Control / dispatch text | ≤ 100 000 chars |
session_key | ≤ 256 chars |
title | ≤ 500 chars |
Org name | 1 … 200 chars |
Replay per connect / GET …/events | limit ≤ 1000 (default 200) |
GET /v1/sessions limit | ≤ 200 (default 50) |
GET …/controls | ≤ 500 rows |
Device capabilities | ≤ 64 items × 64 chars |
| Device name | ≤ 120 chars |
| Push token | 8 … 4096 chars |
| Lease TTL / heartbeat | 30 s / 10 s |
| SSE ping / max connection | 15 s / 30 min |
| E2E recipients | ≤ 64 |
No per-route rate limit is specific to this surface; the gateway's general API-key limits apply.
Versioning
Frames carry v: 1. Additive fields are non-breaking; a new event kind is non-breaking (viewers ignore unknown kinds). Nothing listed here is removed or renamed within v1; deprecations are announced in the protocol repository's CHANGELOG.md before behaviour changes.
Conformance checklist
- Send
Authorization: Bearerand a stable UUID inx-codai-device(plus platform and name). - Ignore unknown frame types, event kinds and fields.
- Track the highest
seqseen and reconnect withafter=<seq>after a disconnect or the 30-minute cut. - Treat
holder_device_id: nullas "no executor". - Render
controlevents (payload.control,payload.applied) andpresenceframes. - Never put the API key in a URL.
- Use a unique control
idper intent and reuse it on retry. - Treat
200 duplicate: trueas success.
- Claim the lease before appending; stop the moment a heartbeat returns
409. - Heartbeat at least every 10 s.
- Send
client_event_idon every event; tolerateaccepted < events.length. - Drain
GET …/controls?applied=falseon start and mark each applied; skip controls with a foreigntarget_device_id. - On a dispatch wake, drain
target=mebefore claiming. - Release the lease on clean shutdown.
- Pick
device_idfromGET /v1/sessions/:id → members[].devices. - Supply its own
control_idwhen retrying. - Treat
pushed: falseas queued, not as failure.