codai docs
Sesiuni partajate

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_xxxxxxxx

Headere de dispozitiv

HeaderObligatoriuSemnificație
x-codai-deviceLa 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-platformRecomandatandroid · ios · web · desktop · cli · agent. Orice altceva (sau absent) se stochează ca agent.
x-codai-device-nameRecomandatEtichetă lizibilă, trunchiată la 120 de caractere. Implicit "<platform> device". Folosită doar la prima înregistrare.
x-codai-push-tokenOpționalToken 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-tokenCând exerciți un share prin linkToken-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" } }
}
  • seq este 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 este 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 și screen_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:

kindEmis cândPayload
controlUn control este acceptat{ "control": Control, "applied": false }
lease_transferredUn 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ă.

kindExecutorul ar trebui să…
sendPornească un turn nou cu text ca mesaj al utilizatorului.
answerRezolve ask-ul în așteptare identificat de ask_id cu text.
approve / denyLase confirmarea în așteptare (ask_id) să continue / o refuze.
cancelOprească turn-ul turn_id (sau pe cel curent).
steerInjecteze text ca ghidaj în turn-ul în curs fără să-l încheie.
injectAdauge 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

  1. owner când owner_user_id al sesiunii ești tu.
  2. 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.
  3. Altfel niciun rol → 403 not_a_member. Un id necunoscut este 404 not_found.
Acțiuneviewereditorowner
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/applieddoar 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ăCaleRol minimRequestRăspunsErori
POST/v1/sessionsorice 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 utilizatorlimit implicit 50, max 200 (aplicat separat listei proprii și celei partajate); archived=1 include arhivatele200 { sessions: [Session & { role, presence_count }] } — ale tale întâi, apoi cele partajate—
GET/v1/sessions/:idviewer—200 Session & { role, members[], lease: Lease | null, presence[] }403, 404
PATCH/v1/sessions/:idowner{ title?: string | null, archived?: bool } (≥ 1 câmp)200 Session400, 403, 404
DELETE/v1/sessions/:idowner—200 { deleted: true, id } — șterge în cascadă events, controls, lease, shares403, 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ăCaleRol minimRequestRăspunsErori
GET/v1/sessions/:id/events?after=&limit=viewerafter implicit 0; limit implicit 200, max 1000200 { last_seq, events: Event[] } crescător după seq403, 404
POST/v1/sessions/:id/eventsdeț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ăCaleRol minimRequestRăspunsErori
GET/v1/sessions/:id/controls?applied=&target=viewerapplied implicit false; target=me (necesită x-codai-device) sau un UUID de dispozitiv200 { controls: Control[] } (≤ 500, cele mai vechi întâi). Nu depinde de lease.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/applieddeținătorul lease-ului—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 cu duplicate: true400, 403, 404 (sesiune sau dispozitiv țintă)
GET/v1/devices/me/dispatch?limit=sesiuni propriilimit implicit 200, max 500; necesită x-codai-device200 { 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 apel400

Lease

MetodăCaleRol minimRequestRăspunsErori
POST/v1/sessions/:id/leaseowner{ 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/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

Dispozitive

MetodăCaleRequestRăspunsErori
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 Device400, 404
DELETE/v1/devices/:id—200 { deleted: true, id }400, 404

Semantica lease-ului

  • TTL 30 s, reînnoit prin PUT …/lease la fiecare 10 s. expires_at este returnat la preluare și la fiecare heartbeat.
  • Preluarea este compare-and-set. POST …/lease reuș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 în message ca (holder_device_id=…).
  • Force. { "force": true } (owner) ia un lease activ de la alt dispozitiv. Serverul adaugă un eveniment lease_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_held cu Lease expired. și fără deținător.
  • Eliberarea este idempotentă: released: false când nu îl dețineai.
  • Fiecare preluare/heartbeat/eliberare publică un frame lease și oglindește executor_device_id / lease_expires_at pe sesiune.
  • Adăugarea de evenimente sau marcarea unui control ca aplicat fără lease-ul activ → 409 lease_held.

Idempotență

OperațieCheieComportament la repetare
POST /v1/sessionssession_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ște200 cu duplicate: true; niciun push retrimis.
POST …/leasedispozitivRe-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.

HTTPcodeCând
400bad_requestJSON invalid, încălcare de schemă, x-codai-device lipsă/invalid, ?target invalid, id greșit
401invalid_api_keyCheie Bearer lipsă sau invalidă
403not_a_memberSesiunea există, nu ai niciun rol (suficient)
403forbiddenId de dispozitiv deținut de alt cont; rol de organizație insuficient; nu ești membru al organizației
404not_foundSesiune / share / organizație / membru / dispozitiv / control necunoscut; ținta dispatch-ului nu e a owner-ului
409lease_heldPreluare, heartbeat, adăugare de eveniment sau marcare applied fără lease-ul activ
409seq_conflictNepotrivire expected_last_seq
500internal_errorEș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 …/events1 … 200
Flush recomandat pe client≤ 200 ms sau 20 evenimente
kind de eveniment1 … 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ție1 … 200 caractere
Replay per conectare / GET …/eventslimit ≤ 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 push8 … 4096 caractere
TTL lease / heartbeat30 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

Pe această pagină