← All writing

CodeX Architect Part 3 - Subagents

In its Navier–Stokes research report, OpenAI describes teams of AI agents trying different approaches to the same math problem in parallel. The researchers used Codex to summarize what each team found. They then shared those summaries with other teams, so the teams could use each other’s results.

I want to trace a similar handoff inside Codex’s own subagent system: a parent sends a task, and a child sends back an answer. In CodeX Architect Part 1 - Context Engineering, we followed one conversation’s model loop. Here, spawn_agent lets the parent start another conversation, with the child running its own model loop. Codex has two multi-agent implementations, V1 and V2, with different ways to pass work and return results, so we compare them before following a task through the code.

(Source note: we inspect the local implementation at commit 221e4e7 (October 16, 2026). Code excerpts are verbatim. Tool arguments and the returned findings are illustrative.)

Table of Contents

Codex’s V1 and V2 implementations

Codex has two versions of its multi-agent implementation, named V1 and V2 in the MultiAgentVersion enum. These labels apply to agent delegation and coordination, not to Codex as a whole. The memory pipeline’s V1 and V2 versions are a separate selection.

Both multi-agent versions create child conversations that run the normal Codex model loop. V2 uses a mailbox, a queue of incoming messages for each conversation, and changes how agents are addressed and managed.

DimensionV1V2
AddressingThread IDs.Task paths, such as /root/audit.
Initial historyFresh by default; fork_context requests a copy.Copies parent history by default; fork_turns can start fresh.
Sending more worksend_input, optionally interrupting.Separate send_message and followup_task modes.
Reading resultswait_agent returns agent statuses and completed messages.Completion arrives through the mailbox; wait_agent reports activity.
Child lifecycleclose_agent and resume_agent tools.Automatic idle unloading and reloading, plus interrupt_agent.
Default nestingDepth one: children do not receive delegation tools.Nested delegation allowed when the child’s model supports V2.

Which multi-agent version a conversation uses depends on configuration and the model catalog. Enabling features.multi_agent_v2 forces V2. That override defaults to off, but a model’s catalog entry can still select V2.

We focus on V2 to inspect the newer coordination design: child answers arrive independently of wait calls, and the runtime can unload and reload idle agents. We will follow a delegated task from the parent’s model loop through child startup, communication, and the returned answer.

1 Delegation begins in the parent’s model loop

As we saw in the context-engineering post, run_turn repeatedly builds a model request, processes its response and tool calls, and decides whether another model step is needed. A request to spawn a subagent comes from the model during this loop. The runtime then handles configuration, startup, and communication.

Before the model can make that choice, spec_plan::add_collaboration_tools registers the tools exposed for the step. With V2’s native tools, direct messaging, and waiting enabled, the set is:

ToolPurpose
spawn_agentCreate a child conversation and give it a task.
send_messageQueue a message to an existing agent.
followup_taskGive an existing child more work and request a turn.
wait_agentWait for mailbox activity, user input, or a timeout.
list_agentsInspect the agent tree and statuses.
interrupt_agentInterrupt an agent’s current work.

Tool availability and delegation guidance are separate. effective_multi_agent_mode selects a developer message: a configured hint takes precedence over the model catalog’s hint; without either, Ultra reasoning effort selects proactive guidance and other efforts select explicit-request guidance. The bundled explicit-request instruction asks the model to delegate only when the user or applicable AGENTS.md or skill instructions request it. This is prompt guidance; the runtime’s capacity and configuration checks happen when a tool is called.

For this walkthrough, assume we explicitly ask Codex to use a subagent for an independent code investigation. We follow the model’s choice to start one child, while the parent continues other work.

The consolidation worker in CodeX Architect Part 2 - Memory was launched by the runtime. Here, the parent model requests the child through spawn_agent.

2 Choosing the child’s starting context

The model’s spawn_agent call names the task, supplies its instructions, and chooses whether to copy parent history. Here are illustrative logical arguments using the built-in spawn schema:

{
  "task_name": "review_auth",
  "message":
    "Check src/auth for missing auth guards. Return file:line findings. No edits.",
  "fork_turns": "none"
}

(Task text is shown for readability. The native V2 schema marks it as encrypted, and the handler distinguishes model-tool messages from direct plaintext input.)

The message is enough to explain this independent task, so we choose "none". The child starts without the parent’s conversation history. If earlier requirements or decisions were necessary, "all" would select a full-history fork. Omitting fork_turns defaults to "all". These branches come directly from SpawnAgentArgs::fork_mode:

let fork_turns = self
    .fork_turns
    .as_deref()
    .map(str::trim)
    .filter(|fork_turns| !fork_turns.is_empty())
    .unwrap_or("all");

if fork_turns.eq_ignore_ascii_case("none") {
    return Ok(None);
}
// Accept legacy turn counts without limiting the inherited history.
if fork_turns.eq_ignore_ascii_case("all") || fork_turns.parse::<NonZeroUsize>().is_ok() {
    return Ok(Some(SpawnAgentForkMode::FullHistory));
}

The numeric branch is compatibility handling: a positive number also selects FullHistory; it does not copy only the last N turns. And a full fork is still prepared for a child: the fork preparation code filters or rebuilds context items as needed. The optional preserve_fork_prefix setting changes that preparation and defaults to false.

Starting fresh only controls copied conversation history. The child still receives parent-derived configuration and an inherited environment. That distinction determines which model, instructions, tools, and permissions it can use.

3 Inheriting configuration and applying a role

child_config::prepare_agent_spawn_config builds the child’s configuration from the spawning step. It takes the parent’s effective settings, applies model and role choices, and reapplies the live runtime policy before handing the result to the agent controller.

LayerWhat it contributes
Parent’s captured settingsParent-derived configuration, the step’s selected model and reasoning settings, and base instructions.
Requested model and effortSpawn arguments override corresponding configured subagent defaults.
Selected roleAllowed instruction and model overrides, plus selected capability restrictions.
Live runtime policyParent’s current approval policy, approval reviewer, working directory, and permission-profile snapshot.

One detail in the model-selection layer: when a model override is supplied without an effort override from either source, Codex uses the selected model’s default effort. A role can then replace model settings.

Which role or model arguments the model actually sees depends on configuration and catalog-supplied tool definitions; the built-in schema builder can hide those fields. Our example omits them and uses the default role.

A role is a preset for the same engine

Roles give the parent a vocabulary for choosing specialized work. role::spawn_tool_spec::build assembles their descriptions into an available-role menu. The built-in declarations include default, explorer for focused codebase questions, and worker for implementation work. A role can also supply a configuration file; it does not introduce a different execution engine.

The important boundary is what that file may change. apply_role_to_config_inner projects it onto a small set of allowed overrides. It can change instructions and model settings, and disable selected features or skills. Sandbox, provider, and MCP-server settings are outside that override set. The apply_role_cannot_expand_parent_authority test feeds in a role requesting broader permissions, a different provider, and an extra server, then checks that those settings still equal the parent’s.

This also means a role’s sandbox_mode = "read-only" does not create a tighter sandbox at this revision: that field is not applied. Likewise, the "No edits" in our task is an instruction to the model. Enforced filesystem restrictions come from the inherited permission policy.

The child now has a task and an effective configuration. The next step creates the conversation that will execute them.

4 Starting the child’s own model loop

spawn::handle_spawn_agent parses the tool call and delegates to thread_spawn::spawn. This shared function prepares configuration, constructs a path such as /root/review_auth, records the parent relationship, and calls the agent backend with the initial message in TriggerTurn mode.

For the local backend, LocalAgentControl::spawn calls spawn_agent_internal, which checks capacity, reserves startup slots, creates the child thread, and submits the initial message. The child’s communication handler queues that message. Its pending-work scheduler then starts a regular turn through Session::start_task.

The actual independent task launch is this line in Session::start_task:

let handle = tokio::spawn(storage_originator.scope(task_future));

Inside that task, RegularTask::run calls the same run_turn used by the parent. A Codex thread here is a managed conversation with its own history and execution. The child uses the normal model loop and its available tools to carry out its task.

Startup is guarded. PendingSpawn owns the provisional child and schedules teardown if the spawn is cancelled or fails before the controller commits it. The controller keeps that ownership across its final awaits, then commits and disarms the guard before returning to its backend caller. Publishing the tool result happens outside that guarded boundary.

With metadata hidden by default, the spawn tool returns the child’s canonical task path:

{ "task_name": "/root/review_auth" }

That return means startup and initial-input admission succeeded. It does not mean the investigation finished. Parent and child can now make separate model requests and tool calls. Their conversation histories diverge, but the local path passes the spawning step’s environment into child startup; the bundled V2 guidance explicitly describes a shared working directory and filesystem. Delegated edits therefore become visible to the other agents without a merge step.

The diagram collects this successful path. Function names link to their definitions; // comments describe their role. It condenses startup checks and follows the fresh-history branch.

Codex V2 delegation: the spawn handler and shared spawn function prepare a child; parent and child run independent conversation loops in a shared workspace; successful completion queues the child's final answer for the parent's next accepted model step.

Open the delegation workflow at full size.

Separate conversations need a way to exchange information while they run. That is the mailbox’s job.

5 Sending messages between conversations

To send a message, an agent names the recipient’s task path. AgentPath::resolve interprets a relative target beneath the sender’s own path, and the controller resolves that path to a thread ID. From /root, review_auth means /root/review_auth. A sibling sending to that same child uses the absolute path /root/review_auth.

handle_message_string_tool handles both send_message and followup_task. Their delivery modes differ:

ToolModeEffect on an idle recipient
send_messageQueueOnlyQueues mail; ordinarily leaves it idle.
followup_taskTriggerTurnRequests a turn to process the task.

For a loaded recipient, the controller submits Op::InterAgentCommunication to that thread. Its handlers::inter_agent_communication enqueues the message and checks whether to wake an idle conversation:

let trigger_turn = communication.trigger_turn;
sess.input_queue
    .enqueue_mailbox_communication(communication, start_options)
    .await;
crate::agent_communication::emit_agent_communication_receive(&sub_id);
if trigger_turn || sess.has_outstanding_durable_sleep() {
    sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
        .await;
}

The durable-sleep check is an exception to the usual queue-only behavior: it lets incoming mail wake an agent that has entered this runtime-supported sleep state. Otherwise, a message to an idle child stays queued until a turn processes it; a follow-up task requests that turn.

For a running recipient, the input queue drains accepted mail into a model step. Session::record_inter_agent_communication records it in conversation history and persistence, so a subsequent model request can include it. The sender’s successful tool call confirms acceptance, not that the recipient has read or acted on the message.

This is also the route the child’s findings take back to the parent. The difference is who initiates the send: normal completion triggers it in runtime code.

6 Returning findings and waiting for them

When a child successfully completes its turn, agent_status_from_event produces Completed(last_agent_message). Session::maybe_notify_parent_of_terminal_turn recognizes terminal events from V2 spawned children and forwards the outcome to LocalAgentControl::notify_parent_of_terminal_turn. The controller formats a message for the direct parent and sends it with trigger_turn: false.

The normal completion payload is the child’s last assistant message. Its searches, command output, and reasoning are not bundled into that handoff. In format_inter_agent_completion_message, successful text is copied in full, while error text is truncated:

let payload = match status {
    AgentStatus::Completed(Some(message)) => message.clone(),
    AgentStatus::Completed(None) => String::new(),
    AgentStatus::Errored(error) => {
        let error = truncate_text(error, TruncationPolicy::Tokens(ERROR_MAX_TOKENS));
        format!("Agent errored: {error}\n\n{ERROR_NEXT_ACTION}")
    }
    AgentStatus::Shutdown => "Agent shut down.".to_string(),
    AgentStatus::NotFound => "Agent was not found.".to_string(),
    AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted => return None,
};
Some(InterAgentCompletionMessage::new(task_name, sender, payload).render())

The completion template renders an assistant-role mailbox message like this, with illustrative findings in the payload:

Message Type: FINAL_ANSWER
Task name: /root
Sender: /root/review_auth
Payload:
<findings with file and line references>

Task name identifies the recipient; Sender identifies the child. If the task asks for a report file, the child can write it in the shared workspace and return a path. The automatic completion mechanism itself copies text, and this formatter places no length cap on a successful answer. Asking for concise, self-contained findings matters because that answer will occupy the parent’s context.

Waiting observes activity; the answer arrives separately

The parent can continue independent work after spawning. When it needs the child’s findings, V2’s wait_agent subscribes to activity on the parent’s input queue. It takes no child ID and does not wait on an “all children finished” condition. Mail, new user input, or a timeout can end the wait. The default timeout is 30 seconds, with a 10-second minimum and a one-hour maximum; those settings are configurable.

For mailbox activity, the tool result is:

{ "message": "Wait completed.", "timed_out": false }

The FINAL_ANSWER is a separate message. A completed wait therefore tells the parent there is activity to inspect, not that a particular child succeeded. The parent’s run_turn continuation check considers both model-requested follow-up and accepted pending input; the next step records delivered mail into history before constructing another model request.

What if the parent finishes first?

Final-answer output can close a delivery gate, deferring queue-only mail to the next turn. Once that delivery gate closes, waiting mail alone does not request another model step. Because completion uses queue-only delivery, it also ordinarily leaves an idle parent idle. A late child answer should not be assumed to reopen a finished response; the parent needs to wait while the result is still required.

Ordinary interruption and budget exhaustion are another case: they map to Interrupted, which the terminal-result filter excludes. The parent should inspect status with list_agents rather than treating a wait timeout as completion. There is a separate notification for repeated Guardian approval denials. Even for a terminal result, delivery is best effort: the controller records whether queueing succeeded and logs a failure instead of retrying it here.

7 Managing capacity and child lifetimes

The runtime limits how much delegated work can run at once. The V2 concurrency default is four including the root; effective_agent_max_threads subtracts one, leaving three child execution slots. The execution limiter checks capacity for V2 subagent work. This limits active work, rather than the total number of task paths that can exist over a conversation’s lifetime.

Idle children can remain available for related work. The residency manager can unload eligible idle agents to make room, and a later followup_task reloads the child and requests another turn. For locally evicted children, queue-only mail can instead wait in a runtime mailbox without forcing a reload. Pending turn-triggering mail and outstanding durable sleep prevent idle eviction.

Children can also delegate. The V2 tool gate has no nesting-depth check, but requires a nested agent’s model to advertise V2 support before exposing the native collaboration tools. The same tree’s capacity limits still apply; nesting does not create a separate allowance of child slots.

What stands out to me

I find the reuse of Codex’s conversation engine the most interesting part. Once startup has prepared its context and policy, a child runs the same model loop as its parent. The additional machinery sits around those loops: task paths, capacity reservations, message queues, and lifecycle management.

The parent receives the answer the child chose to return, while file changes are shared directly. That makes the task boundary important: a child needs a clear question or file ownership, and its answer needs to contain what the parent will use. More agents add useful capacity when those boundaries are clear; they also add coordination work that the prompts and runtime have to handle together.