11 KiB
Beaver Terminal WebSocket Integration Guide
Date: 2026-06-01
Audience: the small-terminal-side Codex agent that will modify terminal firmware or terminal app code.
Goal
Connect the small terminal device to Beaver through a text-only WebSocket channel.
The first acceptance target is simple:
- The terminal opens a WebSocket connection to Beaver.
- The terminal sends a
connectframe with a stablepeer_id. - The terminal sends one text
messageframe. - The terminal receives an
ack. - The terminal receives the final assistant text response from Beaver.
- The terminal can reconnect with the same
peer_idand keep the same Beaver session.
This document replaces the earlier Hermes LiveKit LLM adapter design for the terminal-side work. Do not implement a LiveKit LLM adapter from this document.
Non-Goals
- Do not implement audio streaming.
- Do not implement camera, screen, image, or multimodal frames.
- Do not implement token streaming.
- Do not implement terminal-side tools.
- Do not implement AuthZ, device registration, OAuth, or pairing in the first pass.
- Do not call Beaver REST chat endpoints or the existing Web UI
/ws/{session_id}endpoint. - Do not build an OpenAI-compatible proxy.
- Do not implement Hermes Agent or LiveKit changes on the terminal side.
Beaver Endpoint
The terminal connects to:
ws://<beaver-host>/api/channels/<channel_id>/ws
For local development through the Beaver app instance nginx port:
ws://127.0.0.1:8080/api/channels/terminal-dev/ws
For direct backend development without nginx:
ws://127.0.0.1:18080/api/channels/terminal-dev/ws
Use wss:// when Beaver is deployed behind TLS.
The expected first channel id is:
terminal-dev
The terminal implementation should make the URL configurable, for example:
BEAVER_WS_URL=ws://127.0.0.1:8080/api/channels/terminal-dev/ws
TERMINAL_PEER_ID=device-001
TERMINAL_DEVICE_NAME=desk-terminal
Protocol Overview
The transport is JSON over WebSocket.
All frames are UTF-8 JSON objects. The terminal should ignore unknown fields. Beaver will ignore unknown fields unless the frame type is invalid.
The protocol is request/reply oriented in this phase. Beaver sends only final assistant messages, not token deltas.
Required frame flow:
terminal -> Beaver: connect
Beaver -> terminal: connected
terminal -> Beaver: message
Beaver -> terminal: ack
Beaver -> terminal: message
Optional heartbeat:
terminal -> Beaver: ping
Beaver -> terminal: pong
Connect Frame
The terminal must send connect immediately after the WebSocket opens.
Terminal to Beaver:
{
"type": "connect",
"peer_id": "device-001",
"device_name": "desk-terminal",
"capabilities": ["text"]
}
Required fields:
type: must be"connect".peer_id: stable terminal identity. Reuse this value across reconnects.
Recommended fields:
device_name: human-readable terminal name.capabilities: include"text".
Optional fields:
thread_id: optional sub-session key. Omit it for the first pass.user_id: optional user identity. Omit it unless the terminal already has a stable user id.
Beaver to terminal:
{
"type": "connected",
"channel_id": "terminal-dev",
"session_id": "terminal-dev:local:device-001"
}
The terminal should store session_id for logging and diagnostics. It does not need to send session_id back in message frames.
Message Frame
Terminal to Beaver:
{
"type": "message",
"message_id": "m-001",
"text": "hello"
}
Required fields:
type: must be"message".message_id: unique id for this user message.text: non-empty user text.
Recommended message_id format:
<peer_id>-<monotonic-counter>
Example:
device-001-000001
device-001-000002
The terminal should persist the counter if practical. If persistence is unavailable, generate a UUID or timestamp-based id. Reusing the same message_id tells Beaver to treat the frame as a duplicate.
Optional fields:
thread_id: use only when the terminal intentionally wants a separate Beaver session.user_id: use only when the terminal has a stable user id.
Ack Frame
Beaver sends an ack after accepting or deduplicating the inbound message.
Accepted:
{
"type": "ack",
"message_id": "device-001-000001",
"session_id": "terminal-dev:local:device-001",
"accepted": true
}
Duplicate still processing:
{
"type": "ack",
"message_id": "device-001-000001",
"session_id": "terminal-dev:local:device-001",
"accepted": false,
"duplicate": true,
"pending": true
}
Duplicate already completed:
{
"type": "ack",
"message_id": "device-001-000001",
"session_id": "terminal-dev:local:device-001",
"accepted": false,
"duplicate": true,
"pending": false,
"reply": "cached assistant reply"
}
Terminal behavior:
- If
acceptedis true, wait for the assistantmessage. - If
duplicateandreplyis present, display the cached reply. - If
duplicateandpendingis true, keep waiting on the socket. - If
erroris present, display or log the error.
Assistant Message Frame
Beaver to terminal:
{
"type": "message",
"role": "assistant",
"message_id": "device-001-000001",
"run_id": "run-id",
"text": "assistant reply",
"finish_reason": "stop"
}
Fields:
type:"message".role:"assistant".message_id: the user message id this response belongs to.run_id: Beaver run id for diagnostics.text: final assistant response.finish_reason: usually"stop", or"error"when the run failed.
Terminal behavior:
- Render or speak
text. - Treat
finish_reason == "error"as a failed turn. - Do not expect token-level streaming in this phase.
Ping And Pong
Terminal to Beaver:
{"type": "ping"}
Beaver to terminal:
{"type": "pong"}
Recommended heartbeat interval:
30 seconds
If no pong or other frame is received after a reasonable timeout, reconnect.
Error Frame
Beaver may send:
{
"type": "error",
"error": "human readable error"
}
Terminal behavior:
- Log the error.
- Keep the connection open unless the WebSocket closes.
- If the error is for a user message, allow the user to retry with a new
message_id.
Common first-pass errors:
connectis required beforemessage.peer_idis required.message_idis required.textis required.- Unsupported websocket frame type.
Terminal State Machine
Implement the terminal client as a small state machine.
DISCONNECTED
-> connect websocket
CONNECTING
-> websocket open, send connect frame
WAIT_CONNECTED
-> receive connected
READY
-> send message frame
WAIT_ACK
-> receive ack
WAIT_REPLY
-> receive assistant message
READY
On WebSocket close or network failure, transition to DISCONNECTED and reconnect with backoff.
Recommended reconnect policy:
- Start at 1 second.
- Double up to 30 seconds.
- Reset backoff after a successful
connectedframe.
On reconnect, use the same peer_id.
Terminal Implementation Requirements
The terminal-side code should provide:
- A configurable Beaver WebSocket URL.
- A stable
peer_id. - A configurable
device_name. - A monotonic or otherwise unique
message_idgenerator. - JSON encoding and decoding.
- Connect frame on socket open.
- Ping/pong heartbeat.
- Reconnect with backoff.
- A queue or guard so only one user text turn is in flight at a time for the first pass.
- Logging for
session_id,message_id,run_id, and errors.
The terminal-side code does not need:
- Multi-room session logic.
- Hermes session management.
- LiveKit
AgentSession. - Audio chunking.
- Tool calls.
- OAuth or token refresh.
Example Client Pseudocode
peer_id = load_or_create_peer_id()
counter = load_counter()
async def run_terminal_client():
while True:
try:
async with connect(BEAVER_WS_URL) as ws:
await ws.send_json({
"type": "connect",
"peer_id": peer_id,
"device_name": DEVICE_NAME,
"capabilities": ["text"],
})
connected = await ws.receive_json()
assert connected["type"] == "connected"
log("session_id", connected["session_id"])
await read_send_receive_loop(ws)
except Exception as exc:
log("websocket disconnected", exc)
await sleep(next_backoff())
async def send_user_text(ws, text):
global counter
counter += 1
save_counter(counter)
message_id = f"{peer_id}-{counter:06d}"
await ws.send_json({
"type": "message",
"message_id": message_id,
"text": text,
})
while True:
frame = await ws.receive_json()
if frame["type"] == "ack" and frame.get("message_id") == message_id:
if frame.get("reply"):
return frame["reply"]
continue
if frame["type"] == "message" and frame.get("role") == "assistant":
if frame.get("message_id") == message_id:
return frame.get("text", "")
if frame["type"] == "error":
raise RuntimeError(frame.get("error", "unknown error"))
Adapt the pseudocode to the terminal runtime language and WebSocket library.
Manual Test With websocat
If websocat is available, a developer can manually test the protocol:
websocat ws://127.0.0.1:8080/api/channels/terminal-dev/ws
Then paste:
{"type":"connect","peer_id":"device-001","device_name":"desk-terminal","capabilities":["text"]}
Expected response:
{"type":"connected","channel_id":"terminal-dev","session_id":"terminal-dev:local:device-001"}
Then paste:
{"type":"message","message_id":"device-001-000001","text":"hello"}
Expected responses:
{"type":"ack","message_id":"device-001-000001","session_id":"terminal-dev:local:device-001","accepted":true}
Then, after Beaver finishes the run:
{"type":"message","role":"assistant","message_id":"device-001-000001","run_id":"...","text":"...","finish_reason":"stop"}
Acceptance Checklist For Terminal-Side Codex
- The terminal opens the configured Beaver WebSocket URL.
- The terminal sends
connectimmediately after open. - The terminal receives and logs
connected.session_id. - The terminal sends text using a unique
message_id. - The terminal receives
ack. - The terminal receives and displays assistant
message.text. - The terminal handles
ping/pong. - The terminal reconnects with the same
peer_id. - The terminal does not use REST chat or
/ws/{session_id}. - The terminal implementation remains text-only for the first pass.
When this checklist passes against Beaver, the first-stage device integration is accepted from the terminal side.