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)
| Event | Payload | Ack |
|---|---|---|
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)
| Event | Payload |
|---|---|
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.
| Event | Payload |
|---|---|
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)
| Event | Payload | When |
|---|---|---|
room:update | full room state (§4) | membership / ready / upload / pick changes; at start |
chat:message | one 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:progress | progress 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:annotation | one annotation (§7) | an @align annotation command was applied |
dst:trace | debug trace (§8) | per user message — how it was routed / interpreted; may arrive twice (interim then final) |
intel:update | the 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 · Path | Request → Response |
|---|---|
POST /api/rooms/{code}/document | multipart 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/raw | PDF 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-pdf | application/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}/intel | the 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}/report | text/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}/export | the 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
- Render
chat:messageimmediately — the server never blocks messages on the LLM. - Treat
room:updateas authoritative full state (idempotent re-render). - Render the countdown ONLY from
timerevents — never a local clock. - Decision bubbles: render
actionsas buttons, sendchat:actionon click, disable + show attribution onchat:inject:update; dim personal bubbles (targetParticipant≠ self). - On reconnect, re-seat with
room:rejoinusing the storedparticipantId. - Dedupe messages by
id(ack history overlaps live broadcasts).
10 · Ordering guarantees the frontend depends on
room:create/room:joinack carries fullstate; aroom:updatemay arrive first — the client buffers it until the ack setsparticipantId.setup:startacks, then emitsroom:phase {phase: "chat"}, thenroom:update, then the kickoff moderator line.chat:stopemitsroom:phase {phase: "ended"}before acking.
Last updated on
Companion & Align for Mac
The private-beta Companion that lives in your Mac's notch, picks what needs you from your tools, keeps follow-ups both ways, moves tickets after you push code, and hands you the next step as one tap.
MCP server
Connect Align to ChatGPT, Claude, Cursor and other MCP clients. Endpoint, sign-in flow and tool reference.
