# Protocol reference (/docs/sessions/protocol)

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](https://github.com/codai-ro/codai-protocol). Realtime frames are on the [streaming page](./streaming.mdx); shares and orgs have [their own page](./sharing-and-orgs.mdx).

## Transport and headers [#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.

```http
Authorization: Bearer codai_xxxxxxxx
```

### Device headers [#device-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 [#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 [#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 [#objects]

### Session [#session]

```json
{
  "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 [#device]

```json
{
  "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",
  "online": true
}
```

`capabilities` is a free list of ≤ 64 strings of ≤ 64 chars. The push token is **never returned** — clients see `has_push_token`. `online` (on `GET /v1/devices`) is `true` when `lastSeenAt` is within the last 2 minutes. The two camelCase timestamps on this object are part of v1 and will not be renamed.

### Event [#event]

```json
{
  "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` and `todos`.
- `todos` carries a full snapshot of the agent's todo list (the last one wins): `{"items":[{"id":"1","title":"…","status":"not-started|in-progress|completed"}],"source":"tool|user","tool_call_id":"call_…","done":1,"total":3}`. At most 50 items; `tool_call_id` is present only when `source` is `tool`. A device that follows the session replaces its todo panel with it; it is not a transcript row.
- `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 [#control]

```json
{
  "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 | pause | resume`. `id` is the **client idempotency key** (1 – 128 chars); `text` ≤ 100 000 chars. `target_device_id` is set only by [dispatch](#dispatch) and is `null` otherwise; a targeted control is meant for that one device and every other executor should skip it. A `mode: "open"` dispatch adds `"mode": "open"` to its (text-less) `send` control; the field is absent everywhere else.

| 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). |
| `pause` | Park the running turn at its next step boundary (after the current tool calls finish). The turn stays open; the executor emits `run_state` `{"state":"paused"}`. Editors may send it (same trust as `cancel`). |
| `resume` | Continue the paused turn — the same turn, nothing is re-sent. The executor emits `run_state` `{"state":"running"}`. |
| `steer` | Inject `text` as guidance into the running turn without ending it. |
| `inject` | Append `text` to the context for the next model call. |

### Lease [#lease]

```json
{ "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 [#presence]

```json
{ "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 [#share-and-org]

See [sharing and orgs](./sharing-and-orgs.mdx). 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 [#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`.

| 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 [#http-routes]

Every mutation follows *validate → authenticate → authorize → audit → execute*. A failed validation is `400 bad_request` with `details` describing the fields.

### Sessions [#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 [#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 [#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** (or the target of an `open` handoff) | — | `200 { id, applied: true }` | 403, 404, 409 `lease_held` |
| `POST` | `/v1/sessions/:id/dispatch` | editor | `{ device_id: uuid, mode?: "send" \| "open", text?: string 1..100000, turn_id?, control_id? }` — `text` required unless `mode: "open"` | `202 { control_id, seq, target_device_id, mode, 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 [#lease-1]

| 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 [#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 [#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 [#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 [#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.

**Modes.** `mode` defaults to `send`, which requires `text` and runs it as a new turn. `mode: "open"` is a **handoff**: the target only opens the session. `text` and `turn_id` are ignored, no user turn is created, and the control is a text-less `send` carrying `mode: "open"` (in `GET …/controls`, `GET /v1/devices/me/dispatch`, the echoed event and the FCM data). The target may mark an open handoff applied **without** holding the lease. The response echoes the effective `mode`.

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 [#errors]

```json
{ "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 [#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 [#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 [#conformance-checklist]

<Accordions>
<Accordion title="A viewer MUST">

- Send `Authorization: Bearer` and a stable UUID in `x-codai-device` (plus platform and name).
- Ignore unknown frame types, event kinds and fields.
- Track the highest `seq` seen and reconnect with `after=<seq>` after a disconnect or the 30-minute cut.
- Treat `holder_device_id: null` as "no executor".
- Render `control` events (`payload.control`, `payload.applied`) and `presence` frames.
- Never put the API key in a URL.

</Accordion>
<Accordion title="An editor MUST also">

- Use a unique control `id` per intent and reuse it on retry.
- Treat `200 duplicate: true` as success.

</Accordion>
<Accordion title="An executor MUST also">

- Claim the lease before appending; stop the moment a heartbeat returns `409`.
- Heartbeat at least every 10 s.
- Send `client_event_id` on every event; tolerate `accepted < events.length`.
- Drain `GET …/controls?applied=false` on start and mark each applied; skip controls with a foreign `target_device_id`.
- On a dispatch wake, drain `target=me` **before** claiming.
- Release the lease on clean shutdown.

</Accordion>
<Accordion title="A dispatching agent MUST also">

- Pick `device_id` from `GET /v1/sessions/:id → members[].devices`.
- Supply its own `control_id` when retrying.
- Treat `pushed: false` as queued, not as failure.

</Accordion>
</Accordions>
