How the Codex Plugin Interacts with the Codex App Server Using JSON-RPC

The Codex plugin communicates with the codex app-server binary via a JSON-RPC protocol over either STDIN/STDOUT pipes (direct spawn) or a Unix domain socket (broker mode), using a client library that handles request/response tracking, notification streaming, and graceful shutdowns.

The openai/codex-plugin-cc repository implements a Claude Code plugin that orchestrates AI-powered code reviews and task execution. Understanding how the Codex plugin interacts with the Codex app server reveals a sophisticated three-layer architecture that manages transport selection, JSON-RPC message encoding, and high-level workflow orchestration through specific methods in app-server.mjs and codex.mjs.

Transport Selection: Direct Spawn vs. Broker Mode

The interaction begins in plugins/codex/scripts/lib/app-server.mjs, where CodexAppServerClient.connect() determines whether to spawn a new process or reuse an existing broker session.

The Connection Entry Point

The static method CodexAppServerClient.connect(cwd, options) evaluates environment variables and options to select the transport mechanism. It checks for CODEX_COMPANION_APP_SERVER_ENDPOINT (referenced internally as BROKER_ENDPOINT_ENV) to decide between broker and direct modes.

// 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;
}

Broker Mode (Unix Domain Socket)

When a broker endpoint is available, the client instantiates BrokerCodexAppServerClient, which opens a Unix domain socket using net.createConnection. This mode allows multiple Claude sessions to share a single codex app-server process, reducing overhead and maintaining state across invocations. The sendMessage implementation (lines 26-33) writes JSON lines directly to the socket.

Direct Mode (STDIN/STDOUT)

If no broker endpoint exists or disableBroker is set, the client creates a SpawnedCodexAppServerClient that spawns the binary directly using spawn("codex", ["app-server"], …). Communication occurs over the child process's STDIN and STDOUT pipes, creating an isolated per-invocation session. The sendMessage method (lines 68-75) handles transport-specific writing to STDIN.

JSON-RPC Protocol Implementation

Both transport implementations inherit from AppServerClientBase in app-server.mjs, which implements the JSON-RPC wire protocol independently of the underlying transport.

Core Client Architecture

The base class provides essential RPC functionality:

  • request(method, params) (lines 86-98) – Creates a unique request ID, stores a {resolve, reject} pair in this.pending, and sends the JSON line via sendMessage().
  • setNotificationHandler (lines 76-78) – Registers callbacks to receive async notifications such as item/* and turn/* events.
  • handleChunk / handleLine (lines 107-155) – Split incoming data on newlines and parse JSON, routing responses to pending requests or notification handlers.
  • handleExit (lines 63-76) – Catches process termination, rejects all pending promises with the exit error, and records the error state for graceful degradation.

This architecture ensures that whether communicating via socket or pipe, the JSON-RPC semantics remain consistent.

High-Level Workflow Orchestration

The file plugins/codex/scripts/lib/codex.mjs builds convenience helpers atop the JSON-RPC client to handle complex multi-step workflows like reviews and task execution.

Running Code Reviews

The runAppServerReview function demonstrates the full orchestration flow. It uses withAppServer (lines 13-42) to manage client lifecycle, startThread (lines 332-348) to send the "thread/start" RPC, and captureTurn to aggregate streaming notifications into a final result.

// 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;
    // …setup progress…
    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),
    };
  });
}

The captureTurn function (lines 559-611) installs a temporary notification handler that filters messages for the specific turn ID, aggregates them into a TurnCaptureState, and resolves when receiving "turn/completed". It forwards unrelated notifications to the previous handler to ensure other plugin functions remain operational.

Executing Task Turns

runAppServerTurn follows a similar pattern but first decides whether to resume an existing thread or start fresh, then sends "turn/start" with the user prompt. This supports interactive task delegation where the plugin delegates coding tasks to the Codex app server.

Interrupting Operations

For cancellation support, interruptAppServerTurn sends a fire-and-forget "turn/interrupt" RPC. This method returns immediately without awaiting a response, allowing the /codex:cancel command to terminate long-running operations instantly.

Practical Implementation 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 a Shared Broker

import { CodexAppServerClient } from "./app-server.mjs";

async function demoBroker(cwd) {
  // Assume a broker is already running; no `disableBroker` flag
  const client = await CodexAppServerClient.connect(cwd);
  const status = await client.request("status", {}); // any supported RPC
  console.log("Broker status:", status);
  await client.close(); // just closes the socket; the broker stays alive
}

Capturing a Turn Manually

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 socket) via CodexAppServerClient.connect() in plugins/codex/scripts/lib/app-server.mjs.
  • JSON-RPC messages are encoded/decoded by AppServerClientBase, which manages request IDs, pending promises in this.pending, and notification routing through setNotificationHandler.
  • High-level helpers in plugins/codex/scripts/lib/codex.mjs such as runAppServerReview, runAppServerTurn, and captureTurn orchestrate threads, turns, and result aggregation.
  • The architecture supports both ephemeral per-invocation sessions (via SpawnedCodexAppServerClient) and persistent shared runtimes (via BrokerCodexAppServerClient and ensureBrokerSession).

Frequently Asked Questions

What transport protocols does the Codex plugin support?

The Codex plugin supports two transport mechanisms for interacting with the Codex app server: direct process spawning using STDIN/STDOUT pipes, and Unix domain sockets via a broker. The selection is handled automatically by CodexAppServerClient.connect() based on the presence of the CODEX_COMPANION_APP_SERVER_ENDPOINT environment variable.

How does the plugin handle asynchronous notifications from the app server?

The AppServerClientBase class implements setNotificationHandler() to register callbacks for async messages like turn/completed or item/*. During active turns, the captureTurn function temporarily intercepts these notifications to aggregate state while forwarding unrelated messages to maintain plugin functionality across concurrent operations.

Can multiple Claude Code sessions share the same Codex app server instance?

Yes. When running in broker mode, the plugin connects to a persistent Unix domain socket managed by the broker lifecycle utilities in broker-lifecycle.mjs. This allows multiple sessions to share a single codex app-server process, maintaining state and reducing startup overhead compared to spawning a new process per invocation.

What happens if the app server process exits unexpectedly?

The handleExit method in AppServerClientBase (lines 63-76) catches process termination, rejects all pending promises stored in this.pending with the exit error, and triggers cleanup. This ensures that hanging request() calls fail gracefully rather than remaining unresolved indefinitely, preventing the plugin from freezing during communication failures.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →