BawtHub
⌕ Search ⌘K Source ↗ Open app →
agents · openclaw bridge

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.

Upstream: OpenClaw gateway over WebSocket (TLS) Auth: Ed25519 device identity · v3 signed device-auth handshake Two paths: active (Redis-triggered) + passive (subscription-only) feeds

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.

OpenClaw bridge · request flow + passive subscription
┌──────────────────────────────────────────────────┐
│  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:

FieldValue
client_idgateway-client
client_modebackend
scopesoperator.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 eventpayload.streamdata.phaseAgentEvent
agentlifecyclestartRUN_STARTED
agentlifecycleendRUN_COMPLETED
agentlifecycle(other)SYSTEM_NOTE with raw JSON
agentassistantASSISTANT_DELTA (text from data.delta or data.text)
agenttoolstart/callingTOOL_START with tool_name + tool_arguments
agenttoolend/result/doneTOOL_END with tool_result
agenterrorERROR
chat(state=final)ASSISTANT_DONE with assembled text
chat(state=delta)suppressed — the agent.assistant stream already covers it
chat.sentUSER_MESSAGE (echoes the user's own send into the event log)
infra noisedropped (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:

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:

  1. The bridge picks it up from agent:commands as an rpc.call with method chat.abort.
  2. Before forwarding, the bridge calls ws_client.cancel_session(session_key), which sets the per-session cancel event.
  3. The active send_and_stream loop sees the event flip and returns immediately — releasing the session lock.
  4. The bridge then forwards chat.abort to the gateway via the underlying _request() path so the upstream agent also stops.
  5. 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.

Don't replicate this hack in the other bridges.

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:

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:

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.py
The bridge. ~640 lines. Command listener (chat.send, rpc.call), ingest dispatch, fanout, chat.history fallback, RPC response routing.
src/openclaw_bridge/ws_client.py
WebSocket client. ~680 lines. Ed25519 identity, v3 signed handshake, pairing retry, RPC futures, run queues, cancel events, reconnect.
src/openclaw_bridge/ingest.py
Event normalization. EventIngestPipeline.parse() — the (event_name, stream) dispatch table. Includes IngestFilterConfig for env-driven content/event drop filters.
src/agent_bridge/events.py
Shared event format. AgentEvent + AgentEventKind enum + synthesize_event_id(). The dataclass every bridge re-uses.
src/agent_bridge/publisher.py
Redis publisher. Stream names, maxlen policy (10k events/session, 5k per run, 5k unified), provider stamping at publish boundary.
src/agent_bridge/subscriber.py
Redis subscriber. RedisSubscriber.subscribe_run(request_id) — the async iterator the main API uses to pull events back out per request.
src/agent_bridge/session_queue.py
The shared lock. Per-session asyncio locks + active-task tracking. Reused by Claude Code and Codex bridges.
src/agent_bridge/store.py
EventStore. Optional sqlite event persistence; used by the passive feed for replay scenarios.
src/llm_bawt/agent_backends/openclaw.py
The backend. The actual stream_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.
Validated against main on 2026-05-13 Source: llm-bawt agent backends