Chapter 9: Tool Specifications, Routing, and Dispatch
Reading Contract: Use this chapter to follow one tool request from the schema the model can see to the handler that can actually run. Track three owners: the planner advertises capabilities, the router normalizes model items, and the registry decides whether a runtime handler may execute the payload. By the end, you should be able to explain why a visible tool schema can still fail before any side effect happens.

Source boundary: this chapter explains the public Codex repository at commit
569ff6a1.
Type names, enum variants, tests, and function behavior are verified source
only where the prose links to that pinned snapshot. Claims about why a boundary
matters are surrounding contract inference from those source shapes. The
chapter does not infer private OpenAI service behavior, hidden model policy, or
hosted backend scheduling.
Before Codex can record a tool result, it has to decide whether the requested tool may run at all. This chapter turns from observation to action. A model can emit a function call, a custom tool call, a hosted-tool request, a local shell action, or a tool-search request, but the emitted item is not authority. It is only syntax that must be matched against a turn-local capability plan.
The core invariant is simple:
A tool spec tells the model what shape to produce. A handler proves what the runtime can execute. A registry and router keep those two facts from collapsing into one another.
That invariant is easy to miss because the UI experience is direct. The model asks for a tool; the tool appears to run. In the source, several gates sit between those two moments:
- The planner builds configured specs and handler registrations from configuration, feature flags, MCP state, dynamic tools, hosted tools, and discoverable tools.
- The router filters which specs are model-visible for this turn.
- The router parses provider response items into a normalized
ToolCall. - The registry looks up a handler by
ToolNameand checks that the payload kind matches the handler kind. - Dispatch wraps execution with cancellation, parallelism, hooks, telemetry, trace records, and response shaping.
If one of these gates fails, Codex should not “try the closest tool.” It should return a structured failure or stop the turn with a fatal runtime error, depending on which boundary was violated.
1. Tool Specs Are Model Syntax, Not Runtime Authority
The codex-rs/tools crate defines the serializable tool vocabulary. In the
pinned source, ToolSpec
contains several public shapes:
pub enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "namespace")]
Namespace(ResponsesApiNamespace),
#[serde(rename = "tool_search")]
ToolSearch {
execution: String,
description: String,
parameters: JsonSchema,
},
#[serde(rename = "local_shell")]
LocalShell {},
#[serde(rename = "image_generation")]
ImageGeneration { output_format: String },
#[serde(rename = "web_search")]
WebSearch { /* fields omitted */ },
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
This enum is broad on purpose. It covers ordinary function calling, namespace
tools, deferred tool search, local shell style tools, provider-hosted image and
web-search tools, and freeform custom tools such as patch application. But it is
still just a request shape. The next struct makes the distinction sharper:
ConfiguredToolSpec
adds a parallelism flag to a spec.
pub struct ConfiguredToolSpec {
pub spec: ToolSpec,
pub supports_parallel_tool_calls: bool,
}
Even that is not execution authority. It says, “if this tool appears in the
configured set, this is the model-facing schema and its parallel-call contract.”
The actual runtime authority lives in a handler registered under a ToolName.
That split prevents three common mistakes:
| Mistake | Why it fails |
|---|---|
| Treating the schema as permission | A model-visible JSON schema does not answer whether a handler exists, whether policy allows the call, or whether the current turn can run it. |
| Treating a name as provenance | calendar.create_event, a namespaced MCP tool, and a plain local function can have similar public names but different owners. |
| Treating parallelism as a model feature | The model may emit multiple calls, but Codex must decide whether the specific handler or server can tolerate concurrency. |
The rest of the chapter is the machinery that keeps those mistakes from becoming side effects.
2. Planning Builds Two Products
The planner’s job is not to make one flat list of tools. It builds two related products:
- Configured specs that the router can inspect.
- Runtime handlers that the registry can dispatch.

The public entry point for this planning layer is
build_specs_with_discoverable_tools.
It receives the visible planning inputs: tool configuration, MCP tools,
deferred MCP tools, unavailable tools that the model has already tried to call,
discoverable tools, and dynamic tools.
pub(crate) fn build_specs_with_discoverable_tools(
config: &ToolsConfig,
mcp_tools: Option<Vec<ToolInfo>>,
deferred_mcp_tools: Option<Vec<ToolInfo>>,
unavailable_called_tools: Vec<ToolName>,
discoverable_tools: Option<Vec<DiscoverableTool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
/* planning body omitted */
}
The first important move happens before the lower-level registry builder is called. MCP tools are converted into plan inputs that preserve canonical names and namespace descriptions; deferred MCP tools are converted into source records for tool search; deferred dynamic tools are collected so they can be discoverable without necessarily being visible as direct calls.
let deferred_mcp_tool_sources = deferred_mcp_tools.as_ref().map(|tools| {
tools
.iter()
.map(|tool| ToolRegistryBuildDeferredTool {
name: tool.canonical_tool_name(),
server_name: tool.server_name.as_str(),
connector_name: tool.connector_name.as_deref(),
description: tool.namespace_description.as_deref(),
})
.collect::<Vec<_>>()
});
let deferred_dynamic_tools = dynamic_tools
.iter()
.filter(|tool| tool.defer_loading && (config.namespace_tools || tool.namespace.is_none()))
.cloned()
.collect::<Vec<_>>();
That is already an architectural statement. A deferred tool is not absent. It
can have a handler, search metadata, and provenance, while still being withheld
from the immediate model-visible spec list. This is how Codex can keep a model
from seeing every possible capability up front without losing the ability to
resolve a selected capability later. Deferred MCP tools make that explicit:
the planner later registers
McpHandler entries for deferred tools that were not already registered
directly,
so discovery can still route back to a runtime handler.
The lower-level
build_tool_registry_builder
then registers concrete families. Shell exposure is gated by environment mode
and shell type:
if config.environment_mode.has_environment() {
let include_environment_id =
matches!(config.environment_mode, ToolEnvironmentMode::Multiple);
match &config.shell_type {
ConfigShellToolType::Default => {
builder.register_handler(Arc::new(ShellHandler::new(ShellToolOptions {
exec_permission_approvals_enabled,
})));
}
ConfigShellToolType::UnifiedExec => {
builder.register_handler(Arc::new(ExecCommandHandler::new(
ExecCommandHandlerOptions {
allow_login_shell: config.allow_login_shell,
exec_permission_approvals_enabled,
include_environment_id,
},
)));
builder.register_handler(Arc::new(WriteStdinHandler));
}
/* other shell modes omitted */
}
}
The same builder adds plan, goal, request-input, permission, patch, test, hosted web search, image generation, view-image, multi-agent, agent-job, MCP, and dynamic-tool handlers based on configuration and feature state. The result is not a universal tool surface. It is a turn-specific capability set.
2.1 Deferred Tool Search Is a First-Class Planning Path
When tool search is enabled and either a deferred MCP source list is present or
deferred dynamic tools exist, the builder registers a
ToolSearchHandler:
if config.search_tool
&& (deferred_mcp_tools_for_search.is_some() || !deferred_dynamic_tools.is_empty())
{
let mut search_source_infos = deferred_mcp_tools_for_search
.map(|deferred_mcp_tools| {
collect_tool_search_source_infos(deferred_mcp_tools.iter().map(|tool| {
ToolSearchSource {
server_name: tool.server_name,
connector_name: tool.connector_name,
description: tool.description,
}
}))
})
.unwrap_or_default();
if !deferred_dynamic_tools.is_empty() {
search_source_infos.push(ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some("Tools provided by the current Codex thread.".to_string()),
});
}
builder.register_handler(Arc::new(ToolSearchHandler::new(
params.tool_search_entries.to_vec(),
search_source_infos,
)));
}
The protected invariant is practical: a hidden capability can still be discoverable through an explicit search action. The model should not have to carry every dynamic or external schema in its initial tool list, and the runtime should not lose the original source metadata when the model later selects one.
2.2 Unavailable Tools Still Need a Controlled Result
Unavailable tools are the opposite edge case. The model may refer to a tool
that existed earlier, was present in a different session, or was discovered
under different auth. The builder handles that by inserting an unavailable-tool
placeholder or a handler without a spec, depending on whether the name already
exists. In
spec.rs,
the placeholder description explicitly says the call returns an error.
for unavailable_tool in unavailable_called_tools {
let tool_name = flat_tool_name(&unavailable_tool).into_owned();
if existing_spec_names.insert(tool_name.clone()) {
let spec = codex_tools::ToolSpec::Function(ResponsesApiTool {
name: tool_name.clone(),
description: unavailable_tool_message(
&tool_name,
"Calling this placeholder returns an error explaining that the tool is unavailable.",
),
strict: false,
parameters: JsonSchema::object(
Default::default(),
/*required*/ None,
Some(AdditionalProperties::Boolean(false)),
),
output_schema: None,
defer_loading: None,
});
builder.register_handler(Arc::new(UnavailableToolHandler::new(
unavailable_tool,
spec,
)));
} else {
builder.register_handler(Arc::new(UnavailableToolHandler::without_spec(
unavailable_tool,
)));
}
}
That is better than silently dropping the call. The model receives a structured reason, and the user gets an auditable failure instead of a mysterious missing tool.
3. Provenance Survives Model-Friendly Naming
External and client-owned tools complicate the name problem. A raw MCP tool name may not be suitable as a model-facing Responses API tool name. A dynamic tool may live under a namespace supplied by a client. A deferred tool may be known to the registry but hidden from the immediate model list.

In the planner, MCP tools are sorted by namespace and converted with
mcp_tool_to_responses_api_tool.
For each converted tool, Codex registers an McpHandler under the canonical
tool name.
for (namespace, mut entries) in namespace_entries {
entries.sort_by_key(|tool| tool.name.name.clone());
let mut tools = Vec::new();
for tool in entries {
match mcp_tool_to_responses_api_tool(&tool.name, tool.tool) {
Ok(converted_tool) => {
tools.push(ResponsesApiNamespaceTool::Function(converted_tool));
builder.register_handler(Arc::new(McpHandler::new(tool.name)));
}
Err(error) => {
let tool_name = &tool.name;
tracing::error!(
"Failed to convert `{tool_name}` MCP tool to OpenAI tool: {error:?}"
);
}
}
}
if config.namespace_tools && !tools.is_empty() {
builder.push_spec(
ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace,
description,
tools,
}),
/*supports_parallel_tool_calls*/ false,
);
}
}
The key idea is that the model-facing representation and the route-back identity are related, but not identical concepts. The response item must later be resolved against session state so an MCP call becomes a payload with a server, a raw tool name, and raw arguments.
Dynamic tools follow the same architecture in a different family. For each
successfully converted dynamic tool, the planner creates a loadable spec and
registers a DynamicToolHandler under the namespaced or plain handler name:
let mut dynamic_tool_specs = Vec::new();
for tool in params.dynamic_tools {
match dynamic_tool_to_loadable_tool_spec(tool) {
Ok(loadable_tool) => {
let handler_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
dynamic_tool_specs.push(loadable_tool);
builder.register_handler(Arc::new(DynamicToolHandler::new(handler_name)));
}
Err(error) => {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool: {error:?}",
tool.name
);
}
}
}
The takeaway is not “MCP and dynamic tools are the same.” They are not. The takeaway is that both pass through a planning boundary that records enough identity for later routing.
4. The Router Filters What the Model Sees
Once the builder returns configured specs and a registry, the
ToolRouter
keeps both:
pub struct ToolRouter {
registry: ToolRegistry,
specs: Vec<ConfiguredToolSpec>,
model_visible_specs: Vec<ToolSpec>,
parallel_mcp_server_names: HashSet<String>,
}
ToolRouter::from_config builds the registry, computes deferred dynamic tool
names, filters specs for the model-visible list, and stores the exact MCP
servers that support parallel tool calls:
let (specs, registry) = builder.build();
let deferred_dynamic_tools = dynamic_tools
.iter()
.filter(|tool| tool.defer_loading)
.map(|tool| ToolName::new(tool.namespace.clone(), tool.name.clone()))
.collect::<HashSet<_>>();
let model_visible_specs = specs
.iter()
.filter_map(|configured_tool| {
if config.code_mode_only_enabled
&& codex_code_mode::is_code_mode_nested_tool(configured_tool.name())
{
return None;
}
filter_deferred_dynamic_tool_spec(
configured_tool.spec.clone(),
&deferred_dynamic_tools,
)
})
.collect();
The important detail is that model_visible_specs is derived from specs; it
does not replace specs. A deferred dynamic tool can remain in the configured
set so find_spec and routing logic can still reason about it, while the
initial request hides it from the model.
The filter itself is explicit:
filter_deferred_dynamic_tool_spec
removes deferred function tools or prunes deferred namespace members. If a
namespace becomes empty, it disappears from the model-visible list.
fn filter_deferred_dynamic_tool_spec(
spec: ToolSpec,
deferred_dynamic_tools: &HashSet<ToolName>,
) -> Option<ToolSpec> {
if deferred_dynamic_tools.is_empty() {
return Some(spec);
}
match spec {
ToolSpec::Function(tool) => {
if deferred_dynamic_tools.contains(&ToolName::plain(tool.name.as_str())) {
None
} else {
Some(ToolSpec::Function(tool))
}
}
ToolSpec::Namespace(mut namespace) => {
let namespace_name = namespace.name.clone();
namespace.tools.retain(|tool| match tool {
ResponsesApiNamespaceTool::Function(tool) => !deferred_dynamic_tools.contains(
&ToolName::namespaced(namespace_name.as_str(), tool.name.as_str()),
),
});
if namespace.tools.is_empty() {
None
} else {
Some(ToolSpec::Namespace(namespace))
}
}
spec => Some(spec),
}
}
The test
model_visible_specs_filter_deferred_dynamic_tools
locks down this behavior. It builds a hidden and visible dynamic tool in the
same namespace, then verifies that both remain in router.specs() while only
the visible one remains in router.model_visible_specs().
That is the kind of boundary a large agent runtime needs. Visibility is a turn surface. Capability is a runtime fact. Search is a discovery path between them.
5. Response Items Become Typed Tool Calls
After the model responds, the router no longer deals with specs. It deals with
response items. A response item can be a normal message, a function call, a tool
search call, a custom call, a local shell call, or something else. Codex
normalizes only the tool-call cases into a ToolCall:
pub struct ToolCall {
pub tool_name: ToolName,
pub call_id: String,
pub payload: ToolPayload,
}

The payload enum is the first line of defense against name-only dispatch.
ToolPayload
has distinct variants for function, tool search, custom, local shell, and MCP
calls:
pub enum ToolPayload {
Function {
arguments: String,
},
ToolSearch {
arguments: SearchToolCallParams,
},
Custom {
input: String,
},
LocalShell {
params: ShellToolCallParams,
},
Mcp {
server: String,
tool: String,
raw_arguments: String,
},
}
The router’s
build_tool_call
then maps response variants into this payload vocabulary. The function-call
branch is especially important because it first checks whether the tool name
resolves to MCP provenance:
ResponseItem::FunctionCall {
name,
namespace,
arguments,
call_id,
..
} => {
let tool_name = ToolName::new(namespace, name);
if let Some(tool_info) = session.resolve_mcp_tool_info(&tool_name).await {
Ok(Some(ToolCall {
tool_name: tool_info.canonical_tool_name(),
call_id,
payload: ToolPayload::Mcp {
server: tool_info.server_name,
tool: tool_info.tool.name.to_string(),
raw_arguments: arguments,
},
}))
} else {
Ok(Some(ToolCall {
tool_name,
call_id,
payload: ToolPayload::Function { arguments },
}))
}
}
Tool search is also special. A ToolSearchCall only becomes a client-side
ToolCall when execution == "client" and a call_id is present. Other
tool-search items are ignored by this router path:
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
execution,
arguments,
..
} if execution == "client" => {
let arguments: SearchToolCallParams =
serde_json::from_value(arguments).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to parse tool_search arguments: {err}"
))
})?;
Ok(Some(ToolCall {
tool_name: ToolName::plain("tool_search"),
call_id,
payload: ToolPayload::ToolSearch { arguments },
}))
}
ResponseItem::ToolSearchCall { .. } => Ok(None),
Custom calls and local shell calls get their own payload variants. That matters because a custom freeform patch body and a JSON function call should never be interchanged merely because both have a string-like body.
6. Registry Lookup Rejects Missing and Wrong-Kind Calls
The registry owns the runtime handler map. It is not just a place to call a
function by name. This shortened excerpt from the
ToolHandler
trait shows the runtime boundary:
pub trait ToolHandler: Send + Sync {
type Output: ToolOutput + 'static;
fn tool_name(&self) -> ToolName;
fn spec(&self) -> Option<ToolSpec> {
None
}
fn supports_parallel_tool_calls(&self) -> bool {
false
}
fn kind(&self) -> ToolKind;
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(
(self.kind(), payload),
(ToolKind::Function, ToolPayload::Function { .. })
| (ToolKind::Function, ToolPayload::ToolSearch { .. })
| (ToolKind::Mcp, ToolPayload::Mcp { .. })
)
}
/* mutation, hook, and streaming-diff methods omitted */
fn handle(
&self,
invocation: ToolInvocation,
) -> impl std::future::Future<Output = Result<Self::Output, FunctionCallError>> + Send;
}
kind() and matches_kind() are the quiet guardrails. A handler that expects
MCP payloads should not receive a custom payload. A function handler should not
receive an MCP server payload just because the name looks close. ToolSearch
is allowed to route through function-kind handling because it is a client-side
runtime tool, but it still carries its own payload variant.
The core dispatch path begins in
ToolRegistry::dispatch_any.
It prepares telemetry tags, starts a dispatch trace, looks up the handler, and
then rejects missing or incompatible calls before any handler runs:
let dispatch_trace = ToolDispatchTrace::start(&invocation);
let handler = match self.handler(&tool_name) {
Some(handler) => handler,
None => {
let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
otel.tool_result_with_tags(
tool_name_flat.as_ref(),
&call_id_owned,
log_payload.as_ref(),
Duration::ZERO,
/*success*/ false,
&message,
&metric_tags,
mcp_server_ref,
mcp_server_origin_ref,
);
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
};
if !handler.matches_kind(&invocation.payload) {
let message = format!("tool {tool_name} invoked with incompatible payload");
otel.tool_result_with_tags(
tool_name_flat.as_ref(),
&call_id_owned,
log_payload.as_ref(),
Duration::ZERO,
/*success*/ false,
&message,
&metric_tags,
mcp_server_ref,
mcp_server_origin_ref,
);
let err = FunctionCallError::Fatal(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
Those two branches have different semantics. An unsupported call is turned into a model-facing failure when possible; an incompatible payload is fatal because it means the routing layer and handler boundary disagree about the runtime contract.
The tests in
tool_dispatch_trace_tests.rs
also show that both unsupported and incompatible calls are recorded as failed
dispatch traces. Failure is still an observable runtime fact.
7. Dispatch Is Supervised Work, Not a Switch Statement
Once the handler and payload kind match, dispatch still has to supervise the work. The registry handles pre-tool hooks, mutating-tool gates, telemetry, post-tool hooks, goal accounting, and trace completion. The parallel runtime handles cancellation and read/write locking.

The registry first runs optional pre-tool hooks. If a hook returns a blocking message, the call fails before handler execution:
if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation)
&& let Some(message) = run_pre_tool_use_hooks(
&invocation.session,
&invocation.turn,
invocation.call_id.clone(),
&pre_tool_use_payload.tool_name,
&pre_tool_use_payload.tool_input,
)
.await
{
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
Then it executes the handler inside telemetry logging. Mutating tools wait on a turn-level gate before running:
let is_mutating = handler.is_mutating(&invocation).await;
let response_cell = tokio::sync::Mutex::new(None);
let invocation_for_tool = invocation.clone();
let result = otel
.log_tool_result_with_tags(
tool_name_flat.as_ref(),
&call_id_owned,
log_payload.as_ref(),
&metric_tags,
mcp_server_ref,
mcp_server_origin_ref,
|| {
let handler = handler.clone();
let response_cell = &response_cell;
async move {
if is_mutating {
invocation_for_tool.turn.tool_call_gate.wait_ready().await;
}
match handler.handle_any(invocation_for_tool).await {
Ok(result) => {
let preview = result.result.log_preview();
let success = result.result.success_for_logging();
let mut guard = response_cell.lock().await;
*guard = Some(result);
Ok((preview, success))
}
Err(err) => Err(err),
}
}
},
)
.await;
After a successful handler result,
post-tool hooks
may add context or replace the stored response body before it is converted into
a model or code-mode response item. That hook path is not cosmetic: it means
tool output is still a runtime-owned value that can be shaped before the next
consumer sees it.
Finally, the dispatch trace is completed or failed:
match result {
Ok(_) => {
let mut guard = response_cell.lock().await;
let result = guard.take().ok_or_else(|| {
FunctionCallError::Fatal("tool produced no output".to_string())
})?;
dispatch_trace.record_completed(
&invocation,
&result.call_id,
&result.payload,
result.result.as_ref(),
);
Ok(result)
}
Err(err) => {
dispatch_trace.record_failed(&err);
Err(err)
}
}
7.1 Parallelism Uses Runtime Locks
Parallelism is evaluated before dispatch. In
ToolCallRuntime,
each runtime owns an RwLock<()> named parallel_execution. The
handle_tool_call_with_source
path asks the router whether the call supports parallel execution, then uses a
read lock for parallel-safe calls and a write lock for serialized calls.
let supports_parallel = self.router.tool_supports_parallel(&call);
let lock = Arc::clone(&self.parallel_execution);
let handle: AbortOnDropHandle<Result<AnyToolResult, FunctionCallError>> =
AbortOnDropHandle::new(tokio::spawn(async move {
tokio::select! {
_ = cancellation_token.cancelled() => {
let secs = started.elapsed().as_secs_f32().max(0.1);
dispatch_span.record("aborted", true);
Ok(Self::aborted_response(&call, secs))
},
res = async {
let _guard = if supports_parallel {
Either::Left(lock.read().await)
} else {
Either::Right(lock.write().await)
};
router
.dispatch_tool_call_with_code_mode_result(
session,
turn,
invocation_cancellation_token,
tracker,
call.clone(),
source,
)
.instrument(dispatch_span.clone())
.await
} => res,
}
}));
This is not about trusting the model to decide concurrency. The model may emit parallel calls. Codex decides which calls can actually share a read lock.
The router’s parallel decision has two branches. For non-MCP tools, it checks
configured specs that explicitly support parallel calls. For MCP, it checks the
payload’s server name against parallel_mcp_server_names:
pub fn tool_supports_parallel(&self, call: &ToolCall) -> bool {
match &call.payload {
ToolPayload::Mcp { server, .. } => self.parallel_mcp_server_names.contains(server),
_ => self.configured_tool_supports_parallel(&call.tool_name),
}
}
The test
mcp_parallel_support_uses_exact_payload_server
captures the reason: similarly named MCP tools from different servers must not
inherit each other’s concurrency contract.
7.2 Output Has Multiple Audiences
Tool output is not just a string. The ToolOutput
trait asks each output type to provide a telemetry preview, a success signal, a
model-facing ResponseInputItem, optional post-tool hook data, and a code-mode
result.
pub trait ToolOutput: Send {
fn log_preview(&self) -> String;
fn success_for_logging(&self) -> bool;
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
None
}
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
response_input_to_code_mode_result(self.to_response_item("", payload))
}
}
The same handler result can therefore satisfy different consumers:
| Consumer | Source mechanism |
|---|---|
| The next model request | to_response_item produces a ResponseInputItem. |
| Telemetry | log_preview and success_for_logging produce bounded logging data. |
| Hooks | post_tool_use_response exposes stable hook-facing data when the handler opts in. |
| Code mode | code_mode_result can return a JS/runtime-friendly value. |
| Rollout trace | ToolDispatchTrace stores invocation and result payloads for replay. |
ToolSearchOutput is a compact example. Its
to_response_item
does not pretend to be a normal function output. It returns
ResponseInputItem::ToolSearchOutput with execution: "client" and a list of
loadable tool specs.
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
ResponseInputItem::ToolSearchOutput {
call_id: call_id.to_string(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: self
.tools
.iter()
.map(|tool| {
serde_json::to_value(tool).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize tool_search output: {err}"))
})
})
.collect(),
}
}
That shape matters because the model is not the only observer. The runtime can also emit events, log telemetry, feed post-tool hooks, and preserve trace evidence.
8. Dispatch Trace Makes Tool Calls Replayable
The dispatch trace adapter is where this execution path becomes replay evidence.
The registry starts a
ToolDispatchTrace
before early returns, so unsupported or incompatible calls still receive trace
end events:
pub(crate) struct ToolDispatchTrace {
context: ToolDispatchTraceContext,
}
impl ToolDispatchTrace {
pub(crate) fn start(invocation: &ToolInvocation) -> Self {
let context = invocation
.session
.services
.rollout_thread_trace
.start_tool_dispatch_trace(|| tool_dispatch_invocation(invocation));
Self { context }
}
}
The invocation payload records thread id, turn id, tool-call id, tool name, namespace, requester, and the typed payload:
fn tool_dispatch_invocation(invocation: &ToolInvocation) -> Option<ToolDispatchInvocation> {
let requester = match &invocation.source {
ToolCallSource::Direct => ToolDispatchRequester::Model {
model_visible_call_id: invocation.call_id.clone(),
},
ToolCallSource::CodeMode {
cell_id,
runtime_tool_call_id,
} => ToolDispatchRequester::CodeCell {
runtime_cell_id: cell_id.clone(),
runtime_tool_call_id: runtime_tool_call_id.clone(),
},
};
Some(ToolDispatchInvocation {
thread_id: invocation.session.conversation_id.to_string(),
codex_turn_id: invocation.turn.sub_id.clone(),
tool_call_id: invocation.call_id.clone(),
tool_name: invocation.tool_name.name.clone(),
tool_namespace: invocation.tool_name.namespace.clone(),
requester,
payload: tool_dispatch_payload(&invocation.payload),
})
}
The source-visible contract is therefore stronger than “a tool returned text.” It is “a specific requester asked for a named tool with a typed payload; a handler path completed or failed; a response payload was recorded for the appropriate consumer.” That is the evidence later replay and debugging can consume.
9. Common Misreadings
| Misreading | Correction |
|---|---|
| ”The model owns tools because it emits tool calls.” | The model emits syntax. The runtime owns handler lookup, payload validation, policy, hooks, cancellation, and output shaping. |
| ”The request tool list is the registry.” | model_visible_specs is filtered from configured specs. The registry can still contain handlers that are not exposed directly. |
| ”A namespaced name is enough to route MCP.” | MCP routing depends on canonical tool identity and payload provenance, not just a string that looks namespaced. |
| ”Parallel tool calls are safe if the provider supports them.” | Codex still checks handler/server concurrency support and uses read/write locks around dispatch. |
| ”Failure means nothing happened.” | Unsupported, incompatible, aborted, and hook-blocked calls still create structured model responses or trace facts. |
| ”Tool output is one string.” | Output is shaped for model continuation, telemetry, hooks, code-mode callers, and rollout traces. |
10. Apply This
- Separate syntax from authority. Model-visible schemas describe call shape. Runtime handlers own execution.
- Plan from all capability sources before exposure. Config, feature flags, MCP tools, deferred tools, dynamic tools, hosted tools, and unavailable placeholders all affect the final plan; hidden tools still need search metadata and handlers when they are meant to be discovered later.
- Normalize before dispatch. Convert provider response items into a typed
ToolCall, then reject calls whose payload kind does not match the handler kind. - Make parallelism a runtime decision. Let explicit handler/server contracts choose read-lock concurrency; serialize everything else.
- Treat output as multi-audience. A result must satisfy the model, code-mode callers, hooks, telemetry, and replay without pretending those audiences are the same.
Chapter 10 follows the most consequential handler family: shell and filesystem
execution. There the abstract boundary becomes concrete process supervision:
command parsing, approval, sandbox selection, exec-server, ordered output, and
filesystem operations.