Files
beaver_project/2026-06-01-hermes-gateway-llm-design.md

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:

  1. The terminal opens a WebSocket connection to Beaver.
  2. The terminal sends a connect frame with a stable peer_id.
  3. The terminal sends one text message frame.
  4. The terminal receives an ack.
  5. The terminal receives the final assistant text response from Beaver.
  6. The terminal can reconnect with the same peer_id and 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 accepted is true, wait for the assistant message.
  • If duplicate and reply is present, display the cached reply.
  • If duplicate and pending is true, keep waiting on the socket.
  • If error is 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:

  • connect is required before message.
  • peer_id is required.
  • message_id is required.
  • text is 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 connected frame.

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_id generator.
  • 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 connect immediately 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.