Recently I’ve been learning about communication between Agents, and my first impression was that Codex had previously open-sourced a codex-plugin-cc, which lets Claude Code directly call Codex. I thought it used some general-purpose Agent-to-Agent communication method, but after studying it a bit, I found that’s not the case — it simply supports calling Codex from within Claude Code through a plugin. In this article, I’ll walk through its implementation.
Overall Architecture

Codex App Server
To support reusing the Codex process across multiple invocations, the plugin adopts a client-server (CS) architecture rather than a subprocess architecture. Specifically, when Claude Code calls Codex, instead of simply spawning a Codex child process to execute the task, it first launches a process called the Codex App Server, then connects to the App Server via a Client, sending commands to the App Server for execution over a JSON-RPC protocol.
Also, since Claude Code runs Codex tasks in the background, Codex introduces something called a Broker for concurrency control. Besides concurrency control, the Broker also supports operations like interruption. Let’s go through these concepts in detail below.
Broker: The Middleman That Sublets an stdio Subprocess
The Broker is a small resident server that exclusively holds a single codex app-server, then sublets it out — via a unix socket (a named pipe on Windows) — to short-lived processes that don’t outlive a single command.
What Problem It Solves
codex app-server is an stdio subprocess — it dies the moment its parent process dies. But every Bash invocation from Claude Code is a brand-new node process. So without a broker:
/codex:review → node process A → spawn codex app-server → do work → both die/codex:status → node process B → spawn another one → do work → both die again/codex:cancel → node process C → ...and the turn it wants to interrupt is already gone
Every time you’d have to pay the cold-start cost all over again: loading the binary, parsing config, refreshing the auth token, starting the MCP server (the most expensive part if MCP is configured). Worse still, cross-command operations become completely impossible — process B can’t interrupt a turn in process A, because that Codex instance no longer exists.
With a broker in place:
┌─ resident, survives across commands ─┐/codex:review → proc A ─┤ │/codex:status → proc B ─┼→ broker.sock → broker process ─→ codex app-server/codex:cancel → proc C ─┤ (single instance, always alive)└──────────────────────────────────────┘
What It Is
In plain terms, the app-server is a background web server that spins up the first time it’s needed and stops when the session ends (via a hook). The code file scripts/app-server-broker.mjs is only 252 lines long, and it does two things.
1. Protocol Adaptation
On one side, net.createServer receives JSONL over the socket; on the other side, it forwards it verbatim to the app-server child process it holds, and relays the result back. In essence it’s an stdio ↔ socket adapter, with identical framing on both sides (one JSON object per line):
const result = await appClient.request(message.method, message.params ?? {});send(socket, { id: message.id, result });
2. Notification Routing
Notifications emitted by the app-server (item/completed, turn/completed, …) carry no recipient information, so the broker needs to know which client to send them to — hence it maintains activeRequestSocket / activeStreamSocket, and releases ownership upon seeing turn/completed.
For initialize requests sent by clients themselves, it responds in place (returning userAgent: "codex-companion-broker") rather than passing it further downstream — because the downstream app-server has already completed its handshake.
Lifecycle
Startup
Code: ensureBrokerSession(cwd)
- Read
broker.jsonto get the endpoint → attempt to connect with a 150ms timeout. If connected, reuse it directly, no new process is spawned. - If the connection fails, it indicates leftover state — first call
teardownBrokerSessionto clean up the old socket / pid / tmpdir. mkdtempa session directory →spawn(node, [broker.mjs, "serve", ...], { detached: true, stdio: [ignore, logFd, logFd] }), thenchild.unref().detached+unrefare the key here: once the parent process (that Bash command) exits, the broker keeps running.- Poll the endpoint for up to 2s; it’s only considered ready once a connection succeeds. If it times out, tear it down and return null, and the client falls back to a direct spawn.
Shutdown
The SessionEnd hook sends broker/shutdown → the broker closes all sockets, shuts down the app-server, calls server.close(), and unlinks the socket file and pid file.
Concurrency Conflicts
An app-server can only run one turn at a time, so the broker adds a single-occupancy lock:
- If another socket already holds it → it immediately returns
-32001 "Shared Codex broker is busy". Upon receiving this code, the client automatically falls back to a direct spawn of an independent instance. (Handled by the concurrency-conflict flow below.) turn/start,review/start, andthread/compact/startare streaming methods — even after the response comes back, the lock isn’t released; it’s held untilturn/completed, because notifications are still streaming out.- The sole exception is
turn/interrupt: it’s allowed to cut in even while someone else holds the lock. Without this exception,/codex:cancelwould forever just get “busy” and could never interrupt anything.
Interaction Flow

At this point, I actually had a question: since a conflict just triggers creating your own app-server, is the broker even necessary? Indeed, looking at this diagram, when a second client comes in, in many cases it simply spawns another app-server directly — so what’s the point of the broker existing at all? Going back to the earlier section, we find that the broker does have one role: allowing a stop when the session ends, and this is done via an externally sent command that then triggers an internal, self-initiated stop.
If a client spawns its own app-server, it has no such mechanism on its own — it can only exit once the task finishes executing. If we run into a problem partway through and want to terminate or exit early, there’s no way for the outside to intervene; we can only wait quietly for it to exit on its own.
Connection Form
lib/broker-endpoint.mjs generates this per platform, and both forms can be fed directly into net.createConnection({ path }):
- macOS / Linux:
unix:/var/folders/…/cxc-XXXX/broker.sock - Windows:
pipe:\\.\pipe\cxc-XXXX-codex-app-server
Using a socket instead of a TCP port avoids port allocation and conflicts, and permissions are handled directly via the filesystem.
An Easy-to-Miss Detail
The broker is scoped by workspace (the git repo root), not by Claude session — the path to broker.json comes from resolveStateDir(cwd) → resolveWorkspaceRoot(cwd). So if you open two Claude sessions in the same repo, they share one broker; whichever session ends first will shut it down, and the next time the other session sends a command, ensureBrokerSession‘s liveness check will fail and it’ll pull up a new one. It’s self-healing, but it pays an extra cold-start cost in between.
Also, if you bypass codex-companion.mjs and directly import the high-level functions from lib/codex.mjs, runAppServerTurn will pull up the broker via ensureBrokerSession, but there’s no SessionEnd hook to clean up after you — the process will just linger. This usage pattern requires you to manually call sendBrokerShutdown + teardownBrokerSession + clearBrokerSession before exiting.
A Turn
As mentioned earlier, the app-server’s occupancy cycle is scoped to a turn — so what exactly is a turn? A turn is a very common concept in agents; you can think of it as the completion of a single instruction. For example, when we send an instruction or prompt to Codex/Claude Code, you’ll notice it keeps working until it finally gives you a conclusion or asks what to do next — that step is a turn.
Data Structure
A turn itself is quite simple — the constructor in the test fixtures is its complete shape:
function buildTurn(id, status = "inProgress", error = null) {return { id, status, items: [], error };}
Four fields: id, status, the things done during this round, and error. status is inProgress at the start, and completed (or a failed/canceled state) at the end.
The Lifecycle Is Asynchronous
A point that’s very easy to misunderstand: the response to turn/start is not the result — it’s just a receipt.
// 1. Requestclient.request("turn/start", {threadId,input: [{ type: "text", text: prompt, text_elements: [] }],model, effort, outputSchema})// 2. Returns immediately, status is inProgress, items is empty{ turn: { id: "turn_1", status: "inProgress", items: [], error: null } }// 3. The actual content is all delivered via the notification streamturn/started { threadId, turn }item/started { threadId, turnId, item } ┐item/completed { threadId, turnId, item } ├ repeats many timesitem/started { threadId, turnId, item } ┘turn/completed { threadId, turn: { status: "completed" } } ← only now is it truly done
Note that input is an array, with elements carrying a type field (currently text is used) — the protocol leaves room for multimodal input.
The entire purpose of those 200-odd lines of captureTurn in the plugin is to re-aggregate this pile of loose notifications from step 3 back into a single returnable result: the final answer, the reasoning summary, the files changed, the commands run.
Item: Every Action Within a Turn
Each step of a turn is an item, pushed in pairs via item/started and item/completed. Known types:
| item.type | Meaning | Information Carried |
|---|---|---|
agentMessage |
Assistant message | text, phase (final_answer marks the final response) |
reasoning |
Reasoning summary | summary |
commandExecution |
A command was run | command, exitCode, status |
fileChange |
A file was changed | changes[].path |
webSearch |
A search was performed | query |
mcpToolCall |
An MCP tool was called | server, tool |
dynamicToolCall |
A dynamic tool was called | tool |
collabAgentToolCall |
Spawns a sub-agent | receiverThreadIds |
enteredReviewMode / exitedReviewMode |
Entering/exiting the built-in reviewer | review text |
The structured results the caller receives — touchedFiles, commandExecutions, and so on — are filtered out from items of the corresponding type. Progress hints (Running command: …, Applying N file change(s)) also come from here — it even uses regex to judge whether a command looks like a test/lint/build, marking the phase as verifying accordingly.
Why the Turn Is a Critical Boundary
It serves as the unit for three things simultaneously:
- 1. The unit of interruption. The parameters for
turn/interruptare exactly{ threadId, turnId }. Without the concept of a turn, there’d be no notion of “graceful stop” at all — you could only SIGKILL the whole process. - 2. The lock-holding period.
turn/start,review/start, andthread/compact/startare streaming methods — the caller (or the broker in between) must hold the lock from when the response returns untilturn/completed, because notifications are still streaming out. - 3. The boundary of success and failure. The exit code is determined solely by
finalTurn.status === "completed", nothing else.
The Counterintuitive Part: Turns Can Nest Turns
The collabAgentToolCall item carries a set of receiverThreadIds — when Codex spawns a sub-agent, the sub-agent opens its own turn within its own thread.
So what you think of as “one turn” might actually, underneath, be several threads each running their own turns in parallel:
Main thread turn_1├─ item: collabAgentToolCall → receiverThreadIds: [thr_a, thr_b]│├─ thr_a turn_2 (sub-agent, independent notification stream)└─ thr_b turn_3 (sub-agent, independent notification stream)
This brings two direct consequences:
- The notification stream is multiplexed. The consumer must distinguish which thread each notification belongs to by
threadId, or the sub-agent’s output will get mixed into the main thread’s result. The plugin maintains athreadIds(Set) andthreadTurnIds(Map) for this purpose. - The completion signal may never arrive. The main thread may have already emitted an
agentMessagewithphase: "final_answer", but the sub-agent’s turn hasn’t drained yet, soturn/completedis slow to arrive. The plugin patches this: once it seesfinal_answer, if the set of pending sub-agents is empty, it waits 250ms and force-decides completion. This “inferred completion” logic is essentially a fallback for the protocol’s uncertain behavior.