A bidirectional client for the Codex App Server, built to replace the Codex plugin forwarder for programmatic dispatch.
The plugin's failure mode is not that it is slow or awkward — it is that it
cannot tell "the agent stopped to ask you a question" from "the agent
finished". Both are recorded as status=completed, so a run that paused for
authorization can sit unnoticed for hours. Everything here is organized around
removing that ambiguity.
The mirror-image failure is also explicit: a nominally running turn that emits
no events for 15 minutes becomes stalled. The default deliberately exceeds a
known legitimate nine-minute silent pytest command while still detecting a
dead turn far sooner than an hour-long watch. Set
CODEX_BRIDGE_STALL_TIMEOUT_MS before starting the daemon to tune the window.
$ codex-bridge dispatch --cwd /repo --prompt 'refactor the parser' --wait
...
run run-mtn7uc8l-40pzr9 is BLOCKED and needs a human answer:
[0] exec: run: /bin/bash -lc 'rm -rf build'
answer with: codex-bridge answer run-mtn7uc8l-40pzr9 --request 0 --answer <value>
$ echo $?
10
The App Server protocol sends ten distinct server→client requests, not just notifications:
| method | kind |
|---|---|
item/commandExecution/requestApproval |
exec |
execCommandApproval |
exec (legacy) |
item/fileChange/requestApproval |
fileChange |
applyPatchApproval |
patch (legacy) |
item/permissions/requestApproval |
permissions |
item/tool/requestUserInput |
userInput |
mcpServer/elicitation/request |
elicitation |
item/tool/call |
toolCall |
account/chatgptAuthTokens/refresh |
internal |
attestation/generate |
internal |
A blocking JSON-RPC request is structurally distinct from a turn/completed
notification. A client that only consumes notifications does not "run without
approvals" — it leaves the request unanswered and the turn hangs forever. That
is why a wrapper cannot recover the distinction after the fact; it has to be a
bidirectional peer.
codex-bridge routes every inbound request into exactly one of three buckets,
and there is deliberately no fourth bucket for "drop it":
- auto — answered immediately from policy, and recorded in the event log
- park — the run enters
blockedand waits for a human - unsupported — answered with an explicit JSON-RPC error, never left hanging
npm install
npm run ci # typecheck + build + test
node dist/cli.js helpRequires Node ≥ 20 and the codex CLI on PATH (verified against 0.152.0).
Runtime dependencies are @modelcontextprotocol/sdk and zod, both used only
by the mcp command. Nothing on the CLI or daemon path imports them, so the
core bridge still runs on the standard library alone.
codex-bridge daemon start
codex-bridge dispatch --cwd /repo --prompt '...' [--wait] [--timeout ms]
[--model m] [--effort e] [--sandbox s] [--approval p]
[--policy kind=mode]... [--auto-approve | --auto-deny]
codex-bridge resume <runId> --prompt '...' [--new-run] [--wait] [--timeout ms]
[--cwd d] [--model m] [--effort e] [--policy kind=mode]...
codex-bridge status <runId>
codex-bridge wait <runId> [--timeout ms]
codex-bridge watch <runId> [--timeout ms] [--retry-unreachable ms]
codex-bridge watch --all [--heartbeat ms] [--retry-unreachable ms]
codex-bridge answer <runId> --request <id> --answer <value|json>
codex-bridge interrupt <runId>
codex-bridge list
codex-bridge events <runId> [--since n] [--limit n]
codex-bridge mcp--sandbox is read-only | workspace-write | danger-full-access.
--approval is untrusted | on-request | never.
By default every inbound request is parked and waits for a human — a bridge
whose whole point is surfacing questions must not answer them by accident.
--policy kind=mode opts specific kinds out of that, and repeats:
codex-bridge dispatch --cwd /repo --prompt 'run the tests' --wait \
--policy exec=approve --policy fileChange=deny| values | |
|---|---|
| kinds | exec, patch, fileChange, permissions, userInput, elicitation, toolCall |
| modes | ask (default, parks), approve, deny |
--auto-approve and --auto-deny set every kind at once and can still be
overridden per kind — --auto-approve --policy fileChange=ask approves
everything but patches. The two blanket switches contradict each other and are
rejected together.
approve cannot manufacture authority the bridge does not have: permissions
and toolCall requests still park under --auto-approve, because a permission
grant and a dynamic tool result have no answer that can be synthesized honestly.
A misspelled kind or mode is a usage error (exit 2) before any run is
created — never a silently ignored flag that leaves a run under a policy the
caller did not write. The effective policy is stored on the run record, so an
auto-approved exec is auditable next to the bridge/autoAnswered event that
used it.
resume sends another turn on a finished run's thread and rebuilds the live
record around it. The thread id is read from the persisted snapshot, so this
works after a daemon restart, a reboot, or a run the daemon has never seen in
this process:
codex-bridge resume run-mtn7uc8l-40pzr9 --prompt 'now update the tests' --wait
codex-bridge resume run-mtn7uc8l-40pzr9 --prompt 'try again' --new-run --waitWithout --new-run the continuation stays under the original runId and the
event log grows append-only across the resume. With --new-run the
continuation gets a fresh runId carrying a bridge/resumedFrom event, and the
original record — which holds the evidence of why a resume was needed — is
left untouched.
A run that is still blocked, running, or stalled is refused rather than resumed. A
second turn on a live thread would orphan the parked approval that made the run
interesting in the first place; answer it or interrupt it first. After the App
Server accepts turn/start, resume also waits up to 10 seconds for real turn
activity (turn/started, an item event, a server request, or completion). If
none arrives, the run is marked failed and the resume call returns the precise
failure instead of a run id that only appears to be progressing. The bounded
window is configurable with CODEX_BRIDGE_RESUME_START_TIMEOUT_MS.
codex-bridge mcp serves the bridge over stdio as an MCP server, exposing
codex_dispatch, codex_resume, codex_status, codex_wait, codex_answer,
codex_interrupt, codex_list, and codex_events.
The MCP server also subscribes to the daemon's control socket and emits a
standard notifications/message logging notification whenever a run changes
state, including when it becomes blocked or stalled, or reaches completed,
failed, or interrupted. The notification data contains { type: "runStateChanged", record }. Delivery is best-effort: disconnected clients miss events, and the
daemon neither buffers them nor waits for delivery, so a dropped MCP client can
never delay or damage a run.
{ "command": "node", "args": ["/path/to/codex-bridge/dist/cli.js", "mcp"] }A model has no exit code to read, so unhealthy states are carried twice on
every run-shaped result: the first line is the same unmistakable summary the
CLI prints, while structuredContent.needsHuman and
structuredContent.stalled are booleans to branch on. These fields are always
present, so callers reading only prose or only structure reach the same
conclusion.
A completed run can also carry proseQuestionHint: true. This is an advisory,
not a state: it never changes completed, needsHuman, terminality, or exit
code 0, because prose has no parked requestId that codex_answer could
answer. Packet authors can make the hint deterministic by putting exactly one
of these machine-readable lines in the final message:
CODEX_BRIDGE_PROSE_QUESTION_HINT: true
CODEX_BRIDGE_PROSE_QUESTION_HINT: false
That exact, case-sensitive marker is authoritative. When it is absent, the
fallback scans the whole message and flags ?, the full-width ?, or an
explicit please authorize, please confirm, please choose, please decide,
please provide, please clarify, please advise, please tell, or please select. It can miss indirect asks and languages that use neither question-mark
form, and it can flag rhetorical or quoted questions anywhere in a completed
report. When present, the first-line summary also says that the final message
may be asking for a response.
codex_wait is bounded — 60s by default, 240s maximum. When the window
elapses before the run settles, the result says the run has not finished, sets
timedOut: true and exitCode: 11, and tells the caller to call again. A wait
that ran until the MCP client's own timeout would surface as a transport error
instead of a run state, losing exactly the information the tool exists to
report.
MCP logging notifications cannot wake an idle supervising model: the bridge can
deliver notifications/message to the host, but the host may not schedule the
model to read it. For a reliable process-level wake-up, launch
codex-bridge watch <runId> in the background. watch repeatedly crosses the
daemon's bounded wait windows until the run is completed, blocked, stalled,
failed, or interrupted, then its process exits with the semantic code below. A
stall timer wakes an outstanding wait immediately; watch does not have to
finish its current window first. --timeout
sets the size of each bounded daemon wait window; it is not an overall deadline.
If the daemon becomes unreachable, watch alone retries the connection with
backoff for up to --retry-unreachable milliseconds (300000ms by default).
That retry window measures a continuous outage and resets after any successful
daemon response. If the window expires, watch exits 14 and reports how long it
retried. Set --retry-unreachable 0 to restore fail-fast behaviour. This only
reconnects: watch never starts a daemon. An already-settled or unknown run
returns promptly because a daemon response is never retried. The existing
wait and codex_wait commands remain bounded and fail fast on an outage.
For coverage across every live run, use watch --all. It subscribes to the
daemon's state-transition stream and takes a startup snapshot, so a run that
settles during setup is still observed. The first line names the run that
caused exit and uses the same summary as watch <runId>. When several runs have
settled together, the selected outcome follows this precedence:
BLOCKED > STALLED > FAILED > INTERRUPTED > OK; the run id breaks ties within
an outcome. watch --all never starts a daemon and uses the same
--retry-unreachable outage window as the single-run watcher.
--heartbeat <ms> is opt-in. If the interval elapses without a settled run,
watch --all exits 16 and reports a heartbeat. With no heartbeat, an empty
startup snapshot exits 16 with "no in-flight runs; nothing to report". This
is deliberately distinct from exit 0, which means a run completed. The flag
must be a positive number of milliseconds.
When a shell script must expose the watcher's exit code, make watch the final
command in that script. In watch --all; echo "$?", echo prints the status
saved from watch, but the shell reports echo's own successful exit (0) to
the caller, so the watcher's status is lost.
| code | meaning |
|---|---|
| 0 | completed |
| 2 | usage error |
| 10 | BLOCKED — needs a human answer |
| 11 | still running |
| 12 | failed |
| 13 | interrupted |
| 14 | daemon unreachable |
| 15 | STALLED — no event activity beyond the configured threshold |
| 16 | aggregate heartbeat or no in-flight runs — nothing to report |
A caller that only checks for exit 0 still will not mistake a blocked or stalled run for a finished one. A caller that reads the code learns exactly what happened without parsing prose. Existing codes retain their prior meanings.
The prose-question advisory deliberately has no new exit code. It is uncertain
and unanswerable through codex_answer; changing a completed run's exit status
would break existing shell callers and blur that protocol distinction.
A daemon that answered and refused exits 2, not 14. "The daemon is gone" and "the daemon said no" are different problems with different fixes, and collapsing them would send a caller chasing a socket failure that never happened.
codex-bridge answer <runId> --request 0 --answer accept
codex-bridge answer <runId> --request 0 --answer approve-session
codex-bridge answer <runId> --request 0 --answer decline
codex-bridge answer <runId> --request 2 --answer '{"q1": ["us-east-1"]}' # requestUserInputIf the App Server cancels or replaces a parked request, the upstream may remove
that request without a codex-bridge answer call. The bridge records and
reports requests while they exist, but consumers must not assume a pending
entry disappears only because a human answered it; refresh status after an
upstream cancellation.
codex-bridge will not synthesize an approval it cannot express. Permission
grants and dynamic tool results can only be declined, because inventing them
would fabricate authority the user never gave. It says so and errors out rather
than guessing.
codex-bridge daemon is a detached process that owns the
codex app-server --stdio child. CLI invocations are transient clients over a
unix control socket. A dropped SSH session or a killed terminal takes out the
client, not the run — which is the failure that previously destroyed hours of
finished work.
Run state is derived from App Server events and from the child process itself.
There is no self-maintained PID file that callers are asked to trust: a stale
running record pointing at a dead PID is exactly the bookkeeping that blocks a
resume and has to be hand-repaired.
While the daemon is alive, each run records lastEventAtMs,
stallThresholdMs, and the explicit stalled boolean. App Server activity
resets the inactivity timer; status reads and derived bridge diagnostics do
not. Crossing the threshold appends bridge/stalled without rewriting prior
events, sets state stalled, persists the snapshot, publishes the transition,
and wakes waiters. Later real activity can return the live run to running.
Each run is persisted under $CODEX_BRIDGE_HOME (default ~/.codex-bridge):
runs/<runId>/record.json current snapshot, rewritten on each change
runs/<runId>/events.jsonl append-only log
The snapshot is written after the log, so a crash mid-write leaves the log ahead of the snapshot rather than the reverse. A snapshot must never claim more progress than the log can substantiate.
On startup the daemon reconciles runs that a previous daemon left non-terminal.
A running, blocked, or stalled record with no process behind it is a lie,
so it is marked failed
with a bridge/orphaned event and an error that names the state it died in and
the exact codex-bridge resume command that picks the work back up — the
resume-blocking bookkeeping is repaired rather than described.
A failed write to a run's log or snapshot degrades that one run instead of
taking down the daemon: the reason is recorded on the record's storeError and
echoed to stderr. These writes happen inside the transport's data handler, where
an unguarded throw escapes as an uncaught exception and kills every unrelated
run along with it — the precise durability failure this daemon exists to
prevent. A hole in a transcript is reported, never presented as a short but
complete log.
Only codex app-server --stdio is used, with newline-delimited JSON framing.
This was settled empirically rather than assumed. The unix-socket transports —
the daemon control socket, codex app-server proxy (with and without --sock),
and a self-managed --listen unix:// — all accept a connection but return
nothing for newline JSON, Content-Length framing, or raw bodies. They
presumably expect an undocumented handshake. Rather than guess, this bridge uses
the transport it can actually verify and supplies durability itself.
Unix domain socket paths are capped at 108 bytes, and the failure is a bare
path must be shorter than SUN_LEN that reads like a permissions problem. The
control socket therefore lives at $XDG_RUNTIME_DIR/codex-bridge-<uid>/control.sock
— falling back to /tmp when the runtime dir is itself long — and never under
the (long) bridge home. Relative runtime directories and socket overrides are
rejected or fall back to /tmp; override with an absolute CODEX_BRIDGE_SOCKET.
A daemon on a custom socket is intentionally not auto-stopped when idle: it may
own a blocked run indefinitely. Stop it explicitly with codex-bridge daemon stop --socket /path/to/control.sock (or with the same CODEX_BRIDGE_SOCKET
environment value used to start it). The daemon also records its socket path in
$CODEX_BRIDGE_HOME/daemon.json; there is no safe global scan that can discover
an arbitrary socket and home pair.
cwd, sandbox, and approvalPolicy are always sent explicitly on
thread/start. Inheriting the working directory from whichever shell happened
to launch the process is how a dispatch ends up pointed at the wrong repository,
and a hardcoded sandbox mode is how a run dies on a host whose bubblewrap cannot
create user namespaces.
- An unparseable line on the transport calls
onTransportError; it is never skipped. Treating unparseable output as "nothing happened" is how truncated or fabricated transcripts get mistaken for success. - When the App Server child exits, every non-terminal run is marked
failedwith the real exit code, signal, and a bounded stderr tail. Empty stderr is reported as "stderr was empty", not omitted — the absence of a diagnostic is itself a diagnostic. - Interrupting a run answers every parked request with a decline before interrupting, so the server side is never left waiting on a client that has already walked away.
- An interrupted turn is reported as
interrupted(exit 13), not flattened intofailed(exit 12). The protocol has a first-classinterruptedturn status; reporting it as a failure would send a caller hunting for an error that does not exist. - An
item/startedevent for a collaborationwaitwhosereceiverThreadIdsis empty adds a non-fatalempty_wait_receiverswarning to the record and abridge/warningevent. It remains diagnostic: the bridge neither answers nor fails the turn on its own.
npm run ci runs its checks through scripts/ci.sh. That entrypoint creates
temporary directories outside the checkout and overrides TMPDIR,
NODE_COMPILE_CACHE, and npm's cache for every child process. This keeps a
caller-supplied value such as TMPDIR=. from depositing test or compiler
artifacts in the repository.
| variable | meaning |
|---|---|
CODEX_BRIDGE_HOME |
run store root (default ~/.codex-bridge) |
CODEX_BRIDGE_SOCKET |
control socket path |
CODEX_BIN |
codex executable (default: codex on PATH) |
CODEX_BRIDGE_STALL_TIMEOUT_MS |
event inactivity threshold (default 900000, 15 minutes) |
CODEX_BRIDGE_RESUME_START_TIMEOUT_MS |
resumed-turn activity deadline (default 10000, 10 seconds) |
src/protocol/ is generated by codex app-server generate-ts and is never
hand-edited. npm run regen-protocol regenerates it and re-runs
scripts/fix-protocol-imports.mjs, a deterministic codemod that appends .js
to the extensionless relative imports ts-rs emits (and resolves directory
re-exports to /index.js), which moduleResolution: nodenext otherwise
rejects. Hand-patching a generated file would silently diverge on the next
regeneration.
Feature-complete against its original scope: the MCP server surface,
thread/resume on the CLI, and per-kind policy flags all landed.
Verified end to end against codex 0.152.0 on Linux:
- happy path —
dispatch --waitreturnedcompleted, exit 0 - blocked path — an approval request parked the run,
waitreturnedblockedwith exit 10 and the pending question surfaced;answer --answer acceptreleased it and the run completed with exit 0 - policy — the same prompt under
--policy exec=approvecompleted without ever parking;--policy exce=approveexited 2 with the valid-kinds list before a daemon was even started - resume —
resume --promptkept the thread's context;resume --new-runafter a fulldaemon stopproduced a fresh runId on the same thread and left the original record intact;resumeon a blocked run was refused
npm run ci is green. Beyond the unit tests, test/bridge.test.js
drives the real control socket against a scripted App Server stand-in
(test/fixtures/fake-app-server.mjs), because the behaviours that matter here —
a parked request, a resume across a daemon restart, a run orphaned by a dead
daemon — only exist in the seam between the daemon, the store, and a peer that
sends requests back. Three bugs were found that way and fixed: an interrupted
turn reported as failed, a daemon refusal reported as an unreachable daemon,
and an unguarded event-log write that could kill the daemon.