Docs

Protocol

The Socket.IO and REST wire contract between apps/rooms and apps/api — events, endpoints, and JSON shapes.

The contract between apps/rooms (lib/align/client.ts) and apps/api (execution/sio.py + api/routers/). Transport: Socket.IO (protocol v5 / EIO4) at /socket.io for realtime; HTTP REST at /api/* for uploads and the review record. Cross-origin — the API sets CORS from CORS_ORIGIN.

Error acks are always {"error": "<human sentence>"} — no codes. The string is shown to the user verbatim.


1 · Client → Server (Socket.IO)

Ack-returning (the handler returns a dict)

EventPayloadAck
room:create{name, goalType?, template?, durationMs?, maxParticipants?, dials?}{code, participantId, state}
room:join{code, name}{code, participantId, state} — allowed in setup and chat
room:rejoin{code, participantId}{state, participantId} — re-claim a seat after reconnect
room:state{}{state, participantId}
setup:start{}{ok: true} or {error} — host only; acks before room:phase
chat:review-complete{}{ok: true, sectionId} or {error} — casts my consensus vote for the section under discussion
chat:stop{}{ok: true} or {error} — host only; emits room:phase ended before acking
ai:mode{mode: "silent" | "assist" | "moderator" | "expert" | "critical"}{ok: true} or {error} — host only; how much the AI participates in a voice meeting (PRD §7/§24). Broadcasts room:update.

room:create also accepts voice: bool (request a LiveKit video room — silently downgraded to false when the server has no LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET) and aiMode (as above; default "assist"). goalType is optional and only "docreview" is accepted (v1 ships one goal type).

Fire-and-forget (the handler returns None — no ack)

EventPayload
setup:ready{ready: bool}
setup:pick-sections{sectionIds: string[]} — union of all picks = review scope
chat:message{text} (≤ 1000 chars). @align anywhere, or a leading /ask, summons the assistant
chat:action{messageId, action} — answer a decision bubble; action ∈ its actions[].id
doc:annotate{text, kind?: "note"|"comment"|"highlight"|"mark", sectionId?, anchor?: {page, xPct, yPct}} — place a note from the document viewer; broadcasts doc:annotation
intel:set-action{actionId, status: "open" | "done"} — toggle an action item
intel:set-decision{decisionId, status: "approved" | "rejected" | "pending"} — override a decision's status
voice:interrupt{} — cut Align's TTS off mid-sentence (PRD §15); relayed to the bot as voice:interrupt. No-op unless voice.enabled.
voice:mute-align{muted: bool} — disable/enable the AI voice only; text moderator lines and the intel panels are unaffected. Sets voice.muted, broadcasts room:update.
voice:hand{raised: bool} — raise / lower my hand (PRD §6, "Ask Align"). Updates hands (§4), broadcasts room:update.

intel:refresh ({} → {ok: true}) forces an immediate re-analysis.

The voice events are additive and fail-soft: every handler no-ops on a room with voice.enabled = false, so a text-only room is unaffected.

Bot → Server (Socket.IO — the voice-bot subprocess only, never a browser)

The Pipecat bot is just another Socket.IO client. It authenticates with bot:attach {code, secret} ({ok: true} / {error}; secret = VOICE_BOT_SECRET, server-side only) and then lives in the bot:{code} room.

EventPayload
voice:transcript{participantId, text} — a finalized STT utterance; ingested exactly like a typed chat:message from that participant (carries via: "voice", §5)
voice:interim{participantId, text} — a live non-final caption; re-broadcast to the room as voice:caption and nothing else
voice:bot-state{speaking: bool, utteranceId?, text?} — BotStarted/StoppedSpeakingFrame relayed up; sets voice.speaking and broadcasts align:floor

2 · Server → Client (Socket.IO room broadcasts)

EventPayloadWhen
room:updatefull room state (§4)membership / ready / upload / pick changes; at start
chat:messageone message (§5)every message — user, moderator, system, inject
chat:inject:update{messageId, votes: [{by, byName, action, at}], totalMembers?, resolved: null | {action, by, byName, at}}a decision bubble was answered. confirm-section "Yes" is a per-member vote — partial updates carry resolved: null + totalMembers until everyone connected has voted; "Continue reviewing" resolves on the first click
timer{remainingMs, durationMs}every second during chat
goal:progressprogress snapshot (§6)after every goal-state mutation
room:phase{phase: "chat" | "ended", reason?: "goal" | "timeout" | "stopped"}phase transitions
topic:summary{sectionId, title?, seq, source: "monolith", ref: {kind, sectionTitle | messageIndex}, text}lane B — a section matured and was summarised
doc:annotationone annotation (§7)an @align annotation command was applied
dst:tracedebug trace (§8)per user message — how it was routed / interpreted; may arrive twice (interim then final)
intel:updatethe decisions / risks / actions ledger (§8b)debounced re-analysis of the room (every ~4 messages, on a section move, on intel:refresh, at session end)
align:floor{speaking: null | {utteranceId?, text?, at}}the bot started / stopped speaking — drives the AI-floor-control UI (PRD §15)
voice:caption{participantId, name, text}a live (non-final) STT caption; ephemeral, never persisted or replayed on reconnect

connect / disconnect are the Socket.IO built-ins. On every connect the client auto-fires room:rejoin if sessionStorage["align.session"] holds {code, participantId}.


3 · HTTP REST

Method · PathRequest → Response
POST /api/rooms/{code}/documentmultipart file (15 MB, setup only). PDF → {ok, title, sections: <count>, source}. Outlook thread → {ok, title, emails, topicsPending: true} (topics arrive later via a system message + room:update). Image → 422 {error, ocrPlaceholder: true}. .msg → 422. Every success also emits a system chat:message + room:update.
GET /api/rooms/{code}/document/rawPDF bytes (application/pdf) or {kind: "email", title, messages: [{from, subject?, body}]} — content-type switched. 409 if the document wasn't retained.
GET /api/rooms/{code}/annotated-pdfapplication/pdf download — the review record. 409 if no sections were selected or the source wasn't retained.
GET /api/health{ok, llm, model, knowledgeGraph}
GET /api/templates{templates: [{id, label, checklist: [str]}]} — the review lenses (general / contract / prd / policy / investment-memo / compliance)
GET /api/rooms/{code}/intelthe decisions/risks/actions ledger (§8b) — catch-up
GET /api/rooms/{code}/voice/token?participantId=…{serverUrl, roomName, token} — a per-participant LiveKit join token, minted on demand once the browser is ready to join. 404 unknown room, 403 not a participant, 409 room has no video, 502 the token couldn't be minted.
GET /api/rooms/{code}/reporttext/markdown — the AI-generated final report
GET /api/rooms/history{sessions: [{name, code, title, template, score, coverage, decisions, risks, actions, participants, capturedAt}]} — newest first
GET /api/rooms/history/{name}one archived session, full
GET /api/admin/rooms/{code}/exportthe room in the capture-fixture shape (fixtures/rooms/*.json)
POST /api/admin/rooms/{code}/seed{doc?: {title, sections}, ragChunks?, ragLabel?} — rebuild a setup-phase room for replay. 409 once started.

4 · Room state (room:update, and state in acks)

{
  "code": "K7PM",
  "goalType": "docreview",
  "template": "contract",     // general | contract | prd | policy | investment-memo | compliance
  "phase": "setup" | "chat" | "ended",
  "endedReason": null | "goal" | "timeout" | "stopped",
  "durationMs": 240000, "remainingMs": 231000,
  "maxParticipants": 3, "hostId": "p_…",
  "dials": { "stakes": "$10k – $250k", … },          // sanitized
  "participants": [{ "id": "p_…", "name": "Priya", "ready": true, "connected": true }],
  "doc": {
    "title": "Vendor MSA" | null,
    "sections": [{ "id": "sec_…", "title": "Termination", "preview": "…" }],
    "selectedSections": [ … ],                        // union of picks, set at start
    "picks": { "p_…": ["sec_…"] },
    "sha256": null
  },
  "messages": [ /* §5, capped at 500 */ ],
  "progress": { /* §6 */ },
  "llm": { "available": true, "model": "openai/gpt-oss-120b" },

  // meeting layer (PRD §7-8/§15/§24) — inert unless `voice.enabled`
  "voice": {
    "enabled": false,           // true only when the room was created with voice AND the server has LIVEKIT_* set
    "roomName": null,           // the LiveKit room name; the browser mints its own token (§3)
    "botReady": false,         // a voice-bot subprocess has attached
    "muted": false,           // AI voice muted (voice:mute-align) — text + intel unaffected
    "speaking": null          // null | { utteranceId?, text?, at } — set from voice:bot-state, mirrors align:floor
  },
  "aiMode": "assist",           // silent | assist | moderator | expert | critical (ai:mode)
  "hands": []                   // participantIds with a raised hand, in raise order (voice:hand)
}

Email-derived section ids are prefixed topic_ (the frontend keys email-vs-PDF off that).

5 · Message (chat:message)

{
  "id": "m_…", "at": 1788853171000,                  // epoch ms
  "type": "user" | "moderator" | "system" | "inject",
  "text": "…",
  "via": "voice",                                     // present only when the line arrived over STT (voice:transcript)
  // user:
  "senderId": "p_…", "senderName": "Priya",
  // moderator:
  "trigger": "kickoff" | "time50" | "time75" | "time90" | "idle" | "progress"
           | "answer" | "annotate" | "complete" | "timeout" | "stopped",
  // inject (decision bubble):
  "kind": "confirm-section",
  "actions": [{ "id": "yes", "label": "Yes ✓" }, { "id": "continue", "label": "Continue reviewing" }],
  "sectionId": "sec_…",
  "targetParticipant": null,                          // non-null ⇒ personal; disable for others
  "resolved": null | { "action", "by", "byName", "at" },
  "votes": { "p_…": { "by", "byName", "action", "at" } }   // object here; array on chat:inject:update
}

6 · Progress snapshot (goal:progress) — frozen shape

{
  "goalType": "docreview",
  "sections": [{ "sectionId", "title", "status": "pending" | "discussing" | "confirmed", "messageCount" }],
  "doneCount": 1, "totalCount": 3, "complete": false
}

7 · Annotation (doc:annotation)

{
  "id": "ann_1_…", "at": 1788…, "seq": 1,
  "kind": "mark" | "highlight" | "comment" | "note",
  "text": "the termination window is too short",
  "sectionId": "sec_…", "sectionTitle": "Termination",
  "by": "p_…", "byName": "Priya",
  "colour": "butter", "hex": "#FFF3B0", "ink": "#7a6a12",   // by join order
  "anchor": null | { "page": 0, "xPct": 0.42, "yPct": 0.18 },  // viewer-placed pin
  "evidence": ["…verbatim excerpt the anchor landed on…"]
}

8 · Debug trace (dst:trace) — observability only, never affects state

{
  "messageId": "m_…", "text": "…≤80", "senderName": "Priya", "at": 1788…,
  // normal message — both pipeline stages:
  "lpp": { "intent", "entities": [names], "effects", "tookMs" },
  "llm": { "pending": true }
       | { "skipped": "busy" | "model-down" | "lexical-resolved" | "nlu-failed" | "session-ended" }
       | { "intent", "entities": [accepted], "claimed": [raw model output],
           "rejected": [dropped by the anchor gate], "effects", "tookMs" },
  // OR an @h summon:
  "summon": { "route": "rag" | "persona" | "annotate" | "scope", "question" }
}

8b · Decisions / Risks / Actions ledger (intel:update)

{
  "seq": 3, "updatedAt": 1788…,
  "coverage": 42,      // % of the review scope meaningfully covered (deterministic)
  "score": 61,         // review-quality score 0–100 (deterministic, explainable)
  "summary": "…executive summary…",
  "decisions": [{
    "id", "text", "status": "approved" | "rejected" | "pending",
    "rationale"?, "sectionId"?, "sectionTitle"?,
    "evidence": ["…verbatim doc excerpt…"], "messageIds": ["m_…"],
    "by"?, "at", "source": "vote" | "ai"       // "vote" = a unanimous section confirm
  }],
  "risks": [{
    "id", "kind": "contradiction" | "missing-info" | "risk",
    "text", "severity": "low" | "medium" | "high",
    "sectionId"?, "sectionTitle"?, "evidence": [str], "messageIds": [str]
  }],
  "actions": [{ "id", "text", "owner": str|null, "due": str|null,
               "status": "open" | "done", "messageIds": [str] }]
}

Ids are stable across re-runs (matched by text similarity); a status set via intel:set-action / intel:set-decision survives the next AI re-analysis. Catch-up: GET /api/rooms/{code}/intel.

9 · Client obligations

  1. Render chat:message immediately — the server never blocks messages on the LLM.
  2. Treat room:update as authoritative full state (idempotent re-render).
  3. Render the countdown ONLY from timer events — never a local clock.
  4. Decision bubbles: render actions as buttons, send chat:action on click, disable + show attribution on chat:inject:update; dim personal bubbles (targetParticipant ≠ self).
  5. On reconnect, re-seat with room:rejoin using the stored participantId.
  6. Dedupe messages by id (ack history overlaps live broadcasts).

10 · Ordering guarantees the frontend depends on

  • room:create / room:join ack carries full state; a room:update may arrive first — the client buffers it until the ack sets participantId.
  • setup:start acks, then emits room:phase {phase: "chat"}, then room:update, then the kickoff moderator line.
  • chat:stop emits room:phase {phase: "ended"} before acking.

Last updated on

On this page