The first bridge. The template.
OpenClaw was the original external agent system llm-bawt integrated with — a remote agent gateway running on a separate host, accessed over a persistent WebSocket. The bridge that connects them is older than the Claude Code and Codex bridges, and it's the one whose abstractions they inherit. The AgentEvent format, the Redis fanout, the SessionQueue, the consumer-group pattern — all of it started here. Even the type name on the shared event format gives that away.
01 What OpenClaw is, briefly.
OpenClaw is an agent host — a separate process on a separate machine that runs long-lived agent sessions with tool access, multi-model routing, and a gateway WebSocket. From llm-bawt's perspective it's a black box: send chat.send, receive a stream of agent.* WebSocket events, persist nothing locally beyond what the bridge re-emits. The gateway owns the agent's actual conversation history; llm-bawt's memory layer is a parallel store keyed off the same session_key.
Practically: a session_key like agent:loopy:main lives on the gateway; the bridge subscribes to it on startup, listens for events, and exposes a chat.send path the API can invoke. The AgentBridgeBackend in the main app is the API-facing half of that round-trip.
02 Topology.
┌──────────────────────────────────────────────────┐
│ llm-bawt-app (FastAPI) │
│ AgentBridgeBackend.stream_raw(prompt, config) │
│ → send_command(backend="openclaw", │
│ session_key="agent:loopy:main", message) │
└─────────────────────┬────────────────────────────┘
│ XADD agent:commands
▼
┌──────────────────────────────────────────────────┐
│ openclaw-bridge container │
│ • XREADGROUP group="bridge" │
│ • _resolve_bot_id(session_key) → owner check │
│ • OpenClawWsClient.send_and_stream( │
│ session_key, message, attachments) │
│ → WS chat.send → gateway │
│ ← raw agent.* / chat.* events │
│ • EventIngestPipeline.parse(raw, sk) │
│ → AgentEvent (the shared format) │
│ • publish_run_event(request_id, event) │
│ • on empty stream → chat.history fallback │
└─────────────────────┬────────────────────────────┘
│ XADD agent:run:{req_id}
▼
API SSE subscriber
Persistent WebSocket to OpenClaw gateway lives
parallel to all of this. Every event flows
through ingest, then to per-session streams
(agent:events:{sk}) for passive observers.
03 Ed25519 device identity.
The OpenClaw gateway doesn't accept long-lived API keys. Each bridge instance has a persistent Ed25519 keypair stored at ~/.config/llm-bawt/openclaw-device.json. The device ID is the SHA-256 hex of the raw 32-byte public key.
{
"version": 1,
"deviceId": "<sha256 of raw pubkey>",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...",
"privateKeyPem": "-----BEGIN PRIVATE KEY-----\n...",
"deviceToken": "<token from gateway after pairing>"
}
On WebSocket connect, the bridge builds a v3 signed payload — pipe-separated, fixed field order:
v3|deviceId|client_id|client_mode|role|scopes|signed_at_ms|token|nonce|platform|deviceFamily
This is Ed25519-signed with the private key and sent alongside the public key (base64url, raw 32-byte form) and the (optional) device token. The gateway either accepts the auth and assigns a fresh deviceToken for next time, or returns NOT_PAIRED — in which case the bridge raises _PairingPendingError and retries. Default backend client values:
| Field | Value |
|---|---|
client_id | gateway-client |
client_mode | backend |
scopes | operator.read, operator.write, operator.admin |
If the gateway rejects the existing deviceToken as stale, the bridge nulls it and re-attempts pairing — the keypair stays the same so device identity is preserved.
04 Ingest: gateway events → AgentEvent.
The gateway emits a chatty WebSocket protocol. EventIngestPipeline.parse() is the normalization layer. Inputs are raw dicts of the form {"type": "event", "event": "agent", "payload": {...}}. Outputs are AgentEvent dataclass instances. Most of the work is dispatching on (event_name, payload.stream):
| Gateway event | payload.stream | data.phase | AgentEvent |
|---|---|---|---|
agent | lifecycle | start | RUN_STARTED |
agent | lifecycle | end | RUN_COMPLETED |
agent | lifecycle | (other) | SYSTEM_NOTE with raw JSON |
agent | assistant | — | ASSISTANT_DELTA (text from data.delta or data.text) |
agent | tool | start/calling | TOOL_START with tool_name + tool_arguments |
agent | tool | end/result/done | TOOL_END with tool_result |
agent | error | — | ERROR |
chat | (state=final) | — | ASSISTANT_DONE with assembled text |
chat | (state=delta) | — | suppressed — the agent.assistant stream already covers it |
chat.sent | — | — | USER_MESSAGE (echoes the user's own send into the event log) |
| infra noise | — | — | dropped (ping, pong, heartbeat, health, tick, presence, shutdown) |
Unknown event shapes fall through as SYSTEM_NOTE with the original payload preserved in raw — observable but non-disruptive. Content-pattern filters (regex) can also drop user-message text matching arbitrary patterns; this gets used to suppress automation chatter that doesn't belong in the visible transcript.
05 Fanout: two destinations per event.
Every normalized AgentEvent goes to two Redis streams:
agent:events:{session_key}— the durable per-session stream. Any subscriber (the chat UI, the activity ticker, downstream batch jobs) can replay or tail this. Trimmed to ~10k events per session.agent:run:{request_id}— the short-lived per-run stream. Only events for the currently activechat.sendrequest go here. The API's SSE generator subscribes to this for the duration of the user's turn, then unsubscribes.
This split is what lets passive observers (e.g. a CLI session on the gateway that wasn't triggered by the API) still show up in the unified event stream, while keeping the per-request SSE feed scoped to a single user turn.
06 chat.abort: cooperative cancellation.
OpenClaw's WS client supports an explicit cancel_session(session_key) that sets an asyncio.Event shared with the active send_and_stream iterator. When the API publishes a chat.abort RPC command:
- The bridge picks it up from
agent:commandsas anrpc.callwith methodchat.abort. - Before forwarding, the bridge calls
ws_client.cancel_session(session_key), which sets the per-session cancel event. - The active
send_and_streamloop sees the event flip and returns immediately — releasing the session lock. - The bridge then forwards
chat.abortto the gateway via the underlying_request()path so the upstream agent also stops. - RPC result is published back via Redis for the API to receive.
That ordering matters. If the gateway acknowledged the abort first and the local cancel didn't fire, the session lock would stay held until the WS stream naturally drained — blocking the next send.
07 The history fallback (the load-bearing hack).
OpenClaw sometimes falls back to an alternate provider mid-turn — and in that case the original run ends without ever emitting assistant text. The agent did produce a reply; it just got persisted to the gateway's chat history rather than emitted as live deltas. Left alone, the user sees an empty bubble.
The bridge handles this with a chat.history fallback. After the run stream completes, if no ASSISTANT_DELTA or ASSISTANT_DONE.text was seen, the bridge polls chat.history for up to 45 seconds (default _history_reply_timeout_s), looking for a new assistant message that postdates the user's send. When it finds one, it synthesizes an ASSISTANT_DELTA event with the recovered text and publishes that to the run stream. The user's bubble fills in. The frontend never knew anything was off.
Claude Code and Codex don't need it — their SDKs guarantee that final assistant text is in ResultMessage / turn.completed. The history fallback is a workaround for OpenClaw's specific provider-fallback behavior. It is also explicitly bounded: only used when the live stream produced no assistant text, and only consults messages newer than the latest user message in the session.
08 SessionQueue: the lock everyone reuses.
The SessionQueue object started life inside this bridge and now serves all three. It provides:
async with queue.lock(session_key):— serializeschat.sendcommands per session, so the gateway never sees two parallel sends on the same conversation.queue.set_active_task(session_key, task)— records the current asyncio Task per session, used for cooperative cancellation.queue.is_busy(session_key)— non-blocking check, used to logsession busy — queuing send
.
That same object is imported wholesale by the Claude Code and Codex bridges — from agent_bridge.session_queue import SessionQueue — and used identically. The lock is what guarantees per-session ordering across all three.
09 Session keys, bot routing, and the active path.
The AgentBridgeBackend.stream_raw() active path lives in the main API process, not the bridge. The backend's session-key resolver is the simplest of the three:
def _resolve_session_key(self, config: dict) -> str:
explicit = (config.get("session_key") or "").strip()
if explicit:
return explicit
return os.getenv("OPENCLAW_SESSION_KEY", "main")
OpenClaw bots set their session_key explicitly in agent_backend_config — typically values like agent:loopy:main or agent:snark:main. Unlike Claude Code or Codex, there's no per-user routing here; the gateway-side conversation is shared across whoever hits this bot. (Per-user routing on OpenClaw would require gateway-side session multiplexing the gateway doesn't currently expose.)
The bridge has a session_to_bot map at startup — {session_key → bot_id} — built from the bot configs. _resolve_bot_id(session_key) uses it to filter incoming commands: if a session_key has no owning bot, the bridge ACKs the command without acting (another bridge owns it, or it's stale).
10 The WS client.
OpenClawWsClient is a long-lived websockets connection with:
- Per-request RPC futures (
_pending_requests: {req_id → asyncio.Future}) for synchronous calls likechat.sendandchat.history. - Per-run event queues (
_run_queues: {run_id → asyncio.Queue}) for async iteration over a streaming run. - Per-session cancel events (
_session_cancel_events) for cooperative abort. - An auto-subscribe pass at connect time that re-subscribes to all configured session_keys.
- Backoff + reconnect on disconnect — the WS connection is expected to be persistent.
The send_and_stream(session_key, text) method is the main entry: it sends chat.send over the WS, waits for the runId in the response, then yields each event for that run until a lifecycle end/error arrives, a timeout fires, or the cancel event flips.
11 Key files.
src/openclaw_bridge/bridge.pychat.send, rpc.call), ingest dispatch, fanout, chat.history fallback, RPC response routing.src/openclaw_bridge/ws_client.pysrc/openclaw_bridge/ingest.pyEventIngestPipeline.parse() — the (event_name, stream) dispatch table. Includes IngestFilterConfig for env-driven content/event drop filters.src/agent_bridge/events.pyAgentEvent + AgentEventKind enum + synthesize_event_id(). The dataclass every bridge re-uses.src/agent_bridge/publisher.pyprovider stamping at publish boundary.src/agent_bridge/subscriber.pyRedisSubscriber.subscribe_run(request_id) — the async iterator the main API uses to pull events back out per request.src/agent_bridge/session_queue.pysrc/agent_bridge/store.pysrc/llm_bawt/agent_backends/openclaw.pystream_raw() implementation that all three bridges' backends inherit. Per-thread state, separate-thread async Redis client to avoid event-loop cross-contamination, queue-drain SSE generator.main on 2026-05-13
Source: llm-bawt agent backends