codai docs
Shared sessions

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_xxxxxxxx

Device headers

HeaderRequiredMeaning
x-codai-deviceOn 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-platformRecommendedandroid · ios · web · desktop · cli · agent. Anything else (or absent) is stored as agent.
x-codai-device-nameRecommendedHuman label, truncated to 120 chars. Default "<platform> device". Used only at first registration.
x-codai-push-tokenOptionalFCM 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-tokenWhen exercising a link sharePlaintext 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" } }
}
  • seq is 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 is turn_start, step_start, req_start, local_gen, tool_start, tool_call, tool_result, ask, ask_resolved, wait_user, deadline, error, usage, turn_end, plus assistant, user and the executor-emitted screen_wait.
  • ts: client clock; defaults to server time when omitted.
  • payload: any JSON object (default {}); { "hide": "<base64>" } in E2E mode.

Server-emitted kinds:

kindEmitted whenPayload
controlA control is accepted{ "control": Control, "applied": false }
lease_transferredA 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.

kindThe executor should…
sendStart a new turn with text as the user message.
answerResolve the pending ask identified by ask_id with text.
approve / denyLet the pending confirmation (ask_id) proceed / refuse it.
cancelStop the turn turn_id (or the current one).
steerInject text as guidance into the running turn without ending it.
injectAppend 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

  1. owner when the session's owner_user_id is you.
  2. Otherwise the maximum role over non-expired shares matching you directly, any org you belong to, or the link token you present.
  3. Otherwise no role → 403 not_a_member. An unknown id is 404 not_found.
Actionviewereditorowner
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/appliedlease 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

MethodPathMin roleRequestResponseErrors
POST/v1/sessionsany 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 userlimit default 50 max 200 (applied to own and shared lists separately); archived=1 includes archived200 { sessions: [Session & { role, presence_count }] } — own first, then shared—
GET/v1/sessions/:idviewer—200 Session & { role, members[], lease: Lease | null, presence[] }403, 404
PATCH/v1/sessions/:idowner{ title?: string | null, archived?: bool } (≥ 1 field)200 Session400, 403, 404
DELETE/v1/sessions/:idowner—200 { deleted: true, id } — cascades events, controls, lease, shares403, 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

MethodPathMin roleRequestResponseErrors
GET/v1/sessions/:id/events?after=&limit=viewerafter default 0; limit default 200 max 1000200 { last_seq, events: Event[] } ascending by seq403, 404
POST/v1/sessions/:id/eventslease 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

MethodPathMin roleRequestResponseErrors
GET/v1/sessions/:id/controls?applied=&target=viewerapplied default false; target=me (needs x-codai-device) or a device UUID200 { controls: Control[] } (≤ 500, oldest first). Not lease-gated.400, 403, 404
POST/v1/sessions/:id/controleditor{ 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/appliedlease holder—200 { id, applied: true }403, 404, 409 lease_held
POST/v1/sessions/:id/dispatcheditor{ 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: true400, 403, 404 (session or target device)
GET/v1/devices/me/dispatch?limit=own sessionslimit default 200 max 500; needs x-codai-device200 { device_id, sessions: [{ id, session_key, title, controls: Control[] }] } — every pending control targeted at this device across your non-archived sessions, one call400

Lease

MethodPathMin roleRequestResponseErrors
POST/v1/sessions/:id/leaseowner{ 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/leaseowner— (heartbeat)200 { session_id, device_id, expires_at }403, 404, 409 lease_held
DELETE/v1/sessions/:id/leaseowner—200 { session_id, released: bool }403, 404

Devices

MethodPathRequestResponseErrors
GET/v1/devices—200 { devices: Device[] } (yours, most recent first)—
PATCH/v1/devices/:id{ push_token?: string | null, capabilities?: string[] } (≥ 1 field)200 Device400, 404
DELETE/v1/devices/:id—200 { deleted: true, id }400, 404

Lease semantics

  • TTL 30 s, renewed by PUT …/lease every 10 s. expires_at is returned on claim and every heartbeat.
  • Claim is compare-and-set. POST …/lease succeeds 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 in message as (holder_device_id=…).
  • Force. { "force": true } (owner) takes a live lease from another device. The server appends a lease_transferred event 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_held with Lease expired. and no holder.
  • Release is idempotent: released: false when you did not hold it.
  • Every claim/heartbeat/release publishes a lease frame and mirrors executor_device_id / lease_expires_at onto the session.
  • Appending events or marking a control applied without the live lease → 409 lease_held.

Idempotency

OperationKeyRepeat behaviour
POST /v1/sessionssession_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 absent200 with duplicate: true; no push is re-sent.
POST …/leasedeviceRe-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.

HTTPcodeWhen
400bad_requestInvalid JSON, schema violation, missing/invalid x-codai-device, invalid ?target, bad id
401invalid_api_keyMissing or invalid Bearer key
403not_a_memberSession exists, you have no (sufficient) role
403forbiddenDevice id owned by another account; org role insufficient; not a member of the org
404not_foundUnknown session / share / org / member / device / control; dispatch target not the owner's
409lease_heldClaim, heartbeat, event append or applied-mark without the live lease
409seq_conflictexpected_last_seq mismatch
500internal_errorStorage failure — this surface is not fail-open

WebSocket errors on up-messages use { v: 1, t: "error", ref?, error: {…} } and do include details.

Limits

LimitValue
Events per POST …/events batch1 … 200
Recommended client flush≤ 200 ms or 20 events
Event kind1 … 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 name1 … 200 chars
Replay per connect / GET …/eventslimit ≤ 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 token8 … 4096 chars
Lease TTL / heartbeat30 s / 10 s
SSE ping / max connection15 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

On this page