Realtime: WebSocket, SSE & Live Canvas
The full realtime surface: WS chat protocol, terminal, Live Canvas (A2UI), nodes, and the SSE event/log streams.
The Revka gateway carries every interactive, server-pushed interaction over two realtime transports: WebSocket for bidirectional sessions and Server-Sent Events (SSE) for one-way broadcast streams. This page is the frame-by-frame reference for all of them — the /ws/chat agent protocol and its error codes, the PTY terminal, the Live Canvas (A2UI) system, the dynamic node registry, and the two SSE streams that feed the dashboard’s live views.
Read this if you are building a custom client, an external integration, or an edge device that talks to the gateway in real time. For the transport map and how these fit alongside REST, start at the Gateway API overview. For how a bearer token is minted, see Pairing & authentication.
Authenticating a WebSocket
Section titled “Authenticating a WebSocket”Every /ws/* endpoint requires a bearer token when pairing is enabled. Browsers cannot set an Authorization header on new WebSocket(), so the gateway extracts the token from three sources, in this precedence order:
Authorization: Bearer <token>header — for non-browser clientsSec-WebSocket-Protocol: bearer.<token>subprotocol — browser-compatible?token=<token>query parameter — browser-compatible fallback
// Browser: pass the token as a subprotocol alongside the channel subprotocol.const ws = new WebSocket( "wss://host:port/ws/chat?session_id=" + sessionId, ["revka.v1", "bearer." + token],);WebSocket Agent Chat (/ws/chat)
Section titled “WebSocket Agent Chat (/ws/chat)”The primary interactive channel: a bidirectional session between a client and the Revka agent. It supports session resume, mid-turn steering, stop, file attachments, page-context hints, and streaming output.
ws://host:port/ws/chat?session_id=<uuid>&name=My+Session&token=<bearer>Subprotocol: revka.v1
Connection query parameters
Section titled “Connection query parameters”| Param | Required | Meaning |
|---|---|---|
session_id | No | UUID of an existing session to resume. Omit to create a new session. |
name | No | Human-readable label stored in the session backend. |
token | No | Bearer token (see precedence — header and subprotocol take priority). |
After the upgrade, the server sends a session_start frame as the very first message. If you never send a message, the connection becomes a listen-only broadcast relay after a 5-second handshake timeout — useful for workflow viewers that only want to observe agent_event frames.
Client → server frames
Section titled “Client → server frames”| Frame | Fields | Purpose |
|---|---|---|
{"type":"connect", ...} | session_id?, device_name?, capabilities? | Optional handshake on the first frame; server acks with connected. |
{"type":"message","content":"..."} | content (required), page_context?, attachments? | Send a chat message to the agent. |
{"type":"steer","content":"..."} | content (required) | Inject a mid-turn steering note. |
{"type":"stop"} | — | Cancel the current active turn. |
{ "type": "message", "content": "Summarize the open PRs", "page_context": "workflows", "attachments": ["a1b2c3d4-...", "e5f6a7b8-..."]}Server → client frames
Section titled “Server → client frames”| Frame | Fields | Meaning |
|---|---|---|
{"type":"session_start", ...} | session_id, resumed, message_count, name | First frame after upgrade. |
{"type":"connected","message":"..."} | message | Ack for a connect handshake frame. |
{"type":"chunk","content":"..."} | content | Streaming text delta from the LLM. |
{"type":"thinking","content":"..."} | content | Extended-thinking delta (when thinking is enabled). |
{"type":"tool_call","name":"...","args":{}} | name, args | Tool invocation notification. |
{"type":"tool_result","name":"...","output":"..."} | name, output | Tool execution result. |
{"type":"operator_status","phase":"...","detail":"..."} | phase, detail | Operator lifecycle status (e.g. queued, steering). |
{"type":"agent_event","event":{}} | event | Relayed broadcast event from the operator. |
{"type":"chunk_reset"} | — | Clear the accumulated draft before the done frame arrives. |
{"type":"done","full_response":"..."} | full_response | The complete response for this turn. |
{"type":"stopped","message":"..."} | message | The turn was cancelled by a stop frame. |
{"type":"error","message":"...","code":"..."} | message, code | An error occurred (see codes below). |
Error codes
Section titled “Error codes”The error frame’s code field is one of:
| Code | Cause |
|---|---|
INVALID_JSON | The frame was not valid JSON. |
EMPTY_CONTENT | A message frame had no content. |
SESSION_BUSY | The per-session queue is full — a turn is already in flight (see serialization). |
AGENT_INIT_FAILED | The agent could not be initialized for this turn. |
AUTH_ERROR | Authentication failed. |
PROVIDER_ERROR | The upstream LLM provider returned an error. |
AGENT_ERROR | The agent raised an error mid-turn. |
NO_ACTIVE_TURN | A steer was sent with no turn running. |
UNKNOWN_MESSAGE_TYPE | The frame’s type was not recognized. |
Steering and stop
Section titled “Steering and stop”Send {"type":"steer","content":"..."} while a turn is running to inject guidance. The note is applied at the next Operator boundary, not instantly — if the turn is mid-tool-call, the steer lands after that call completes. This race is expected: a steer sent at the very end of a turn may not take effect before done.
Send {"type":"stop"} to cancel the active turn; the server replies with a stopped frame. A stop with no turn in flight returns a stopped frame (message ‘No active Operator turn to stop.’). A steer with no turn in flight returns error with code NO_ACTIVE_TURN.
Attachments
Section titled “Attachments”Upload files first via POST /api/sessions/{session_id}/attachments (multipart, 25 MiB per file), which returns a file_id. Then reference those IDs in the attachments array of a message frame. The gateway resolves each ID into a content marker the agent sees: images become [IMAGE:/path] markers for vision-capable providers; text files are inlined up to 200,000 characters with a truncation marker beyond that.
Page-context hints
Section titled “Page-context hints”The optional page_context field on a message frame switches in specialized LLM instructions for a dashboard page. Accepted values are agent_pool, agent_teams, skills, workflows, and canvas. The canvas context, for example, instructs the agent to call the render_canvas tool rather than describing output in prose. (The Architect editor uses XML blocks such as <editor-state> and <architect-instructions> inside content instead of a page_context value.)
Per-session serialization
Section titled “Per-session serialization”Each session permits at most one in-flight turn. Concurrent requests for the same session queue in FIFO order and run when the current turn completes; when the queue is full, the server returns an error with code SESSION_BUSY. Serialization prevents SQLite history corruption and keeps session state consistent.
The queue lock timeout is 300 seconds by default. Long tool chains (web search, sub-agent delegation) routinely exceed 30 seconds, so raise this — not the per-request REST timeout — for slow chat turns:
| Variable | Default | Meaning |
|---|---|---|
REVKA_GATEWAY_SESSION_LOCK_TIMEOUT_SECS | 300 | Seconds a queued request waits for the session lock before timing out. |
A timeout surfaces as Previous message is still being processed — please wait, or retry once it completes. Sessions also use a dashboard_<uuid> memory prefix to isolate Kumiho memory per session.
WebSocket PTY Terminal (/ws/terminal)
Section titled “WebSocket PTY Terminal (/ws/terminal)”A full PTY terminal over WebSocket, backed by portable_pty. It spawns the user’s shell or a named AI coding CLI inside a PTY and bridges stdio bidirectionally. This powers the Code tab in the dashboard and gives full shell access — treat it as a privileged endpoint.
ws://host:port/ws/terminal?token=<bearer>&tool=claude&cwd=/my/project&cols=120&rows=40&mcp_session=<id>&mcp_token=<tok>Query parameters
Section titled “Query parameters”| Param | Default | Meaning |
|---|---|---|
token | — | Bearer token. |
session_id | — | Optional session label. |
tool | (user $SHELL) | CLI to launch: claude, codex, opencode, gemini, agy, cursor. Omit for the user’s shell. |
cwd | — | Working directory (tilde-expanded and validated). |
mcp_session | — | MCP daemon session ID for auto-injection. |
mcp_token | — | MCP bearer token for auto-injection. |
cols | 80 | Initial PTY columns. |
rows | 24 | Initial PTY rows. |
Wire protocol
Section titled “Wire protocol”- Client → server: a text or binary frame is raw keystroke/byte data piped to PTY stdin. To resize, send
{"type":"resize","cols":N,"rows":N}. - Server → client: text frames are UTF-8-decoded PTY output. On spawn failure the server sends a red ANSI error frame.
const term = new Terminal(); // xterm.jsconst ws = new WebSocket(url, ["bearer." + token]);ws.binaryType = "arraybuffer";term.onData((d) => ws.send(d)); // keystrokes → PTYws.onmessage = (e) => term.write(typeof e.data === "string" ? e.data : new Uint8Array(e.data)); // PTY output → screenterm.onResize(({ cols, rows }) => ws.send(JSON.stringify({ type: "resize", cols, rows })));MCP auto-injection
Section titled “MCP auto-injection”When mcp_session and mcp_token are supplied, the gateway writes a CLI-specific MCP config so the launched tool reaches the in-process MCP server. Each CLI uses its own mechanism:
| Tool | Config written | Mechanism |
|---|---|---|
claude | <tmpdir>/.mcp.json | --mcp-config <path> CLI flag |
codex | <tmpdir>/.codex/config.toml | HOME redirect |
opencode | <tmpdir>/.config/opencode/config.json | HOME + XDG_CONFIG_HOME redirect |
gemini | <tmpdir>/.gemini/settings.json | HOME redirect |
agy | <tmpdir>/.gemini/config/mcp_config.json | HOME redirect |
cursor | <tmpdir>/.cursor/mcp.json | HOME redirect |
The env vars REVKA_MCP_URL, REVKA_MCP_SESSION, and REVKA_MCP_TOKEN are always set. For tool branches, HOME is redirected to a temp dir so the CLI’s config pickup is isolated; the temp dir is removed when the WebSocket closes. The shell sets TERM=xterm-256color and COLORTERM=truecolor, and forwards a safe subset of user env (PATH, LANG, LC_ALL, etc.).
Live Canvas (A2UI)
Section titled “Live Canvas (A2UI)”Live Canvas is Revka’s A2UI (agent-to-UI) surface: agents render rich HTML/SVG/Markdown panels that stream live to every connected browser tab. The agent (or any caller) pushes a frame to a named canvas; all WebSocket subscribers receive it instantly. Canvas state is in-memory and not persisted across daemon restarts.
There is a default canvas named default, and you can address any number of additional named canvases.
WebSocket Live Canvas (/ws/canvas/:id)
Section titled “WebSocket Live Canvas (/ws/canvas/:id)”Subscribe to a single named canvas and receive every new frame as it is pushed.
ws://host:port/ws/canvas/<canvas_id>?token=<bearer>On connect, the server immediately sends the current snapshot (if one exists), then a connected frame.
Server → client frames
Section titled “Server → client frames”| Frame | Fields | Meaning |
|---|---|---|
{"type":"frame","canvas_id":"...","frame":{}} | canvas_id, frame | New content pushed to the canvas. |
{"type":"connected","canvas_id":"..."} | canvas_id | Initial ack after upgrade. |
{"type":"lagged","canvas_id":"...","missed_frames":N} | canvas_id, missed_frames | The client fell behind the broadcast ring buffer. |
{"type":"error","error":"Maximum canvas count reached"} | error | The registry is at capacity. |
A frame object carries frame_id, content_type, content, and timestamp. In the dashboard, text/html frames render in a sandboxed iframe.
Live Canvas REST API
Section titled “Live Canvas REST API”REST endpoints manage canvas state; the WebSocket streams updates. All canvas endpoints accept a :id (a name or UUID).
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/canvas | Bearer | List active canvas IDs → {"canvases":["default", ...]} |
GET | /api/canvas/:id | Bearer | Get the current snapshot → {"canvas_id":"...","frame":{}} |
GET | /api/canvas/:id/history | Bearer | Get frame history → {"canvas_id":"...","frames":[...]} |
POST | /api/canvas/:id | Loopback: none; external: bearer | Push a frame. |
DELETE | /api/canvas/:id | Loopback: none; external: bearer | Clear the canvas. |
POST /api/canvas/defaultContent-Type: application/json
{ "content_type": "html", "content": "<h1>Build status</h1><p>Green</p>" }Content types and size limits
Section titled “Content types and size limits”The content_type field is validated against the canvas content-type allowlist — html, svg, markdown, and text. Arbitrary eval content is excluded by design. Content larger than the configured per-frame maximum returns HTTP 413.
| Concern | Behavior |
|---|---|
Allowed content_type | html, svg, markdown, text (eval excluded) |
| Oversize content | 413 rejection |
| Registry at capacity | error frame Maximum canvas count reached; DELETE an unused canvas first |
render_canvas and clear_canvas
Section titled “render_canvas and clear_canvas”Agents drive Live Canvas through two operator tools rather than calling REST directly:
render_canvas— push a new frame to a named canvas (the agent’s path toPOST /api/canvas/:id).clear_canvas— clear a canvas (the agent’s path toDELETE /api/canvas/:id).
When a chat message carries page_context: "canvas", the agent is instructed to call render_canvas to draw output instead of describing it in text. Embedded <img> and <a> tags can reference workspace files through HMAC-signed workspace asset URLs, which work through tunnels because they are relative.
WebSocket Dynamic Node Registry (/ws/nodes)
Section titled “WebSocket Dynamic Node Registry (/ws/nodes)”External processes — phones, sensors, IoT devices, or remote Revka instances — connect over WebSocket and advertise capabilities. Each capability becomes a dynamically available agent tool; the gateway dispatches invocations back to the correct node. The registry is purely in-memory and is lost on restart.
ws://host:port/ws/nodes?token=<bearer>Subprotocol: revka.nodes.v1
Node → gateway frames
Section titled “Node → gateway frames”| Frame | Fields | Purpose |
|---|---|---|
{"type":"register","node_id":"...","capabilities":[...]} | node_id (1–128 chars), capabilities | Register the node and its capabilities. |
{"type":"result","call_id":"...","success":true,"output":"...","error":null} | call_id, success, output, error? | Return the result of an invocation. |
Gateway → node frames
Section titled “Gateway → node frames”| Frame | Fields | Purpose |
|---|---|---|
{"type":"invoke","call_id":"...","capability":"...","args":{}} | call_id, capability, args | Invocation request to the node. |
{"type":"error","message":"..."} | message | Error (e.g. registry at capacity). |
{ "type": "register", "node_id": "phone-1", "capabilities": [ { "name": "camera.snap", "description": "Take a photo", "parameters": { "type": "object", "properties": {} } }, { "name": "gps.locate", "description": "Read GPS fix", "parameters": { "type": "object", "properties": {} } } ]}Each capability’s parameters is a JSON Schema object, defaulting to {"type":"object","properties":{}}. A capability surfaces to the agent as a tool prefixed with the node ID. Re-registering the same node_id updates it; on WebSocket close the node is unregistered and its capabilities disappear.
Auth: a node-specific auth_token from config takes precedence; if no node token is configured, the standard pairing guard applies. Registry capacity is bounded by a configurable max_nodes.
Multi-node registry (REST)
Section titled “Multi-node registry (REST)”Node discovery is enabled with [nodes] enabled = true. The REST surface lists connected nodes and invokes a capability on a specific one:
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/nodes | Bearer | List connected nodes and their capabilities. |
POST | /api/nodes/{node_id}/invoke | Bearer | Invoke a capability — body {"capability":"...","args":{}}. |
The invoke endpoint enforces a 30-second hard timeout per call. Use it to orchestrate multi-node agent networks from a central gateway. See Specialized suites: nodes for orchestration patterns and Hardware quickstart for edge devices.
SSE Event Stream (/api/events)
Section titled “SSE Event Stream (/api/events)”A Server-Sent Events stream that exposes the gateway’s internal broadcast channel to dashboard clients: LLM requests, tool calls, agent lifecycle, and observability errors in real time. This is broadcast/observability data — it is not a per-session conversation. Use /ws/chat for that.
GET /api/eventsAuthorization: Bearer <token>Accept: text/event-streamAuth is required when pairing is enabled. The broadcast channel has a capacity of 4096 events, and Axum’s default keep-alive comment frames hold the connection open.
Event types
Section titled “Event types”type | Fields | Emitted when |
|---|---|---|
llm_request | provider, model, timestamp | An LLM API call starts. |
tool_call_start | tool, timestamp | A tool call begins. |
tool_call | tool, duration_ms, success, timestamp | A tool call completes. |
agent_start | provider, model, timestamp | An agent turn begins. |
agent_end | provider, model, duration_ms, tokens_used, cost_usd, timestamp | An agent turn finishes. |
error | component, message, timestamp | An observability error occurs. |
channel_event | payload | An operator channel event (also relayed to /ws/chat clients as agent_event). |
channel_event payloads carry approval requests (human_approval_request) and other operator notifications, which the dashboard surfaces as cross-page approval toasts.
const es = new EventSource("/api/events"); // browser adds no Authorization headeres.onmessage = (e) => { const evt = JSON.parse(e.data); if (evt.type === "agent_end") console.log("cost:", evt.cost_usd);};Daemon Log SSE Stream (/api/daemon/logs)
Section titled “Daemon Log SSE Stream (/api/daemon/logs)”Tails the daemon stderr log and streams new lines as SSE events. On connect it sends an initial burst of roughly the last 64 KB, then polls every 500 ms for newly appended bytes. Historical logs beyond that initial burst are not replayed.
GET /api/daemon/logsAuthorization: Bearer <token>Accept: text/event-streamThe log file lives at ~/.revka/logs/daemon.stderr.log (resolved relative to the config file’s parent directory). The tailer handles rotation and truncation: if the file shrinks, it resets to the start.
| Event payload | Meaning |
|---|---|
{"type":"log","line":"...","timestamp":"..."} | A new log line. |
{"type":"log_unavailable","line":"daemon log not readable at ...","timestamp":"..."} | The gateway could not read its log file. |
The dashboard Logs page consumes this stream into a rolling 500-entry buffer with pause/resume and per-type filtering. See Logs, audit, doctor, pairing & skins pages.
SSE vs. WebSocket — choosing a transport
Section titled “SSE vs. WebSocket — choosing a transport”A typical realtime session
Section titled “A typical realtime session”-
Pair once to obtain a bearer token (see Pairing & authentication).
-
Subscribe to
/api/eventsover SSE for live observability, cost, and approval events across the whole gateway. -
Open
/ws/chatwith therevka.v1andbearer.<token>subprotocols. Send amessageframe, streamchunkframes into a draft, discard onchunk_reset, and renderdone.full_response. -
Steer or stop the turn in flight with
steer/stopframes as needed. -
Watch a canvas with
/ws/canvas/defaultif the agent renders A2UI output, or open/ws/terminalfor a coding session.