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

A different SDK. Same envelope.

The Codex bridge is the third agent backend in llm-bawt, and the one that proved the bridge pattern was actually a pattern. Different SDK (OpenAI's openai_codex_sdk, talking JSON-RPC 2.0 over stdio to a bundled Rust binary), different auth (ChatGPT-mode OAuth via ~/.codex/auth.json), different item types (commandExecution, fileChange, webSearch, mcpToolCall). And yet — the main API doesn't have to know any of that. Same Redis stream, same AgentEvent envelope, same SSE generator on the other side.

Upstream: OpenAI Codex SDK + bundled Rust binary (static-pie musl) Auth: ChatGPT subscription OAuth (Plus/Pro/Team) Concurrency: 1 AsyncCodex per container · [agents] max_threads threads inside

01 Six locked decisions.

The architecture is opinionated. From CODEX_BRIDGE.md, distilled and verified against the bridge implementation:

  1. OAuth only — no API key. CODEX_API_KEY and OPENAI_API_KEY are scrubbed from the bridge's environment at startup. The Rust binary self-refreshes the OAuth bundle on disk; the bridge bind-mounts the host's ~/.codex/auth.json read-write so a codex login on the host is visible to the bridge through the same inode.
  2. One AsyncCodex per container. A single long-lived AsyncCodex instance hosts multiple concurrent threads (one per session), bounded by [agents] max_threads in ~/.codex/config.toml. Per-session ordering is enforced by a shared SessionQueue lock — the same one OpenClaw uses.
  3. Local plugins are staged at startup. The bridge mirrors ~/dev/agent-skills/codex/.codex-plugin/marketplace.json into ~/.agents/plugins/marketplace.json and materializes plugin dirs under ~/plugins/ before the SDK boots. Codex's home-local plugin contract gets the shared agent-skills repo without absolute paths leaking into the repo.
  4. Self-healing supervisor. If the Rust subprocess dies or auth fails mid-turn, the supervisor publishes ERROR + run_done, tears down AsyncCodex, and rebuilds it on the next request. Rebuild re-reads auth.json off disk — so codex login on the host self-heals the bridge with no docker restart.
  5. Reuses openclaw_bridge infrastructure unchanged. Same RedisPublisher, AgentEvent, AgentEventKind, synthesize_event_id, SessionQueue, COMMANDS_STREAM. Consumer group: codex-bridge; filters on backend == "codex" and ACKs everything else.
  6. Session continuity via thread_id. The bot's agent_backend_config.session_key stores Codex's thread_id — written by the bridge after thread.started fires on the first turn. On chat.send the bridge calls thread_resume(). Model change → fresh thread. /new → fresh thread. Resume failures (thread not found, Missing required parameter: input[N].encrypted_content, no rollout found) → clear session + retry once.

02 Authentication: ChatGPT, not platform.

The Codex CLI exists in two modes — API key (metered) and ChatGPT (subscription). The bridge accepts only the latter. On startup it parses ~/.codex/auth.json and hard-fails if tokens.auth_mode isn't "chatgpt":

{
  "tokens": {
    "auth_mode": "chatgpt",
    "access_token": "...",
    "refresh_token": "...",
    "expires_at": "2026-05-13T18:30:00Z"
  },
  "installation_id": "...",
  ...
}

Operationally this means the only way to (re)authenticate is npm install -g @openai/codex && codex login on the host, which opens a browser for OAuth and writes a fresh auth.json. The bridge picks up the new bundle the next time it spawns AsyncCodex.

One slightly painful detail the doc gets right: the pip wheel for openai-codex-sdk ships without the actual Rust binary. So the compose service bind-mounts the host's @openai/codex/node_modules/@openai/codex-linux-x64/vendor directory into the SDK's expected path inside the container. The binary is static-pie musl and runs on any glibc/musl Linux — no in-container install needed.

03 Topology, with Codex's own concurrency.

Codex bridge · request flow
┌──────────────────────────────────────────────────┐
│  llm-bawt-app (FastAPI)                          │
│  CodexBackend.stream_raw()                       │
│  → send_command(backend="codex",                 │
│       session_key="codex:nick", message, model)  │
└─────────────────────┬────────────────────────────┘
                      │ XADD agent:commands
                      ▼
┌──────────────────────────────────────────────────┐
│  codex-bridge container                          │
│   • XREADGROUP group="codex-bridge"              │
│   • filter backend == "codex"                    │
│   • SessionQueue.lock(session_key) — per-bot     │
│   • _get_session(bot) → thread_id from Postgres  │
│   • single AsyncCodex (process-wide)             │
│       codex.start_thread(opts) | resume_thread() │
│       → thread.run_streamed(prompt, signal)      │
│   • async-iter ThreadEvent ➝ AgentEvent       │
└─────────────────────┬────────────────────────────┘
                      │ XADD agent:run:{req_id}
                      ▼
                 API SSE subscriber

The thread.start_thread options the bridge hard-codes are worth quoting in full:

thread_options = {
    "model":             model,
    "working_directory": self._cwd,            # /home/bridge/dev
    "approval_policy":   "never",              # no human-in-the-loop prompts
    "sandbox_mode":      "danger-full-access", # trust the agent
    "skip_git_repo_check": True,
}

approval_policy="never" + sandbox_mode="danger-full-access" is the unattended-service stance: Codex inside the bridge can do anything inside its bind mounts. If a human needed approval flows they'd be using the chat UI, not the bridge.

04 Item types: Codex's vocabulary.

Where Claude Code surfaces ToolUseBlock with a Claude-CLI tool name like Bash or Edit, Codex surfaces ThreadEvents with a different vocabulary. The bridge translates them at _emit_tool_start() and _emit_tool_end():

Codex item typeBridge maps toArguments captured
commandExecutionshellcommand, cwd
fileChangefile_change (one event per file)file_path, kind ("update"/"create"/"delete")
webSearchweb_searchaction, query, url, pattern
mcpToolCallthe MCP tool's real name (e.g. memory_search)arguments dict, pass-through
dynamicToolCallthe dynamic tool's namearguments dict
imageViewimage_viewpath
agentMessage(no tool card — accumulates deltas)
fileChange is one event per file, not per turn.

The bridge expands a single Codex fileChange item into N TOOL_START/TOOL_END pairs — one per touched file — so the frontend renders a card per file in chronological order. This matches the way Claude Code surfaces individual Edit and Write tool uses, even though the underlying SDK shapes are different.

The interim trick is that the tool-card UI was originally written for Claude Code's tool names. Rather than build a parallel renderer set for Codex, the bridge aliases its item types to Claude-style tool names, and the frontend's existing ClaudeToolCallCard.tsx renders them. The cost: a Codex file_change card shows just path + status badge ("Updated"/"Created"/"Deleted"), no diff — because Codex's SDK doesn't expose patch text the way Claude's tool result does. The proper per-provider tool dispatch system is tracked as TASK-212 internally.

05 Thread lifecycle events.

Codex emits a ThreadEvent stream with its own lifecycle types:

ThreadEvent.typeBridge action
thread.startedCapture thread_id; if this is the first turn for the bot, write it to agent_backend_config.session_key via the bots API. Subsequent turns will thread_resume() on it.
turn.startedNo-op.
item.startedEmit TOOL_START per the mapping above.
item.deltaBuffer command output, file diff, or message text deltas. agentMessage deltas become ASSISTANT_DELTA.
item.completedEmit TOOL_END with the buffered/aggregated result. For commands, the truncated stdout (4KB max) + exit code.
turn.completedEmit ASSISTANT_DONE with the full assembled text and token_usage extracted from the SDK's usage payload + the model's context window.
turn.failedEmit ERROR with the error message. The supervisor decides whether it's a recoverable session error (clear thread, retry) or a hard failure (publish run_done, surface to user).

06 Recoverable resume failures.

The most common Codex failure isn't an outage — it's a stale thread_id. Reasons it goes stale:

The bridge detects these via the error strings thread not found, thread_id is invalid, Missing required parameter: input[N].encrypted_content, and no rollout found. On any of those, the bridge clears session_key via the bots API, drops resume_id, and re-runs start_thread() as a fresh conversation — once. The user never sees an error. The follow-up turn looks like a normal first turn from the agent's perspective.

07 chat.abort and AbortController.

The bridge wires the SDK's AbortController into the shared SessionQueue. When the API publishes a chat.abort command for a session_key, the bridge looks up the active AbortController for that session and calls controller.abort("chat.abort"). The SDK propagates that signal into the Rust subprocess, which interrupts the in-flight turn server-side.

The bridge then surfaces chat.abort: session=<sk> detail=turn_interrupted in its logs and publishes ASSISTANT_DONE with whatever text accumulated before the abort. The user's turn ends with status interrupted in the UI rather than just freezing.

08 Configuration knobs.

Env varDefaultWhat it controls
CODEX_HOME/home/bridge/.codexExported to the SDK; matches the auth.json mount.
CODEX_AUTH_PATH/home/bridge/.codex/auth.jsonOAuth bundle path.
CODEX_MODELgpt-5.4Default model when chat.send omits one.
CODEX_BACKEND_NAMEcodexRedis stream filter value.
CODEX_BRIDGE_REQUEST_TIMEOUT300Per-call SDK timeout, seconds.
CODEX_BRIDGE_CWD/home/bridge/devCodex thread working directory.
CODEX_LOCAL_PLUGINS_ENABLED1Stage repo-managed local plugins into ~/.agents + ~/plugins before SDK startup.
CODEX_BRIDGE_HEALTH_PORT8682/health TCP port.

~/.codex/config.toml controls Codex itself, and the bridge expects at minimum:

model = "gpt-5.4"
model_reasoning_effort = "high"

[agents]
max_threads = 10

The max_threads setting is what caps concurrency across all Codex bots on this host. Same-bot concurrent sends are serialized by the SessionQueue lock; different-bot concurrent sends proceed in parallel up to this cap.

09 What the bridge intentionally drops.

Codex emits more event types than the chat UI knows what to do with. The bridge suppresses (silently drops) three categories:

Cost telemetry is the one piece genuinely not exposed: Codex's SDK doesn't surface per-turn cost the way Claude's SDK does, so the total_cost_usd field on ASSISTANT_DONE.token_usage is always None for codex turns. Input/output/cache token counts are surfaced.

10 Codex as a bot persona.

BawtHub bot roster showing chatbots Mira, Nova, Proto, Spark and agent bots Byte, Caid, Codex, Loopy, Snark, Vex
/bots — the roster. Top row chatbots; bottom row agents. Codex is the canonical Codex-backed bot; Caid is Claude-Code-backed; Loopy and Snark typically run on OpenClaw. The personality system is upstream of the backend — the bot's system_prompt is the same regardless of which SDK runs the turn.

11 Key files.

src/codex_bridge/bridge.py
The bridge. ~1,630 lines. Session map, supervisor, event translator (_emit_tool_start/_emit_tool_end/_translate_event), token usage extraction, recoverable-error retry, periodic cleanup.
src/codex_bridge/transport.py
Auth + transport glue. CodexTransport, validate_auth_json() — refuses any auth_mode other than chatgpt.
src/codex_bridge/local_plugins.py
Plugin staging. Mirrors ~/dev/agent-skills/codex/.codex-plugin/marketplace.json into ~/.agents/plugins/ at startup. Resolves skill entries to live repo paths.
src/codex_bridge/parser_patch.py
SDK monkey-patch. Patches SDK internals where event shapes drifted from what the bridge expects. Used as a quick-turn fix mechanism between SDK releases.
src/codex_bridge/exec_patch.py
Subprocess wrapper. Locates the bundled Codex Rust binary and wires up env var scrubbing (CODEX_API_KEY, OPENAI_API_KEY).
src/llm_bawt/agent_backends/codex.py
The backend. 42 lines. name = "codex"; routing key is {bot_id}:{user_id}, identical to claude-code. Otherwise inherits the entire AgentBridgeBackend.stream_raw() pipeline.
Validated against main on 2026-05-13 Source: llm-bawt agent backends