Skip to content

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.

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:

  1. Authorization: Bearer <token> header — for non-browser clients
  2. Sec-WebSocket-Protocol: bearer.<token> subprotocol — browser-compatible
  3. ?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],
);

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

ParamRequiredMeaning
session_idNoUUID of an existing session to resume. Omit to create a new session.
nameNoHuman-readable label stored in the session backend.
tokenNoBearer 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.

FrameFieldsPurpose
{"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-..."]
}
FrameFieldsMeaning
{"type":"session_start", ...}session_id, resumed, message_count, nameFirst frame after upgrade.
{"type":"connected","message":"..."}messageAck for a connect handshake frame.
{"type":"chunk","content":"..."}contentStreaming text delta from the LLM.
{"type":"thinking","content":"..."}contentExtended-thinking delta (when thinking is enabled).
{"type":"tool_call","name":"...","args":{}}name, argsTool invocation notification.
{"type":"tool_result","name":"...","output":"..."}name, outputTool execution result.
{"type":"operator_status","phase":"...","detail":"..."}phase, detailOperator lifecycle status (e.g. queued, steering).
{"type":"agent_event","event":{}}eventRelayed broadcast event from the operator.
{"type":"chunk_reset"}Clear the accumulated draft before the done frame arrives.
{"type":"done","full_response":"..."}full_responseThe complete response for this turn.
{"type":"stopped","message":"..."}messageThe turn was cancelled by a stop frame.
{"type":"error","message":"...","code":"..."}message, codeAn error occurred (see codes below).

The error frame’s code field is one of:

CodeCause
INVALID_JSONThe frame was not valid JSON.
EMPTY_CONTENTA message frame had no content.
SESSION_BUSYThe per-session queue is full — a turn is already in flight (see serialization).
AGENT_INIT_FAILEDThe agent could not be initialized for this turn.
AUTH_ERRORAuthentication failed.
PROVIDER_ERRORThe upstream LLM provider returned an error.
AGENT_ERRORThe agent raised an error mid-turn.
NO_ACTIVE_TURNA steer was sent with no turn running.
UNKNOWN_MESSAGE_TYPEThe frame’s type was not recognized.

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.

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.

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.)

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:

VariableDefaultMeaning
REVKA_GATEWAY_SESSION_LOCK_TIMEOUT_SECS300Seconds 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.

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>
ParamDefaultMeaning
tokenBearer token.
session_idOptional session label.
tool(user $SHELL)CLI to launch: claude, codex, opencode, gemini, agy, cursor. Omit for the user’s shell.
cwdWorking directory (tilde-expanded and validated).
mcp_sessionMCP daemon session ID for auto-injection.
mcp_tokenMCP bearer token for auto-injection.
cols80Initial PTY columns.
rows24Initial PTY rows.
  • 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.js
const ws = new WebSocket(url, ["bearer." + token]);
ws.binaryType = "arraybuffer";
term.onData((d) => ws.send(d)); // keystrokes → PTY
ws.onmessage = (e) => term.write(typeof e.data === "string"
? e.data : new Uint8Array(e.data)); // PTY output → screen
term.onResize(({ cols, rows }) =>
ws.send(JSON.stringify({ type: "resize", cols, rows })));

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:

ToolConfig writtenMechanism
claude<tmpdir>/.mcp.json--mcp-config <path> CLI flag
codex<tmpdir>/.codex/config.tomlHOME redirect
opencode<tmpdir>/.config/opencode/config.jsonHOME + XDG_CONFIG_HOME redirect
gemini<tmpdir>/.gemini/settings.jsonHOME redirect
agy<tmpdir>/.gemini/config/mcp_config.jsonHOME redirect
cursor<tmpdir>/.cursor/mcp.jsonHOME 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 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.

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.

FrameFieldsMeaning
{"type":"frame","canvas_id":"...","frame":{}}canvas_id, frameNew content pushed to the canvas.
{"type":"connected","canvas_id":"..."}canvas_idInitial ack after upgrade.
{"type":"lagged","canvas_id":"...","missed_frames":N}canvas_id, missed_framesThe client fell behind the broadcast ring buffer.
{"type":"error","error":"Maximum canvas count reached"}errorThe 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.

REST endpoints manage canvas state; the WebSocket streams updates. All canvas endpoints accept a :id (a name or UUID).

MethodPathAuthPurpose
GET/api/canvasBearerList active canvas IDs → {"canvases":["default", ...]}
GET/api/canvas/:idBearerGet the current snapshot → {"canvas_id":"...","frame":{}}
GET/api/canvas/:id/historyBearerGet frame history → {"canvas_id":"...","frames":[...]}
POST/api/canvas/:idLoopback: none; external: bearerPush a frame.
DELETE/api/canvas/:idLoopback: none; external: bearerClear the canvas.
POST /api/canvas/default
Content-Type: application/json
{ "content_type": "html", "content": "<h1>Build status</h1><p>Green</p>" }

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.

ConcernBehavior
Allowed content_typehtml, svg, markdown, text (eval excluded)
Oversize content413 rejection
Registry at capacityerror frame Maximum canvas count reached; DELETE an unused canvas first

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 to POST /api/canvas/:id).
  • clear_canvas — clear a canvas (the agent’s path to DELETE /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

FrameFieldsPurpose
{"type":"register","node_id":"...","capabilities":[...]}node_id (1–128 chars), capabilitiesRegister 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.
FrameFieldsPurpose
{"type":"invoke","call_id":"...","capability":"...","args":{}}call_id, capability, argsInvocation request to the node.
{"type":"error","message":"..."}messageError (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.

Node discovery is enabled with [nodes] enabled = true. The REST surface lists connected nodes and invokes a capability on a specific one:

MethodPathAuthPurpose
GET/api/nodesBearerList connected nodes and their capabilities.
POST/api/nodes/{node_id}/invokeBearerInvoke 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.

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/events
Authorization: Bearer <token>
Accept: text/event-stream

Auth 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.

typeFieldsEmitted when
llm_requestprovider, model, timestampAn LLM API call starts.
tool_call_starttool, timestampA tool call begins.
tool_calltool, duration_ms, success, timestampA tool call completes.
agent_startprovider, model, timestampAn agent turn begins.
agent_endprovider, model, duration_ms, tokens_used, cost_usd, timestampAn agent turn finishes.
errorcomponent, message, timestampAn observability error occurs.
channel_eventpayloadAn 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 header
es.onmessage = (e) => {
const evt = JSON.parse(e.data);
if (evt.type === "agent_end") console.log("cost:", evt.cost_usd);
};

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/logs
Authorization: Bearer <token>
Accept: text/event-stream

The 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 payloadMeaning
{"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”
  1. Pair once to obtain a bearer token (see Pairing & authentication).

  2. Subscribe to /api/events over SSE for live observability, cost, and approval events across the whole gateway.

  3. Open /ws/chat with the revka.v1 and bearer.<token> subprotocols. Send a message frame, stream chunk frames into a draft, discard on chunk_reset, and render done.full_response.

  4. Steer or stop the turn in flight with steer / stop frames as needed.

  5. Watch a canvas with /ws/canvas/default if the agent renders A2UI output, or open /ws/terminal for a coding session.