How the Codex Plugin Communicates with the Codex App Server: JSON-RPC Protocol Explained
The Codex plugin communicates with the Codex app server using a JSON-RPC protocol over either a direct STDIN/STDOUT pipe or a Unix domain socket via a broker, implemented in app-server.mjs and orchestrated through high-level helpers in codex.mjs.
The open-source openai/codex-plugin-cc repository enables Claude Code to interact with the Codex CLI through a sophisticated communication layer. Understanding how the Codex plugin communicates with the Codex app server is essential for developers extending or debugging the integration. The architecture relies on three distinct layers: transport selection, JSON-RPC message handling, and high-level API abstraction.
Transport Selection: Direct vs. Broker Mode
The entry point for all communication is CodexAppServerClient.connect() in plugins/codex/scripts/lib/app-server.mjs. This static method determines whether to spawn a fresh app-server process or connect to an existing broker session.
Direct mode spawns the codex app-server binary as a child process and communicates over STDIN/STDOUT pipes. This is implemented by the SpawnedCodexAppServerClient class.
Broker mode connects to a Unix domain socket managed by a persistent broker process. This uses the BrokerCodexAppServerClient class and is triggered when the CODEX_COMPANION_APP_SERVER_ENDPOINT environment variable (referenced internally as BROKER_ENDPOINT_ENV) is set.
// 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;
if (!brokerEndpoint && options.reuseExistingBroker) {
brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null;
}
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 broker approach allows multiple Claude Code sessions to share a single Codex app-server instance, while direct mode provides isolation per invocation.
JSON-RPC Protocol Implementation
Once the transport is established, both client subclasses inherit from AppServerClientBase to handle the JSON-RPC protocol encoding and decoding. This base class manages request/response correlation and notification dispatching.
Request handling occurs through the request(method, params) method, which generates a unique ID, stores a {resolve, reject} pair in this.pending, and sends the JSON line to the transport.
Notification handling uses setNotificationHandler() to register callbacks for async messages like item/* and turn/* events.
Line buffering and parsing is handled by handleChunk() and handleLine() (lines 107-155), which split incoming data on newlines and parse JSON. Error handling and graceful shutdown are managed by handleExit() (lines 63-76), which resolves or rejects all pending promises when the connection closes.
Transport-specific sending is abstracted through the sendMessage(message) method, implemented differently in each subclass:
SpawnedCodexAppServerClient.sendMessage()(lines 68-75) writes to STDINBrokerCodexAppServerClient.sendMessage()(lines 26-33) writes to the socket
High-Level API for Thread and Turn Management
The file plugins/codex/scripts/lib/codex.mjs builds on the JSON-RPC client to expose convenient helpers for the plugin commands. These functions manage the lifecycle of threads and turns.
withAppServer() (lines 13-42) opens a client via CodexAppServerClient.connect(), executes the provided async function, and ensures the client closes properly afterward.
startThread() (lines 332-348) sends the "thread/start" RPC and optionally "thread/name/set" to initialize a new conversation context.
captureTurn() (lines 559-611) installs a temporary notification handler that filters messages for a specific turn, aggregates them into a TurnCaptureState, and resolves when receiving "turn/completed". It forwards unrelated notifications to the previous handler to maintain plugin functionality.
Running a Review
The runAppServerReview() function demonstrates the complete communication flow:
// 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),
};
});
}
runAppServerTurn() follows a similar pattern but decides whether to resume an existing thread or start fresh, then sends "turn/start" with the user prompt. interruptAppServerTurn() provides a fire-and-forget "turn/interrupt" RPC for cancellation commands.
Practical Communication 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) {
// Assumes CODEX_COMPANION_APP_SERVER_ENDPOINT is set
const client = await CodexAppServerClient.connect(cwd);
const status = await client.request("status", {});
console.log("Broker status:", status);
await client.close(); // closes socket; broker stays alive
}
Capturing Turn Output
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;
});
}
Summary
- The plugin selects between direct spawn (STDIN/STDOUT) and broker mode (Unix domain socket) via
CodexAppServerClient.connect()inplugins/codex/scripts/lib/app-server.mjs. - All communication uses JSON-RPC with one JSON line per message, handled by the
AppServerClientBaseclass. - Request/response correlation and notification dispatching are managed through internal maps and handler chains.
- High-level operations in
plugins/codex/scripts/lib/codex.mjsabstract thread creation, review execution, and turn management into reusable async functions. - The architecture supports both isolated per-command execution and shared broker sessions for multi-process workflows.
Frequently Asked Questions
What protocol does the Codex plugin use to communicate with the app server?
The Codex plugin uses JSON-RPC (JSON Remote Procedure Call) over line-delimited JSON streams. Each request, response, and notification is encoded as a single JSON line sent either through STDIN/STDOUT pipes (direct mode) or a Unix domain socket (broker mode). This protocol is implemented in plugins/codex/scripts/lib/app-server.mjs.
What is the difference between broker mode and direct mode?
Direct mode spawns a new codex app-server process for each connection and communicates via STDIN/STDOUT, providing process isolation. Broker mode connects to a persistent Unix domain socket managed by a separate broker process, allowing multiple Claude Code sessions to share one app-server instance. The broker endpoint is controlled by the CODEX_COMPANION_APP_SERVER_ENDPOINT environment variable.
How does the plugin handle asynchronous notifications from the app server?
The AppServerClientBase class in app-server.mjs provides setNotificationHandler() to register callbacks for async events like item/* and turn/*. The captureTurn() function in codex.mjs temporarily intercepts these notifications to aggregate turn-specific data while forwarding unrelated messages to maintain global plugin state.
How can I interrupt a running operation in the Codex app server?
The plugin exposes interruptAppServerTurn(), which sends a fire-and-forget "turn/interrupt" JSON-RPC request to the app server. This method is invoked by the /codex:cancel command and does not wait for a response, immediately signaling the server to stop the current turn.
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 →