BawtHub
⌕ Search ⌘K Source ↗ Open app →
llm-bawt · agent-backends

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.

Path: src/llm_bawt/agent_backends/ Backends: OpenClaw, Claude Code, Codex Transport: Redis streams + RPC

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.

Bridge command/event flow (same for openclaw, claude-code, codex)
llm-bawt · 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.
bridge process · the matching 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.
SDK / gateway · runs the actual model + tools, streams back its native events.
bridge process · normalizes those native events into AgentEvent records and publishes them on a per-request Redis stream.
llm-bawt · subscribes to that per-request stream, yields text deltas + dict-shaped tool events to the SSE generator.

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:

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:

  1. If OPENCLAW_WS_ENABLED and REDIS_URL are both set, instantiate agent_bridge.subscriber.RedisSubscriber and connect.
  2. Walk every bot with agent_backend in (openclaw, claude-code, codex) and build a session_key -> bot_id mapping for the subscriber. Log it at startup.
  3. Stash the subscriber on the service so all AgentBridgeBackend instances can find it via get_agent_subscriber() (a module-level singleton, set by set_agent_subscriber).
  4. Start the background tool-event drain that persists tool_start / tool_end records to tool_call_records for later inspection.
  5. 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:

EndpointEffect
POST /v1/chat/abortLooks 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/resetSends 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:

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.py
AgentBackend. 50 lines. Abstract base — one async chat() method, optional health_check(). The whole plugin contract.
agent_backends/registry.py
Plugin registry. 68 lines. Builtin registration + entry-point discovery via importlib.metadata. get_backend(name) for instance construction.
agent_bridge/
The generic transport package. 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.py
AgentBridgeBackend. ~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.py
OpenClawBackend. ~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.py
ClaudeCodeBackend. 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.py
CodexBackend. 41 lines. Same shape as Claude Code. Routes to codex-bridge. SDK thread_id management is on the bridge side.
agent_backends/prompts.py
Backend prompt fragments. 498 lines. Static system-prompt overlays for each backend — tool-use guidance specific to the SDK's tool catalog.
clients/agent_backend_client.py
AgentBackendClient. 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.
Validated against main on 2026-05-13 Source: llm-bawt/src/llm_bawt/agent_backends