Referința protocolului
Shared Sessions v1 — obiecte, headere, fiecare rută HTTP, regulile lease-ului, idempotență, erori și limite.
Aceasta este suprafața normativă a codai Shared Sessions v1.0.0 așa cum este servită de https://ai.codai.ro. Numele câmpurilor sunt snake_case pe fir; timestamp-urile numite *_at sunt string-uri ISO-8601; ts și last_seen sunt milisecunde întregi de la epoca Unix. JSON Schema-urile pentru fiecare obiect stau în repository-ul protocolului. Frame-urile realtime sunt pe pagina de streaming; share-urile și organizațiile au pagina lor.
Transport și headere
Toate rutele sunt HTTPS sub URL-ul de bază al gateway-ului și sunt JSON dacă nu se spune altfel. Autentificarea este o cheie API Bearer; principalul este utilizatorul căruia îi aparține cheia.
Authorization: Bearer codai_xxxxxxxxHeadere de dispozitiv
| Header | Obligatoriu | Semnificație |
|---|---|---|
x-codai-device | La fiecare scriere, lease, stream și WebSocket; opțional la citiri. | Un UUID ales de client, stabil per instalare. Înregistrat lazy sub utilizatorul apelantului la prima apariție; last_seen_at actualizat la fiecare request. Orice nu este UUID primește 400. |
x-codai-device-platform | Recomandat | android · ios · web · desktop · cli · agent. Orice altceva (sau absent) se stochează ca agent. |
x-codai-device-name | Recomandat | Etichetă lizibilă, trunchiată la 120 de caractere. Implicit "<platform> device". Folosită doar la prima înregistrare. |
x-codai-push-token | Opțional | Token de înregistrare FCM, 8 – 4096 caractere ASCII, salvat (upsert) pe dispozitiv la orice request HTTP care poartă x-codai-device. O valoare malformată este ignorată silențios. |
x-codai-share-token | Când exerciți un share prin link | Token-ul de link share în clar. Alternativă: ?share=<token> (pentru SSE/WS din browsere). |
Un id de dispozitiv deja înregistrat la alt utilizator este refuzat cu 403 forbidden — Device id is registered to another account. — nu refolosit.
Selectarea v2 pe căi suprapuse
GET /v1/sessions și GET /v1/sessions/:id împart căile cu un API legacy de memorie read-only. Handler-ul de sesiuni partajate este selectat când request-ul poartă x-codai-device sau ?v=2. Trimite mereu x-codai-device; ?v=2 este pentru citiri rapide fără dispozitiv.
Referințe la sesiune
Oriunde o cale conține :id poți trimite fie id-ul UUID de server, fie propriul session_key. Când un session_key se ciocnește între utilizatori, sesiunea ta câștigă.
Obiecte
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 este ales de client (telefonul folosește același UUID pe care îl trimite ca x-codai-session-id la inferență); id este atribuit de server. Răspunsurile de listare adaugă role (al tău) și presence_count; răspunsul de detaliu adaugă role, members[], lease și presence[]; shared-with-me adaugă role și 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 este o listă liberă de ≤ 64 string-uri de ≤ 64 caractere. Token-ul push nu este returnat niciodată — clienții văd has_push_token. Cele două timestamp-uri camelCase de pe acest obiect fac parte din v1 și nu vor fi redenumite.
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" } }
}seqeste atribuit de server, dens per sesiune, începând de la 1. Clienții nu îl trimit niciodată.kind: 1 – 64 caractere, opac pentru server; kind-urile necunoscute nu sunt respinse niciodată. Vocabularul de trace al executorului esteturn_start, step_start, req_start, local_gen, tool_start, tool_call, tool_result, ask, ask_resolved, wait_user, deadline, error, usage, turn_end, plusassistant,userșiscreen_wait, emis de executor.ts: ceasul clientului; implicit ora serverului când lipsește.payload: orice obiect JSON (implicit{});{ "hide": "<base64>" }în modul E2E.
Kind-uri emise de server:
| kind | Emis când | Payload |
|---|---|---|
control | Un control este acceptat | { "control": Control, "applied": false } |
lease_transferred | Un lease este preluat forțat de la un deținător activ | { "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 este cheia de idempotență a clientului (1 – 128 caractere); text ≤ 100 000 caractere. target_device_id este setat doar de dispatch și este null altfel; un control țintit este destinat acelui singur dispozitiv și orice alt executor ar trebui să-l sară.
| kind | Executorul ar trebui să… |
|---|---|
send | Pornească un turn nou cu text ca mesaj al utilizatorului. |
answer | Rezolve ask-ul în așteptare identificat de ask_id cu text. |
approve / deny | Lase confirmarea în așteptare (ask_id) să continue / o refuze. |
cancel | Oprească turn-ul turn_id (sau pe cel curent). |
steer | Injecteze text ca ghidaj în turn-ul în curs fără să-l încheie. |
inject | Adauge text la context pentru următorul apel de model. |
Lease
{ "session_id": "…-uuid", "device_id": "…-uuid", "expires_at": "2026-09-13T10:04:40.000Z" }TTL 30 s; heartbeat la fiecare 10 s. Frame-urile lease realtime folosesc holder_device_id (nullable) în loc de device_id.
Presence
{ "device_id": "…-uuid", "user_id": "…-uuid", "role": "editor", "executor": false, "driving": false, "last_seen": 1757757852512, "online": true, "remote": true }Efemer, niciodată persistat. remote este calculat pentru apelant și apare doar în GET /v1/sessions/:id; online apare doar în frame-urile presence (false pe frame-ul de plecare).
Share și Org
Vezi partajare și organizații. 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 o singură dată). Org: { id, name, ownerUserId, createdAt } — camelCase pe fir — cu roluri de membru owner | admin | member.
Roluri
ownercândowner_user_idal sesiunii ești tu.- Altfel rolul maxim peste share-urile neexpirate care ți se potrivesc direct, oricărei organizații din care faci parte sau token-ului de link pe care îl prezinți.
- Altfel niciun rol →
403 not_a_member. Un id necunoscut este404 not_found.
| Acțiune | 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 | doar deținătorul lease-ului (în practică un dispozitiv al owner-ului) |
Rute HTTP
Fiecare mutație urmează validare → autentificare → autorizare → audit → execuție. O validare eșuată este 400 bad_request cu details care descrie câmpurile.
Sesiuni
| Metodă | Cale | Rol minim | Request | Răspuns | Erori |
|---|---|---|---|---|---|
POST | /v1/sessions | orice utilizator | { session_key?: string ≤256, title?: string ≤500, e2e?: bool }. session_key implicit un UUID aleator. | 201 Session (nouă) · 200 Session (session_key existent) | 400 |
GET | /v1/sessions?v=2&limit=&archived= | orice utilizator | limit implicit 50, max 200 (aplicat separat listei proprii și celei partajate); archived=1 include arhivatele | 200 { sessions: [Session & { role, presence_count }] } — ale tale întâi, apoi cele partajate | — |
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 câmp) | 200 Session | 400, 403, 404 |
DELETE | /v1/sessions/:id | owner | — | 200 { deleted: true, id } — șterge în cascadă events, controls, lease, shares | 403, 404 |
members[] = { user_id, role, remote, devices: [{ device_id, name, platform, last_seen_at, remote }] }, o intrare per utilizator membru (owner, share-uri directe către utilizatori, membrii share-urilor către organizații și tu, dacă ai venit printr-un link).
Evenimente
| Metodă | Cale | Rol minim | Request | Răspuns | Erori |
|---|---|---|---|---|---|
GET | /v1/sessions/:id/events?after=&limit= | viewer | after implicit 0; limit implicit 200, max 1000 | 200 { last_seq, events: Event[] } crescător după seq | 403, 404 |
POST | /v1/sessions/:id/events | deținătorul lease-ului | { 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? } — niciodată seq. accepted numără evenimentele stocate nou; duplicatele (după client_event_id) sunt raportate cu seq-ul lor original.
Controls și dispatch
| Metodă | Cale | Rol minim | Request | Răspuns | Erori |
|---|---|---|---|---|---|
GET | /v1/sessions/:id/controls?applied=&target= | viewer | applied implicit false; target=me (necesită x-codai-device) sau un UUID de dispozitiv | 200 { controls: Control[] } (≤ 500, cele mai vechi întâi). Nu depinde de lease. | 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 | deținătorul lease-ului | — | 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 cu duplicate: true | 400, 403, 404 (sesiune sau dispozitiv țintă) |
GET | /v1/devices/me/dispatch?limit= | sesiuni proprii | limit implicit 200, max 500; necesită x-codai-device | 200 { device_id, sessions: [{ id, session_key, title, controls: Control[] }] } — fiecare control în așteptare țintit către acest dispozitiv din toate sesiunile tale nearhivate, într-un singur apel | 400 |
Lease
| Metodă | Cale | Rol minim | Request | Răspuns | Erori |
|---|---|---|---|---|---|
POST | /v1/sessions/:id/lease | owner | { device_id?: uuid, force?: bool } (body gol permis; device_id implicit 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 |
Dispozitive
| Metodă | Cale | Request | Răspuns | Erori |
|---|---|---|---|---|
GET | /v1/devices | — | 200 { devices: Device[] } (ale tale, cele mai recente întâi) | — |
PATCH | /v1/devices/:id | { push_token?: string | null, capabilities?: string[] } (≥ 1 câmp) | 200 Device | 400, 404 |
DELETE | /v1/devices/:id | — | 200 { deleted: true, id } | 400, 404 |
Semantica lease-ului
- TTL 30 s, reînnoit prin
PUT …/leasela fiecare 10 s.expires_ateste returnat la preluare și la fiecare heartbeat. - Preluarea este compare-and-set.
POST …/leasereușește când lease-ul este liber, expirat sau deja deținut de dispozitivul apelant. Dacă alt dispozitiv deține un lease activ →409 lease_held; deținătorul este numit înmessageca(holder_device_id=…). - Force.
{ "force": true }(owner) ia un lease activ de la alt dispozitiv. Serverul adaugă un evenimentlease_transferredși îl auditează. Fostul deținător află la următorul heartbeat (409 lease_held, Lease lost to another device.) și trebuie să oprească execuția, să devină viewer și să arate cine conduce. - Heartbeat pe un lease expirat este
409 lease_heldcu Lease expired. și fără deținător. - Eliberarea este idempotentă:
released: falsecând nu îl dețineai. - Fiecare preluare/heartbeat/eliberare publică un frame
leaseși oglindeșteexecutor_device_id/lease_expires_atpe sesiune. - Adăugarea de evenimente sau marcarea unui control ca aplicat fără lease-ul activ →
409 lease_held.
Idempotență
| Operație | Cheie | Comportament la repetare |
|---|---|---|
POST /v1/sessions | session_key (per utilizator) | 200 cu sesiunea existentă în loc de 201. Un session_key egal cu id-ul UUID al unei sesiuni existente se rezolvă tot la acea sesiune. |
POST …/events | (session, sender_device, client_event_id) | Duplicatele nu sunt stocate; răspunsul le mapează la seq-ul lor original; accepted le exclude. Elementele fără client_event_id nu sunt deduplicate niciodată — trimite mereu unul. |
POST …/control, control WS | (session, control.id) | 200 { accepted: true, seq, duplicate: true }; niciun eveniment nou, niciun rând nou de audit. |
POST …/dispatch | (session, control_id); dispatch:<uuid> generat de server când lipsește | 200 cu duplicate: true; niciun push retrimis. |
POST …/lease | dispozitiv | Re-preluarea propriului lease reîmprospătează expires_at. |
POST …/control/:cid/applied | — | Re-marcarea este un no-op 200. |
Append optimist. expected_last_seq pe un body de evenimente face adăugarea condiționată: dacă last_seq al sesiunii diferă → 409 seq_conflict și nu se scrie nimic. Alocarea de seq se face într-o singură tranzacție — last_seq este incrementat cu numărul de evenimente noi și acestea sunt inserate ca base+1 … base+n, așa că seq este dens și fără goluri.
Dispatch
Pornește o sarcină pe un dispozitiv anume din orice client editor+. Doar dispozitivele owner-ului pot deține lease-ul, așa că device_id trebuie să aparțină owner-ului — orice altceva este 404 not_found (Target device not found for this session.). Alege id-ul din GET /v1/sessions/:id → members[].devices.
La POST …/dispatch serverul persistă un control send al cărui eveniment-ecou poartă payload.control.target_device_id, îl auditează și — dacă ținta are token push — trimite un mesaj FCM HTTP v1 data-only, high-priority { "type": "dispatch", "session_id", "control_id" }. Push-ul este fail-open: un eșec nu face niciodată request-ul să eșueze; controlul este durabil oricum. pushed: false cu push_reason ∈ no_push_token | no_token | auth_failed | network_error | unregistered | http_<status> înseamnă pus în coadă, nelivrat încă — nu o eroare. La unregistered serverul șterge token-ul push al dispozitivului.
Fluxul de trezire pe dispozitiv: push → pornește executorul headless → GET /v1/devices/me/dispatch (sau GET …/controls?applied=false&target=me, înainte de a deține lease-ul) → POST …/lease (force: true dacă politica permite) → execută send-ul consumat → POST …/control/:cid/applied → bucla normală. Dispatch-ul nu atinge niciodată lease-ul.
Erori
{ "error": { "message": "Another device holds a live executor lease. (holder_device_id=1c2f…)", "type": "invalid_request_error", "code": "lease_held" } }details este inclus pentru bad_request (rezultatul validării la nivel de câmp). Pentru lease_held și seq_conflict valoarea utilă este încorporată în message; re-GET sesiunea pentru last_seq-ul curent.
| HTTP | code | Când |
|---|---|---|
| 400 | bad_request | JSON invalid, încălcare de schemă, x-codai-device lipsă/invalid, ?target invalid, id greșit |
| 401 | invalid_api_key | Cheie Bearer lipsă sau invalidă |
| 403 | not_a_member | Sesiunea există, nu ai niciun rol (suficient) |
| 403 | forbidden | Id de dispozitiv deținut de alt cont; rol de organizație insuficient; nu ești membru al organizației |
| 404 | not_found | Sesiune / share / organizație / membru / dispozitiv / control necunoscut; ținta dispatch-ului nu e a owner-ului |
| 409 | lease_held | Preluare, heartbeat, adăugare de eveniment sau marcare applied fără lease-ul activ |
| 409 | seq_conflict | Nepotrivire expected_last_seq |
| 500 | internal_error | Eșec de stocare — această suprafață nu este fail-open |
Erorile WebSocket pe mesajele up folosesc { v: 1, t: "error", ref?, error: {…} } și includ details.
Limite
| Limită | Valoare |
|---|---|
Evenimente per batch POST …/events | 1 … 200 |
| Flush recomandat pe client | ≤ 200 ms sau 20 evenimente |
kind de eveniment | 1 … 64 caractere |
turn_id, client_event_id, id de control, ask_id | ≤ 128 caractere |
text de control / dispatch | ≤ 100 000 caractere |
session_key | ≤ 256 caractere |
title | ≤ 500 caractere |
name de organizație | 1 … 200 caractere |
Replay per conectare / GET …/events | limit ≤ 1000 (implicit 200) |
limit la GET /v1/sessions | ≤ 200 (implicit 50) |
GET …/controls | ≤ 500 rânduri |
capabilities de dispozitiv | ≤ 64 elemente × 64 caractere |
| Nume de dispozitiv | ≤ 120 caractere |
| Token push | 8 … 4096 caractere |
| TTL lease / heartbeat | 30 s / 10 s |
| Ping SSE / conexiune maximă | 15 s / 30 min |
| Destinatari E2E | ≤ 64 |
Nu există rate limit per rută specific acestei suprafețe; se aplică limitele generale per cheie API ale gateway-ului.
Versionare
Frame-urile poartă v: 1. Câmpurile adăugate nu sunt breaking; un kind nou de eveniment nu este breaking (viewerii ignoră kind-urile necunoscute). Nimic din ce este listat aici nu este eliminat sau redenumit în cadrul v1; deprecierile sunt anunțate în CHANGELOG.md din repository-ul protocolului înainte ca comportamentul să se schimbe.
Checklist de conformitate
- Să trimită
Authorization: Bearerși un UUID stabil înx-codai-device(plus platformă și nume). - Să ignore tipurile de frame, kind-urile de eveniment și câmpurile necunoscute.
- Să urmărească cel mai mare
seqvăzut și să se reconecteze cuafter=<seq>după o deconectare sau după tăierea de 30 de minute. - Să trateze
holder_device_id: nullca „fără executor”. - Să afișeze evenimentele
control(payload.control,payload.applied) și frame-urilepresence. - Să nu pună niciodată cheia API într-un URL.
- Să folosească un
idde control unic per intenție și să-l refolosească la retry. - Să trateze
200 duplicate: trueca succes.
- Să preia lease-ul înainte de a adăuga; să se oprească în clipa în care un heartbeat returnează
409. - Să facă heartbeat cel puțin la fiecare 10 s.
- Să trimită
client_event_idpe fiecare eveniment; să tolerezeaccepted < events.length. - Să consume
GET …/controls?applied=falsela pornire și să marcheze fiecare ca aplicat; să sară controls cu untarget_device_idstrăin. - La o trezire prin dispatch, să consume
target=meînainte de a prelua lease-ul. - Să elibereze lease-ul la o oprire curată.
- Să aleagă
device_iddinGET /v1/sessions/:id → members[].devices. - Să furnizeze propriul
control_idla retry. - Să trateze
pushed: falseca pus în coadă, nu ca eșec.
Sesiuni partajate
O conversație, mai multe dispozitive — gateway-ul păstrează jurnalul ordonat de evenimente; dispozitivul cu uneltele execută; toți ceilalți urmăresc sau ghidează.
Streaming de sesiuni
Urmărește o sesiune live prin SSE sau WebSocket, reia de la orice seq și scrie idempotent, ca un retry să nu dubleze niciodată.