External agents, internal bots.
Claude Code, OpenAI Codex, and the OpenClaw gateway aren't separate products bolted onto llm-bawt — they're agent backends registered under llm_bawt.agent_backends. A bot with agent_backend: claude-code appears in /v1/models as a model alias and gets the full memory, history, tool-call, and turn-log treatment. The mechanism is a tiny plugin registry, one shared Redis command/event protocol, and three thin classes.
01 Why a bridge at all.
External agent SDKs — the Claude Agent SDK, the OpenAI Codex SDK, OpenClaw's WebSocket gateway — are full conversation runtimes. They maintain their own message history, run their own tool loops (Bash, Read, Write, Edit, etc.), and stream results back over their own protocols. You don't want llm-bawt's pipeline to compete with that — the SDK is doing the right thing on its own side. But you do want llm-bawt's persistent memory, profile attributes, summarization, and turn logging to apply on every turn the SDK runs.
The bridge pattern threads that needle. Each agent SDK runs in its own bridge process (separate binary, separate venv if needed). llm-bawt's AgentBackendClient (see the clients page) sends commands and subscribes to events over Redis. The pipeline's pre- and post-processing runs against the bridge's output the same way it runs against an OpenAI response.
02 The plugin interface.
agent_backends/base.py is 50 lines:
class AgentBackend(ABC):
name: str = "base"
@abstractmethod
async def chat(self, prompt: str, config: dict, stream: bool = False) -> str:
...
async def health_check(self, config: dict) -> bool:
return True
That's it. A backend implements chat() — an async coroutine that takes a prompt and a config dict and returns text. health_check() is optional and defaults to True. Backends register themselves either as builtins (in registry._register_builtins) or via the llm_bawt.agent_backends entry-point group declared in any third-party package's pyproject.toml:
[project.entry-points."llm_bawt.agent_backends"] myagent = "mypackage.agents:MyAgentBackend"
The registry in agent_backends/registry.py loads entry points on import and falls back to the three builtins: openclaw, claude-code, codex.
03 AgentBridgeBackend — the shared protocol.
The generic bridge protocol lives in the src/agent_bridge/ package and the AgentBridgeBackend base class. Every external-agent integration — OpenClaw, Claude Code, Codex — is a thin subclass that overrides name and the routing-key resolver. The 400-line round-trip plumbing only exists once.
The relationship between llm-bawt and any bridge process is mediated entirely through Redis: there's no direct WebSocket from llm-bawt to the gateway, no in-process SDK call, no shared address space. The bridge is always a separate OS process, often a separate container.
AgentBridgeBackend.stream_raw publishes a chat.send command on the agent:commands Redis stream — prompt, session key, model alias, attachments. The backend field tags it for the right bridge.openclaw-bridge, claude-code-bridge, or codex-bridge) is the consumer for that backend tag and runs the SDK / gateway call on its side.AgentEvent records and publishes them on a per-request Redis stream.OpenClawBackend is now a 30-line subclass of AgentBridgeBackend that sets name = "openclaw" and resolves session keys from the bot's agent_backend_config.session_key. The 412-line shared mechanics — Redis subscription, ASSISTANT_DONE reconciliation, request-local state, abort handling — moved into the base. The same is true for ClaudeCodeBackend and CodexBackend: each is a small subclass that picks a session-key strategy and inherits everything else.
The backend yields a mix of types from stream_raw:
- Strings — text deltas, forwarded as content chunks in the SSE stream.
{"event": "tool_call", ...}— emitted onTOOL_STARTevents from the gateway.{"event": "tool_result", ...}— emitted onTOOL_END.{"event": "metadata", "upstream_model": ...}— the actual model the gateway used (often differs from the alias).{"event": "token_usage", ...}— final usage stats, used by the chat UI for the per-bubble pill.
The ASSISTANT_DONE event from the gateway carries the complete response text. Because gateway tool-heavy turns sometimes only emit the final synthesis in DONE rather than as ASSISTANT_DELTAs, the backend reconciles: if done.text starts with the accumulated deltas, it yields the extra. If it doesn't (prefix mismatch), it logs a warning and yields the full done.text so persistence is at least correct, even if the SSE stream duplicates some content.
04 Claude Code — same protocol, different bridge.
agent_backends/claude_code.py is 37 lines. It subclasses AgentBridgeBackend and overrides exactly one method:
class ClaudeCodeBackend(AgentBridgeBackend):
name = "claude-code"
def _resolve_session_key(self, config: dict) -> str:
# Route by bot + user so each user gets an independent Claude
# session for a given bot.
bot_id = str(config.get("bot_id") or "main").strip() or "main"
user_id = str(config.get("user_id") or "default").strip() or "default"
return f"{bot_id}:{user_id}"
The Redis command/event protocol is identical. The only difference is that claude-code-bridge (separate process, separate entry point) listens on the same Redis but talks to the Claude Agent SDK instead of the OpenClaw gateway. The bridge filters on the backend field of incoming commands so it doesn't accidentally claim an OpenClaw command.
Session-key routing is the subtle part. The bridge tracks a Claude SDK session_id (a UUID) per Claude conversation thread. That UUID gets written back into the bot's agent_backend_config.session_key so the SDK can resume the conversation on the next turn. But routing commands to the right bridge can't use that UUID as a key — the bridge parses commands by stable identifier (bot slug + user). Hence _resolve_session_key returns "{bot}:{user}" as the routing key, and the persisted SDK UUID stays separate.
The chat-route session-reset endpoint (POST /v1/chat/session/reset) sends a session.reset RPC over Redis with the bot-slug routing key. The bridge wipes its stored SDK session and starts fresh on the next turn. Each user gets their own Claude conversation thread — a property the SDK provides naturally and the routing key preserves.
05 Codex — same shape again.
agent_backends/codex.py is 41 lines and structurally identical to Claude Code: subclass AgentBridgeBackend, override _resolve_session_key to return "{bot}:{user}", ship.
The differences are on the bridge side: codex-bridge uses the openai-codex-sdk package and authenticates via the ChatGPT-mode OAuth flow (not an API key). Its persisted session_key in agent_backend_config is the Codex thread_id written by the bridge after the first thread/started event — managed automatically; cleared by a /new command or a model change.
Three external agent providers, three thin Python files, one shared Redis protocol. The bridge processes carry the SDK-specific weight.
06 The backend-prompts catalog.
agent_backends/prompts.py (498 lines) holds backend-specific prompt fragments injected into the bot's system prompt when an agent backend is active. These are templated overlays — they don't replace the bot's personality, they augment it with backend-specific tool-use guidance (e.g. "you have file system access via the Bash tool, prefer Read/Edit over cat/sed"). Keeping them as Python constants instead of in the prompt-templates table is a deliberate trade-off: they're tightly coupled to the agent SDK version and shouldn't be edited by end users.
07 Startup wiring.
The service's lifespan handler in service/api.py does the cross-process plumbing:
- If
OPENCLAW_WS_ENABLEDandREDIS_URLare both set, instantiateagent_bridge.subscriber.RedisSubscriberand connect. - Walk every bot with
agent_backend in (openclaw, claude-code, codex)and build asession_key -> bot_idmapping for the subscriber. Log it at startup. - Stash the subscriber on the service so all
AgentBridgeBackendinstances can find it viaget_agent_subscriber()(a module-level singleton, set byset_agent_subscriber). - Start the background tool-event drain that persists
tool_start/tool_endrecords totool_call_recordsfor later inspection. - Start the periodic stale-consumer-group cleanup (every 5 minutes).
If the subscriber isn't initialized at request time (because Redis is unreachable or the flags weren't set), AgentBridgeBackend.stream_raw raises a clear RuntimeError rather than silently producing empty responses.
08 Abort and session reset.
The chat route has two endpoints specifically for agent backends:
| Endpoint | Effect |
|---|---|
POST /v1/chat/abort | Looks up the turn's agent_session_key, sends chat.abort RPC to the bridge with the bot's backend name (so the wrong bridge doesn't claim the abort). Marks the turn-log row aborted regardless of RPC outcome. |
POST /v1/chat/session/reset | Sends session.reset RPC to the bridge with the routing session key. For claude-code and codex that's always bot_slug; for openclaw it's the explicit session_key from agent_backend_config. |
Both endpoints route through the same Redis-RPC helper (RedisSubscriber.send_rpc). The backend filter ensures cross-bridge RPC races don't corrupt state when multiple bridges are listening.
09 What still applies (and what doesn't).
Because the agent backend is wrapped in AgentBackendClient and treated as a normal model client, the full pipeline runs:
- System prompt assembly — user context, bot traits, base prompt, memory cold-start, global instructions — all sent to the bridge via the
system_promptcommand field. The SDK applies it on top of its own. - History — the pipeline still calls
history_manager.add_message("user", prompt)before dispatch andadd_message("assistant", response)after. The agent SDK's own internal history is independent, but llm-bawt's persistent history table mirrors everything. - Memory extraction — the post-turn scheduler still extracts memories from the user/assistant exchange.
- Tool-call capture — the bridge's tool events are persisted to
tool_call_recordsand surfaced in turn logs alongside any tool calls llm-bawt's own pipeline made. - Profile attribute updates — high-importance extracted facts still route to
profile_attributes.
What doesn't apply: the in-turn tool loop. The agent SDK runs its own tool dispatch (Bash, Read, Edit, etc.). llm-bawt doesn't try to intercept or rewrite those — they're the agent's job. llm-bawt observes them through the bridge's event stream and records them, but never executes them on the SDK's behalf.
10 Key files.
agent_backends/base.pyAgentBackend. 50 lines. Abstract base — one async chat() method, optional health_check(). The whole plugin contract.agent_backends/registry.pyimportlib.metadata. get_backend(name) for instance construction.agent_bridge/AgentEvent, AgentEventKind, EventStore, RedisPublisher, RedisSubscriber, SessionQueue. Tables: agent_events, agent_runs, agent_session_state. Redis streams: agent:{events,run,commands,history,rpc}.agent_backends/agent_bridge.pyAgentBridgeBackend. ~412 lines. The shared bridge implementation that all three integrations subclass. Owns the chat.send dispatch, AgentResult + AgentToolCall dataclasses, ASSISTANT_DONE reconciliation, request-local state, abort handling.agent_backends/openclaw.pyOpenClawBackend. ~30 lines. Sets name = "openclaw"; resolves the routing key from the bot's agent_backend_config.session_key. Routes to openclaw-bridge.agent_backends/claude_code.pyClaudeCodeBackend. 37 lines. Inherits AgentBridgeBackend; routes by {bot_id}:{user_id} so each user gets an independent Claude session. Routes to claude-code-bridge.agent_backends/codex.pyCodexBackend. 41 lines. Same shape as Claude Code. Routes to codex-bridge. SDK thread_id management is on the bridge side.agent_backends/prompts.pyclients/agent_backend_client.pyAgentBackendClient. 229 lines. Lives under clients/ but is the LLMClient face of this subsystem. Wraps a backend as a normal client so the pipeline never knows the difference.main on 2026-05-13
Source: llm-bawt/src/llm_bawt/agent_backends