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.
01 Six locked decisions.
The architecture is opinionated. From CODEX_BRIDGE.md, distilled and verified against the bridge implementation:
- OAuth only — no API key.
CODEX_API_KEYandOPENAI_API_KEYare 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.jsonread-write so acodex loginon the host is visible to the bridge through the same inode. - One AsyncCodex per container. A single long-lived
AsyncCodexinstance hosts multiple concurrent threads (one per session), bounded by[agents] max_threadsin~/.codex/config.toml. Per-session ordering is enforced by a sharedSessionQueuelock — the same one OpenClaw uses. - Local plugins are staged at startup. The bridge mirrors
~/dev/agent-skills/codex/.codex-plugin/marketplace.jsoninto~/.agents/plugins/marketplace.jsonand materializes plugin dirs under~/plugins/before the SDK boots. Codex's home-local plugin contract gets the sharedagent-skillsrepo without absolute paths leaking into the repo. - Self-healing supervisor. If the Rust subprocess dies or auth fails mid-turn, the supervisor publishes
ERROR+run_done, tears downAsyncCodex, and rebuilds it on the next request. Rebuild re-readsauth.jsonoff disk — socodex loginon the host self-heals the bridge with nodocker restart. - Reuses
openclaw_bridgeinfrastructure unchanged. SameRedisPublisher,AgentEvent,AgentEventKind,synthesize_event_id,SessionQueue,COMMANDS_STREAM. Consumer group:codex-bridge; filters onbackend == "codex"and ACKs everything else. - Session continuity via
thread_id. The bot'sagent_backend_config.session_keystores Codex'sthread_id— written by the bridge afterthread.startedfires on the first turn. Onchat.sendthe bridge callsthread_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.
┌──────────────────────────────────────────────────┐
│ 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 type | Bridge maps to | Arguments captured |
|---|---|---|
commandExecution | shell | command, cwd |
fileChange | file_change (one event per file) | file_path, kind ("update"/"create"/"delete") |
webSearch | web_search | action, query, url, pattern |
mcpToolCall | the MCP tool's real name (e.g. memory_search) | arguments dict, pass-through |
dynamicToolCall | the dynamic tool's name | arguments dict |
imageView | image_view | path |
agentMessage | (no tool card — accumulates deltas) | — |
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.type | Bridge action |
|---|---|
thread.started | Capture 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.started | No-op. |
item.started | Emit TOOL_START per the mapping above. |
item.delta | Buffer command output, file diff, or message text deltas. agentMessage deltas become ASSISTANT_DELTA. |
item.completed | Emit TOOL_END with the buffered/aggregated result. For commands, the truncated stdout (4KB max) + exit code. |
turn.completed | Emit ASSISTANT_DONE with the full assembled text and token_usage extracted from the SDK's usage payload + the model's context window. |
turn.failed | Emit 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:
- Codex's cleanup job pruned the rollout file (
~/.codex/sessions/YYYY/MM/DD/*.jsonl) the thread referenced. The bridge's own cache cleanup runs every 6 hours and deletes rollouts older than 24 hours. - The thread was created against a different model and the bot's model has since changed.
- The encrypted_content payload Codex requires for resume isn't reproducible across container restarts.
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 var | Default | What it controls |
|---|---|---|
CODEX_HOME | /home/bridge/.codex | Exported to the SDK; matches the auth.json mount. |
CODEX_AUTH_PATH | /home/bridge/.codex/auth.json | OAuth bundle path. |
CODEX_MODEL | gpt-5.4 | Default model when chat.send omits one. |
CODEX_BACKEND_NAME | codex | Redis stream filter value. |
CODEX_BRIDGE_REQUEST_TIMEOUT | 300 | Per-call SDK timeout, seconds. |
CODEX_BRIDGE_CWD | /home/bridge/dev | Codex thread working directory. |
CODEX_LOCAL_PLUGINS_ENABLED | 1 | Stage repo-managed local plugins into ~/.agents + ~/plugins before SDK startup. |
CODEX_BRIDGE_HEALTH_PORT | 8682 | /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:
- Reasoning items (
item/reasoning/*) — the chat UI has no card for them yet, and exposing them would clutter the transcript with model thoughts. Tracked for future surfacing. - Plan and planUpdate items — same reason.
- Approval requests — moot, because
approval_policy="never"means none are ever generated. They'd be dropped anyway.
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.
system_prompt is the same regardless of which SDK runs the turn.11 Key files.
src/codex_bridge/bridge.py_emit_tool_start/_emit_tool_end/_translate_event), token usage extraction, recoverable-error retry, periodic cleanup.src/codex_bridge/transport.pyCodexTransport, validate_auth_json() — refuses any auth_mode other than chatgpt.src/codex_bridge/local_plugins.py~/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.pysrc/codex_bridge/exec_patch.pyCODEX_API_KEY, OPENAI_API_KEY).src/llm_bawt/agent_backends/codex.pyname = "codex"; routing key is {bot_id}:{user_id}, identical to claude-code. Otherwise inherits the entire AgentBridgeBackend.stream_raw() pipeline.main on 2026-05-13
Source: llm-bawt agent backends