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:

  1. /codex:review node process A spawn codex app-server do work both die
  2. /codex:status node process B spawn another one do work both die again
  3. /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:

  1. ┌─ resident, survives across commands ─┐
  2. /codex:review proc A ─┤
  3. /codex:status proc B ─┼→ broker.sock broker process ─→ codex app-server
  4. /codex:cancel proc C ─┤ (single instance, always alive)
  5. └──────────────────────────────────────┘

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):

  1. const result = await appClient.request(message.method, message.params ?? {});
  2. 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)

  1. Read broker.json to get the endpoint → attempt to connect with a 150ms timeout. If connected, reuse it directly, no new process is spawned.
  2. If the connection fails, it indicates leftover state — first call teardownBrokerSession to clean up the old socket / pid / tmpdir.
  3. mkdtemp a session directory → spawn(node, [broker.mjs, "serve", ...], { detached: true, stdio: [ignore, logFd, logFd] }), then child.unref(). detached + unref are the key here: once the parent process (that Bash command) exits, the broker keeps running.
  4. 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, and thread/compact/start are streaming methods — even after the response comes back, the lock isn’t released; it’s held until turn/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:cancel would 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:

  1. function buildTurn(id, status = "inProgress", error = null) {
  2. return { id, status, items: [], error };
  3. }

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. // 1. Request
  2. client.request("turn/start", {
  3. threadId,
  4. input: [{ type: "text", text: prompt, text_elements: [] }],
  5. model, effort, outputSchema
  6. })
  7. // 2. Returns immediately, status is inProgress, items is empty
  8. { turn: { id: "turn_1", status: "inProgress", items: [], error: null } }
  9. // 3. The actual content is all delivered via the notification stream
  10. turn/started { threadId, turn }
  11. item/started { threadId, turnId, item }
  12. item/completed { threadId, turnId, item } repeats many times
  13. item/started { threadId, turnId, item }
  14. 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/interrupt are 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, and thread/compact/start are streaming methods — the caller (or the broker in between) must hold the lock from when the response returns until turn/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:

  1. Main thread turn_1
  2. ├─ item: collabAgentToolCall receiverThreadIds: [thr_a, thr_b]
  3. ├─ thr_a turn_2 (sub-agent, independent notification stream)
  4. └─ 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 a threadIds (Set) and threadTurnIds (Map) for this purpose.
  • The completion signal may never arrive. The main thread may have already emitted an agentMessage with phase: "final_answer", but the sub-agent’s turn hasn’t drained yet, so turn/completed is slow to arrive. The plugin patches this: once it sees final_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.