How the Codex Plugin Interacts with the Codex App Server: Architecture and Implementation
The Codex plugin communicates with the Codex app server through a JSON‑RPC protocol that supports two transport modes—direct STDIN/STDOUT spawn or Unix domain socket broker connection—with all operations orchestrated through CodexAppServerClient.connect in app-server.mjs.
The openai/codex-plugin-cc repository implements a Claude Code plugin that delegates code review and task execution to OpenAI's Codex CLI. Understanding how the Codex plugin interacts with the Codex app server reveals a layered architecture designed for flexibility: you can spawn ephemeral app-server processes or share a persistent broker across multiple Claude sessions. This article examines the transport selection, JSON‑RPC protocol, and high-level API that make this integration work.
Transport Selection: Direct Spawn vs. Broker Connection
The entry point for all communication is CodexAppServerClient.connect() in plugins/codex/scripts/lib/app-server.mjs. This static method determines which transport to use based on environment configuration and options.
// plugins/codex/scripts/lib/app-server.mjs
static async connect(cwd, options = {}) {
let brokerEndpoint = null;
if (!options.disableBroker) {
brokerEndpoint = options.brokerEndpoint ??
options.env?.[BROKER_ENDPOINT_ENV] ??
process.env[BROKER_ENDPOINT_ENV] ??
null;
// Re‑use an existing broker session if requested …
if (!brokerEndpoint && options.reuseExistingBroker) {
brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null;
}
// …or create a fresh broker session.
if (!brokerEndpoint && !options.reuseExistingBroker) {
const brokerSession = await ensureBrokerSession(cwd, { env: options.env });
brokerEndpoint = brokerSession?.endpoint ?? null;
}
}
const client = brokerEndpoint
? new BrokerCodexAppServerClient(cwd, { …options, brokerEndpoint })
: new SpawnedCodexAppServerClient(cwd, options);
await client.initialize();
return client;
}
The method evaluates three sources for a broker endpoint:
options.brokerEndpoint– explicit configuration passed by the calleroptions.env[BROKER_ENDPOINT_ENV]orprocess.env[BROKER_ENDPOINT_ENV]– the environment variableCODEX_COMPANION_APP_SERVER_ENDPOINTloadBrokerSession(cwd)– a persisted session file from a previous broker launch
If no broker endpoint is found and disableBroker is falsy, the code calls ensureBrokerSession() from broker-lifecycle.mjs to start a new broker. Otherwise, it falls back to direct mode.
Direct Mode: SpawnedCodexAppServerClient
When brokerEndpoint is null, the plugin spawns a fresh codex app-server process:
// SpawnedCodexAppServerClient implementation (app-server.mjs lines 68-75)
sendMessage(message) {
if (!this.childProcess) throw new Error("Child process not available");
this.childProcess.stdin.write(JSON.stringify(message) + "\n");
}
This client writes JSON‑RPC messages to the child process's STDIN and reads responses from STDOUT. The process terminates when client.close() is called, making this mode suitable for one-off operations.
Broker Mode: BrokerCodexAppServerClient
When a broker endpoint is available, the plugin connects via Unix domain socket:
// BrokerCodexAppServerClient implementation (app-server.mjs lines 26-33)
sendMessage(message) {
if (!this.socket) throw new Error("Socket not connected");
this.socket.write(JSON.stringify(message) + "\n");
}
The broker persists across multiple plugin invocations, enabling session reuse and background task management. Closing this client only terminates the socket—the broker process continues running.
JSON‑RPC Client Implementation: AppServerClientBase
Both transport subclasses inherit from AppServerClientBase, which implements the protocol logic in plugins/codex/scripts/lib/app-server.mjs:
| Feature | Implementation Details |
|---|---|
| Request/Response correlation | request(method, params) generates unique IDs, stores {resolve, reject} pairs in this.pending, and returns a Promise (lines 86-98) |
| Notification handling | setNotificationHandler(callback) registers async message receivers for events like item/* and turn/* (lines 76-78) |
| Line-delimited JSON parsing | handleChunk buffers incoming data, handleLine splits on newlines and parses JSON (lines 107-155) |
| Graceful shutdown | handleExit resolves or rejects all pending promises and records exit errors (lines 63-76) |
| Transport abstraction | Subclasses implement sendMessage(message) for their specific channel |
The base class enforces one JSON line per message, the standard for JSON‑RPC over streams. This design keeps the protocol simple while supporting both synchronous request-response cycles and asynchronous server-pushed notifications.
High-Level API: Operations in codex.mjs
The file plugins/codex/scripts/lib/codex.mjs builds convenient helpers on top of the JSON‑RPC client. These functions are what the slash-commands actually invoke.
Running a Code Review: runAppServerReview
// plugins/codex/scripts/lib/codex.mjs
export async function runAppServerReview(cwd, options = {}) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
throw new Error("Codex CLI is not installed …");
}
return withAppServer(cwd, async (client) => {
emitProgress(options.onProgress, "Starting Codex review thread.", "starting");
const thread = await startThread(client, cwd, {
model: options.model,
sandbox: "read-only",
ephemeral: true,
threadName: options.threadName,
});
const sourceThreadId = thread.thread.id;
const turnState = await captureTurn(
client,
sourceThreadId,
() =>
client.request("review/start", {
threadId: sourceThreadId,
delivery,
target: options.target,
}),
{ onProgress: options.onProgress, … }
);
return {
status: buildResultStatus(turnState),
threadId: turnState.threadId,
sourceThreadId,
turnId: turnState.turnId,
reviewText: turnState.reviewText,
reasoningSummary: turnState.reasoningSummary,
stderr: cleanCodexStderr(client.stderr),
};
});
}
This function demonstrates the standard pattern:
withAppServer(lines 13-42) – acquires a client viaCodexAppServerClient.connect, executes the callback, then guarantees cleanupstartThread(lines 332-348) – sends"thread/start"RPC with configuration, optionally followed by"thread/name/set"captureTurn(lines 559-611) – the most complex helper: installs a temporary notification filter, aggregates messages intoTurnCaptureState, and resolves when"turn/completed"arrives
The captureTurn helper is particularly important—it ensures that concurrent turns don't interfere by routing only matching notifications to the active capture while forwarding others to any previously installed handler.
Running a Generic Turn: runAppServerTurn
Similar to runAppServerReview, but sends "turn/start" with user-provided input. It first checks whether to resume an existing thread (via options.threadId) or create a new one, making it suitable for interactive task delegation.
Interrupting Execution: interruptAppServerTurn
A simple fire-and-forget RPC that sends "turn/interrupt", used by the /codex:cancel command to abort in-progress work.
Practical Code Examples
Direct Spawn Without Broker
import { CodexAppServerClient } from "./app-server.mjs";
async function demoDirect(cwd) {
// Force direct mode – no broker endpoint
const client = await CodexAppServerClient.connect(cwd, { disableBroker: true });
await client.initialize(); // starts `codex app-server`
const info = await client.request("account/read", {});
console.log("Account:", info);
await client.close(); // gracefully shuts down the child process
}
Connecting to an Existing Broker
import { CodexAppServerClient } from "./app-server.mjs";
async function demoBroker(cwd) {
// Uses default broker detection from environment
const client = await CodexAppServerClient.connect(cwd);
const status = await client.request("status", {});
console.log("Broker status:", status);
await client.close(); // socket closes; broker process continues
}
Manual Turn Capture
import { withAppServer, captureTurn } from "./codex.mjs";
async function demoTurn(cwd, prompt) {
return withAppServer(cwd, async (client) => {
const thread = await client.request("thread/start", { cwd });
const threadId = thread.thread.id;
const turnState = await captureTurn(
client,
threadId,
() => client.request("turn/start", {
threadId,
input: [{ type: "text", text: prompt }],
})
);
console.log("Final message:", turnState.lastAgentMessage);
return turnState;
});
}
Key Source Files
| File | Purpose |
|---|---|
plugins/codex/scripts/lib/app-server.mjs |
Transport selection, JSON‑RPC base classes, broker/direct implementations |
plugins/codex/scripts/lib/codex.mjs |
Public API: runAppServerReview, runAppServerTurn, captureTurn, withAppServer |
plugins/codex/scripts/lib/broker-lifecycle.mjs |
Broker session management: ensureBrokerSession, loadBrokerSession |
plugins/codex/scripts/lib/process.mjs |
Process utilities: binaryAvailable, terminateProcessTree |
plugins/codex/commands/*.md |
Slash-command definitions that invoke the helpers |
Summary
- The Codex plugin selects transport dynamically via
CodexAppServerClient.connect, choosing between direct STDIN/STDOUT spawn or Unix socket broker connection based on environment and options. - All communication uses line-delimited JSON‑RPC, with
AppServerClientBasehandling request correlation, notification routing, and graceful shutdown. - High-level operations in
codex.mjsorchestrate thread lifecycle, turn execution, and result aggregation while insulating command implementations from protocol details. - The architecture supports both ephemeral, isolated sessions (direct mode) and persistent, shared runtimes (broker mode) without changing the calling code.
Frequently Asked Questions
How does the plugin choose between direct and broker mode?
The plugin checks for a broker endpoint in this priority order: explicit options.brokerEndpoint, the CODEX_COMPANION_APP_SERVER_ENDPOINT environment variable, or a persisted session file. If none are found and disableBroker is not set, it starts a new broker. Otherwise, it spawns the app-server directly. This logic is centralized in CodexAppServerClient.connect().
What happens to pending requests when the app-server exits?
AppServerClientBase.handleExit() (lines 63-76) iterates through all pending promises in this.pending and rejects them with an appropriate error. This ensures that in-flight operations fail fast rather than hanging indefinitely when the underlying transport closes.
Can multiple Claude Code sessions share the same Codex app-server?
Yes. When using broker mode, the Unix domain socket allows multiple clients to connect to a single persistent app-server process. The reuseExistingBroker option and loadBrokerSession() helper enable this sharing by reading endpoint information from a session file stored in the working directory.
What JSON‑RPC methods does the app-server expose?
The plugin invokes methods including "thread/start", "thread/name/set", "turn/start", "review/start", "turn/interrupt", "account/read", and "status". Notifications like "item/*" and "turn/completed" stream progress back to the client. The full protocol is defined by the codex app-server binary, not the plugin code.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →