How the Codex Plugin Implements Turn Capture and Notification

The Codex plugin wraps every backend interaction in a turn, generates unique IDs via nextTurnId in state.mjs, emits turn/started and turn/completed JSON-RPC events, and routes notifications through app-server-broker.mjs to clients via WebSocket.

The openai/codex-plugin-cc repository provides a reference implementation for building conversational AI plugins that require strict observability. Understanding the turn capture and notification system reveals how the plugin maintains state across asynchronous operations and notifies external consumers like VS Code panels or test harnesses in real time.

Understanding the Turn Capture Architecture

The plugin treats every discrete unit of work—a command sent to the Codex backend, a sub-agent invocation, or a tool execution—as a turn. Each turn receives a unique identifier and transitions through a well-defined lifecycle that mirrors JSON-RPC request-response patterns.

Turn State Management in state.mjs

Central to the system is plugins/codex/scripts/lib/state.mjs, which maintains a monotonic counter (nextTurnId) to generate identifiers such as turn_1, turn_2, and so forth. When a command handler initiates work, it calls nextTurnId(state) to obtain a fresh ID before emitting the initial event.

The module also exports buildTurn(turnId, status, description), a factory function that constructs the turn payload (lines 165–174). This payload includes the turn ID, the current status (started or completed), and minimal metadata describing the operation. According to the source, emitTurnStarted and emitTurnCompleted wrap this builder and immediately invoke the send helper to dispatch JSON-RPC messages to the broker.

// plugins/codex/scripts/lib/state.mjs (lines 165-174)
export function emitTurnStarted(state, threadId, description) {
  const turnId = nextTurnId(state);
  const turn = buildTurn(turnId, "started", description);
  send({ method: "turn/started", params: { threadId, turn } });
  return turnId;
}

export function emitTurnCompleted(state, threadId, turnId) {
  const turn = buildTurn(turnId, "completed");
  send({ method: "turn/completed", params: { threadId, turn } });
}

Turn vs. Item Events

While turns represent top-level conversational units, items represent granular steps within a turn—such as individual tool calls or streaming deltas. The plugin emits parallel item/started and item/completed events that reference the parent turn ID. This hierarchical event structure allows consumers to render nested progress indicators without parsing the turn payload itself.

The Notification Broker System

Once state.mjs generates a turn event, the plugin delegates routing to plugins/codex/scripts/app-server-broker.mjs. This broker acts as a message router between the plugin’s internal state machine and external clients connected via WebSocket.

Message Routing via app-server-broker.mjs

The broker’s handleMessage function (lines 70–90) inspects incoming JSON-RPC method names. When it encounters turn/started or turn/completed, it forwards the payload to all registered listeners. The implementation maintains a Set of client connections and iterates through them, serializing the turn object to JSON before transmission.

// plugins/codex/scripts/app-server-broker.mjs (lines 70-90)
function handleMessage(message, clients) {
  if (message.method?.startsWith('turn/')) {
    clients.forEach(client => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message));
      }
    });
  }
}

WebSocket Delivery in app-server.mjs

Underpinning the broker is plugins/codex/scripts/lib/app-server.mjs, which manages the WebSocket server lifecycle and connection state. This module handles connection upgrades, heartbeat pings, and reconnection logic, ensuring that turn/completed notifications survive transient network interruptions. The separation of concerns—state generation in state.mjs, routing in app-server-broker.mjs, and transport in app-server.mjs—allows developers to swap transport layers without modifying turn logic.

Implementation Walkthrough: Capturing a Turn

Consider a command handler defined in plugins/codex/commands/review.md. To capture the entire review operation as a turn, the implementation follows this sequence:

  1. Initialize the turn by calling emitTurnStarted.
  2. Execute the Codex API call or sub-agent tasks.
  3. Emit item events for intermediate steps.
  4. Finalize with emitTurnCompleted.
// plugins/codex/commands/review.md (conceptual handler)
import { emitTurnStarted, emitTurnCompleted } from '../scripts/lib/state.mjs';

export async function handleReview(state, threadId, prompt) {
  const turnId = emitTurnStarted(state, threadId, { command: 'review', prompt });
  
  try {
    const result = await callCodexApi(prompt);
    // Intermediate item events omitted for brevity
    emitTurnCompleted(state, threadId, turnId);
    return result;
  } catch (error) {
    // Error handling ensures turn/completed still fires
    emitTurnCompleted(state, threadId, turnId);
    throw error;
  }
}

This pattern ensures that even if the underlying API call throws an exception, the notification system receives a definitive turn/completed event, preventing UI clients from displaying indefinite loading states.

Summary

  • Turn Identity: The nextTurnId counter in plugins/codex/scripts/lib/state.mjs guarantees monotonic, unique identifiers for every discrete operation.
  • Event Semantics: turn/started signals the beginning of work, while turn/completed guarantees closure, both emitted via JSON-RPC through the send helper.
  • Broker Architecture: app-server-broker.mjs decouples state management from transport, forwarding turn events to WebSocket clients via handleMessage.
  • Hierarchical Observability: Turns encapsulate lower-level item/* events, enabling rich, nested progress visualization without tight coupling.
  • Resilient Delivery: The underlying app-server.mjs WebSocket implementation ensures notifications survive network interruptions through connection management and heartbeat mechanisms.

Frequently Asked Questions

What constitutes a "turn" in the Codex plugin?

A turn represents a single logical interaction between the user and the Codex backend, such as executing a review command or running a setup script. Each turn receives a unique ID (e.g., turn_1) and emits lifecycle events that allow external observers to track when work begins and ends, regardless of how many intermediate API calls or sub-agents are involved.

How does the plugin generate unique turn identifiers?

The plugin maintains a monotonic counter in the global state object managed by plugins/codex/scripts/lib/state.mjs. The nextTurnId(state) function increments this counter and returns a formatted string like turn_${counter}, ensuring that every turn ID is unique within the current session and sortable by creation time.

What is the difference between turn events and item events?

Turn events (turn/started, turn/completed) bookend entire operations, while item events (item/started, item/completed) track granular steps within a turn, such as individual tool invocations or streaming tokens. The item events include a reference to the parent turn ID, enabling consumers to render nested progress trees without parsing the full turn payload.

How does the UI receive turn notifications in real time?

The UI connects to the plugin via WebSocket through the endpoint managed by plugins/codex/scripts/lib/app-server.mjs. When app-server-broker.mjs receives a turn event from state.mjs, it broadcasts the JSON-RPC message to all connected WebSocket clients. The UI subscribes to these messages and updates its view upon receiving turn/completed, ensuring synchronous state between the plugin backend and the interface.

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 →