The Brain: Inside the Core Engine

Part 3 of "Anatomy of an AI Coding Agent: Dissecting the OpenAI Codex CLI"

We've journeyed from the 10,000-foot view of Codex's architecture down to the nervous system that carries signals between components. Now it's time to step inside the skull and examine the brain itself

The Codex Orchestrator: The Main Brain

At the absolute core of everything sits this deceptively simple struct:

pub struct Codex {
    pub(crate) tx_sub: Sender<Submission>,
    pub(crate) rx_event: Receiver<Event>,
    pub(crate) agent_status: watch::Receiver<AgentStatus>,
    pub(crate) session: Arc<Session>,
    pub(crate) session_loop_termination: SessionLoopTermination,
}

Three public-facing queues and a reference to the session. That's the entire API surface. Everything you need to command an AI agent in 5 lines of code.

But inside that Session struct, hidden behind async mutexes and channels, lives a sprawling 7000+ line codex.rs file that orchestrates one of the most complex state machines ever built. Let me pull back the veil.

The Session: The Persistent Memory

Inside the Codex struct lives an Arc<Session>:

pub(crate) struct Session {
    pub(crate) conversation_id: ThreadId,
    tx_event: Sender<Event>,
    agent_status: watch::Sender<AgentStatus>,
    state: Mutex<SessionState>,
    features: ManagedFeatures,
    pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
    pub(crate) services: SessionServices,
    js_repl: Arc<JsReplHandle>,
}

The Session is the persistent memory of a conversation. It holds conversation history, configuration, active turns, and all the subsystems (file watchers, MCP managers, skills managers, etc.). The Arc is crucial—this session is shared across multiple async tasks.


The Submission Loop: The Heartbeat

When Codex::spawn initializes the system, it creates two async channels and spawns a submission loop:

async fn submission_loop(
    sess: Arc<Session>,
    config: Arc<Config>,
    rx_sub: Receiver<Submission>,
) {
    while let Ok(sub) = rx_sub.recv().await {
        match sub.op.clone() {
            Op::UserTurn { items, ... } => {
                handlers::user_input_or_turn(&sess, sub.id, sub.op).await;
            }
            Op::ExecApproval { decision, ... } => {
                handlers::exec_approval(&sess, decision).await;
            }
            Op::Interrupt => {
                handlers::interrupt(&sess).await;
            }
            Op::Shutdown => break,
            // ... 30+ more Op variants
        }
    }
}

This loop is the heartbeat of the entire system. It runs for the lifetime of the session, waiting for submissions to arrive. Each submission is a request to do something: "Run a user prompt," "Approve this command," "Undo the last turn," "Refresh MCP servers."

The Turn: A Single Round Trip

Let's trace what happens when a user submits a turn with Op::UserTurn. A turn has a complete lifecycle:

Turn Lifecycle

TurnStarted → items flow → TurnCompleted

Events are emitted at the start, during execution (streaming), and at the end.

Building the Prompt: The Assembly Pipeline

Before the model can think, the orchestrator must assemble a prompt from many sources. This is where context becomes gold:

fn assemble_prompt(
    turn_ctx: &TurnContext,
    conversation_history: &[TurnItem],
    tools: &[ToolSpec],
    skills: &[SkillMetadata],
) → Prompt {
    let mut messages = Vec::new();

    // 1. System prompt: Who are you?
    messages.push(Message {
        role: "system",
        content: build_system_prompt(&turn_ctx),
    });

    // 2. Conversation history: What have we talked about?
    for item in conversation_history {
        messages.push(Message {
            role: item.role,
            content: item.text,
        });
    }

    // 3. Tool definitions: What can you call?
    let tool_definitions =
        create_tools_json_for_responses_api(tools);

    // 4. Skills context: Custom capabilities?
    let skills_section = render_skills_section(skills);

    // 5. MCP tools: External tools?
    let mcp_tools = render_mcp_tools(turn_ctx);

    Prompt {
        messages,
        tools: tool_definitions,
        max_tokens: 16000,
    }
}


The prompt is a layered construction. Each layer adds context. The model sees the full picture: who it is, what it's been asked to do, and what tools it can use.

The ModelClient: Talking to OpenAI

Now we have a prompt. How does it get to the model?

pub struct ModelClient {
    state: Arc<ModelClientState>,
}

pub struct ModelClientSession {
    client: ModelClient,
    // Cached request for incremental updates
    last_request: Option<ResponsesApiRequest>,
    // Sticky routing token for this turn
    turn_state: Option<String>,
    // WebSocket connection (created lazily)
    websocket: Option<ApiWebSocketConnection>,
}

The ModelClient is a session-scoped wrapper around the OpenAI Responses API. When we call client.stream_response(prompt):

// 1. Build the API request
let api_request = ResponsesApiRequest {
    messages: prompt.messages,
    tools: prompt.tools,
    model: "claude-opus-4.6",
    temperature: 0.7,
    max_tokens: 16000,
};

// 2. Get authentication
let auth = self.get_auth().await?;

// 3. Attempt WebSocket (preferred)
if let Some(ws) = self.get_or_create_websocket(&auth).await? {
    return self.stream_via_websocket(ws, api_request).await;
}

// 4. Fall back to SSE (Server-Sent Events)
self.stream_via_sse(&auth, api_request).await

Two transport options exist: WebSocket (preferred, persistent connection) and Server-Sent Events (fallback, HTTP streaming). The client tries WebSocket first. If it disconnects, future turns automatically fall back to SSE.

Streaming Responses: Chunks Arrive in Real Time

Once the connection is established, the model starts outputting tokens. The response arrives as a stream of events:

while let Some(event) = response_stream.next().await {
    match event {
        ResponseEvent::ContentBlockDelta(ContentDelta::Text { text }) => {
            // Text token arrived. Stream to UI.
            agent_message_buffer.push_str(&text);
            sess.send_event(Event {
                msg: EventMsg::AgentMessageDelta(text),
            }).await;
        }
        ResponseEvent::FunctionCall { name, arguments, id } => {
            // Model wants to call a tool!
            handle_tool_call(name, arguments, id).await;
        }
        ResponseEvent::Done { finish_reason } => {
            // Stream ended.
            break;
        }
    }
}

The key insight: nothing waits for the full response. Text arrives in chunks, and each chunk is immediately emitted as an event. The UI streams text to the user in real time. It's all happening concurrently.

The Tool Call Loop: Execution Under Control

When the model decides to run a shell command, it sends a function call. Here's the loop:


The beauty is structured interleaving: the model generates text, requests a tool, the orchestrator executes it, the result comes back, and the model continues from where it left off.

MCP Connections: Bringing External Tools In

Codex doesn't have to know about every possible tool. Instead, it uses the Model Context Protocol (MCP) to connect to external tool servers:

pub struct McpConnectionManager {
    connections: Arc<RwLock<HashMap<String, McpConnection>>>,
    services: Arc<SessionServices>,
}

pub async fn get_or_create(
    &self,
    server_name: &str,
) → CodexResult<Arc<McpConnection>> {
    // Check if already connected
    if let Some(conn) = self.connections.read().await.get(server_name) {
        return Ok(Arc::clone(conn));
    }

    // Load config, launch server, establish connection, cache...
}

MCP is the bridge to the wider ecosystem. GitHub, Slack, databases, custom APIs—all accessible through a standard protocol.

The Safety Layer: Approval Before Execution

One of the most important features is approval gating:

pub enum ApprovalPolicy {
    AlwaysAllow,      // Auto-approve (dangerous!)
    AskAlways,        // Show every command
    AskIfDangerous,   // Show risky commands
}

The user always has the final say over what code runs. The model can suggest, but cannot command.

The Cliffhanger: Who Stops the Danger?

The model generates:

"I'll clean up the temporary files."

call_tool("sh", { "command": "rm -rf /" })

Codex emits an ExecApprovalRequest event with a big red warning. The user sees it. They reject it.

But what if the approval policy was AlwaysAllow? What if the user made a mistake and approved it?

The Critical Question

Who stops a malicious or mistaken command from running?

The Sandbox.

Next time, we'll open up the vault. We'll see how Codex isolates code execution, enforces filesystem constraints, limits network access, and ensures that even if the model or the user makes a catastrophic mistake, the damage is contained.

The brain may decide to run rm -rf / . But the sandbox will laugh and deny the request.

Originally published on LinkedIn.