Codex · EN · Part 2
Ch 05 The Tool System
05-ch-05-The-Tool-System.md
第5章 The Tool System
Tool Definition and Specification
Every tool in Codex CLI exists as a contract between the LLM and the runtime. This contract has two parts: a specification that tells the LLM what the tool does and what parameters it accepts, and a handler that actually executes the tool when called.
JSON Schema for Parameters
Tool parameters use a simplified JSON Schema subset. The JsonSchema enum captures the essential types needed for tool definitions:
// codex-rs/tools/src/json_schema.rs
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum JsonSchema {
Boolean {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
String {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
Number {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
Array {
items: Box<JsonSchema>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
Object {
properties: BTreeMap<String, JsonSchema>,
#[serde(skip_serializing_if = "Option::is_none")]
required: Option<Vec<String>>,
#[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")]
additional_properties: Option<AdditionalProperties>,
},
}
🤿 深入底层: The
sanitize_json_schemafunction (codex-rs/tools/src/json_schema.rs:77) performs defensive normalization of incoming schemas. It infers missingtypefields from structural hints—propertiesimplies object,itemsimplies array—and fills missing required fields with permissive defaults. This defensive approach prevents schema parsing failures from malformed MCP tools.
ToolSpec Structure
The ToolSpec enum represents the complete specification sent to the LLM. Each variant serializes to a different OpenAI Responses API tool type:
// codex-rs/tools/src/tool_spec.rs
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[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 {
#[serde(skip_serializing_if = "Option::is_none")]
external_web_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
filters: Option<ResponsesApiWebSearchFilters>,
// ...
},
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
The ResponsesApiTool variant carries the full function-calling payload including name, description, strict mode flag, and parameter schema:
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiTool {
pub name: String,
pub description: String,
pub strict: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub defer_loading: Option<bool>,
pub parameters: JsonSchema,
#[serde(skip)]
pub output_schema: Option<Value>,
}
Tool Trait
All tool handlers implement the ToolHandler trait, which defines the interface between the registry and individual tools:
// codex-rs/core/src/tools/registry.rs
pub trait ToolHandler: Send + Sync {
type Output: ToolOutput + 'static;
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 { .. })
)
}
/// Returns `true` if the tool *might* mutate the user's environment.
/// This function must remain defensive and return `true` if in doubt.
fn is_mutating(
&self,
_invocation: &ToolInvocation,
) -> impl std::future::Future<Output = bool> + Send {
async { false }
}
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
None
}
fn post_tool_use_payload(
&self,
_call_id: &str,
_payload: &ToolPayload,
_result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
None
}
/// Perform the actual tool invocation and return the final output.
fn handle(
&self,
invocation: ToolInvocation,
) -> impl std::future::Future<Output = Result<Self::Output, FunctionCallError>> + Send;
}
⚠️ 避坑指南: The
is_mutatingdefault returnsfalse, but this is intentionally defensive—tools that might mutate should override this to returntrue. The shell handler, for example, checks against a known safe-command list before determining mutability (codex-rs/core/src/tools/handlers/shell.rs:194). When implementing custom tools, err on the side of returningtrueif the effects aren't crystal clear.
Built-in vs Custom Tools
Codex CLI distinguishes between three categories of tools:
- Built-in tools compiled into the binary (shell, apply_patch, file_search)
- MCP tools dynamically loaded from MCP servers at runtime
- Dynamic tools loaded from external sources via the dynamic tools protocol
The tool system treats all three uniformly through the ToolHandler trait, but they arrive via different registration paths. Built-in tools register at startup, MCP tools register when their server connects, and dynamic tools load on-demand based on LLM requests.
Tool Registration and Discovery
The ToolRegistry serves as the central dispatch point for all tool invocations. Tools register themselves during startup, and the registry routes incoming tool calls to the appropriate handler.
ToolRegistry
The registry maintains a HashMap mapping tool names (optionally namespace-qualified) to trait objects:
// codex-rs/core/src/tools/registry.rs
pub struct ToolRegistry {
handlers: HashMap<String, Arc<dyn AnyToolHandler>>,
}
impl ToolRegistry {
fn handler(&self, name: &str, namespace: Option<&str>) -> Option<Arc<dyn AnyToolHandler>> {
self.handlers
.get(&tool_handler_key(name, namespace))
.map(Arc::clone)
}
}
fn tool_handler_key(tool_name: &str, namespace: Option<&str>) -> String {
if let Some(namespace) = namespace {
format!("{namespace}:{tool_name}")
} else {
tool_name.to_string()
}
}
The AnyToolHandler trait object wrapper provides type erasure, allowing heterogeneous handlers to coexist in the same registry. This design enables mixing built-in Rust handlers with dynamically-loaded MCP tools without compile-time coupling.
Startup Registration
Tool registration happens via the ToolRegistryBuilder pattern during session initialization:
// codex-rs/core/src/tools/spec.rs
pub(crate) fn build_specs_with_discoverable_tools(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
discoverable_tools: Option<Vec<DiscoverableTool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
let mut builder = ToolRegistryBuilder::new();
// Build a plan from all available tool sources
let plan = build_tool_registry_plan(
config,
ToolRegistryPlanParams {
mcp_tools: mcp_tools.as_ref(),
app_tools: app_tool_sources.as_deref(),
discoverable_tools: discoverable_tools.as_deref(),
dynamic_tools,
// ...
},
);
// Register handlers for each tool in the plan
for handler in plan.handlers {
match handler.kind {
ToolHandlerKind::Shell => {
builder.register_handler(handler.name, shell_handler.clone());
}
ToolHandlerKind::ApplyPatch => {
builder.register_handler(handler.name, apply_patch_handler.clone());
}
ToolHandlerKind::Mcp => {
builder.register_handler(handler.name, mcp_handler.clone());
}
// ... 25+ other handler kinds
}
}
builder
}
☕ 茶水间时间: The registry uses
Arcclones extensively for handler sharing. Multiple tool specs (likeshellandshell_command) can share the same underlyingShellHandlerinstance. This sharing isn't just about memory—it ensures that stateful handlers (like those tracking approval counts) maintain consistent state across different tool invocations.
Dynamic Discovery
MCP tools and dynamic tools don't exist at compile time, so they're discovered and registered at runtime. The build_tool_registry_plan function (codex-rs/tools/src/tool_registry_plan.rs) orchestrates this by:
- Collecting MCP server tool definitions via
McpConnectionManager - Parsing dynamic tool specs from the
dynamic_tools.tomlconfiguration - Discovering available connectors and plugins
- Building a unified
ToolRegistryPlancontaining both specs and handler mappings
MCP tools undergo special handling—they can be marked as defer_loading, meaning their full schema loads lazily when first called rather than upfront. This optimization reduces initial API payload size for servers with hundreds of tools.
Tool Categories
Tools organize into categories based on their function and origin:
| Category | Examples | Handler Kind |
|---|---|---|
| Execution | shell, shell_command, exec_command | Shell, ShellCommand |
| File Operations | apply_patch, list_dir | ApplyPatch, ListDir |
| Discovery | tool_search, tool_suggest | ToolSearch, ToolSuggest |
| Agents | spawn_agent_v2, wait_agent_v2 | SpawnAgentV2, WaitAgentV2 |
| MCP | All MCP server tools | Mcp |
| Interactive | request_user_input, view_image | RequestUserInput, ViewImage |
🤿 深入底层: The
ToolHandlerKindenum (codex-rs/tools/src/tool_registry_plan_types.rs) maps to concrete handler types via pattern matching. New handlers require adding both the kind variant and the corresponding match arm inbuild_specs_with_discoverable_tools. This design could be replaced with a procedural macro for registration, but the current approach makes handler-to-spec relationships explicit and auditable.
Execution Flow and Sandboxing
When the LLM calls a tool, that call flows through a precisely orchestrated pipeline. The sequence from request to response ensures security, provides visibility, and enables retry semantics.
Rendering diagram...
Request Parsing
Tool execution begins when the LLM emits a tool call in the streaming response. The core parses this into a ToolPayload enum:
// codex-rs/core/src/tools/context.rs
pub enum ToolPayload {
Function {
arguments: String, // JSON string of parameters
},
ToolSearch {
arguments: ToolSearchParams,
},
Custom {
input: String,
},
LocalShell {
params: LocalShellParams,
},
Mcp {
server: String,
tool: String,
raw_arguments: String,
},
}
The registry dispatches based on this payload type, routing Function calls through the standard handler path, Mcp calls to MCP servers, and LocalShell through the optimized shell execution path.
Validation
Parameter validation happens at multiple levels. The LLM ideally respects the JSON Schema, but Codex CLI defensively validates:
- Schema validation via
parse_tool_input_schemaensures the input conforms to the declared schema - Argument parsing via
parse_arguments<T>deserializes into the Rust struct - Handler-specific validation checks business logic constraints
// codex-rs/core/src/tools/handlers/shell.rs:236
let cwd = resolve_workdir_base_path(&arguments, turn.cwd.as_path())?;
let params: ShellToolCallParams =
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
⚠️ 避坑指南: The
parse_argumentsfunction usesserde_json::from_strunder the hood. If the LLM sends malformed JSON, this fails with aFunctionCallError::RespondToModel. The error message feeds back to the LLM so it can retry—this is intentional, as it lets the model self-correct without breaking the turn.
Sandboxing
Codex CLI's sandbox system provides defense-in-depth for tool execution. The SandboxManager selects an appropriate sandbox based on policy:
// codex-rs/core/src/tools/sandboxing.rs (excerpt)
pub enum SandboxType {
None, // No sandboxing (escalated)
LinuxBubblewrap, // Linux bwrap sandbox
LinuxLandlock, // Linux Landlock-only
WindowsJobObject, // Windows job object
}
The orchestrator's run method implements the attempt-and-escalate pattern:
- First attempt: Run with the sandbox selected by policy
- On denial: If the tool fails with
SandboxDenied, request approval for escalation - Second attempt: Retry without sandbox if user approves
// codex-rs/core/src/tools/orchestrator.rs:219
match first_result {
Ok(out) => Ok(OrchestratorRunResult {
output: out,
deferred_network_approval: first_deferred_network_approval,
}),
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. }))) => {
// Request escalation approval and retry
// ...
}
}
Execution
Actual tool execution happens in the ToolHandler::handle method. Each handler receives a ToolInvocation containing everything needed for execution:
pub struct ToolInvocation {
pub session: Arc<Session>,
pub turn: Arc<TurnContext>,
pub tracker: SharedTurnDiffTracker,
pub call_id: String,
pub tool_name: String,
pub tool_namespace: Option<String>,
pub payload: ToolPayload,
}
Handlers pull what they need from this struct—session state, configuration, working directory, etc.—and return a ToolOutput that serializes to the LLM response format.
Error Handling
Tool errors propagate through the FunctionCallError enum:
pub enum FunctionCallError {
RespondToModel(String), // Send message to LLM, continue turn
Fatal(String), // Abort the turn immediately
Rejected(String), // User rejected the tool call
}
The RespondToModel variant is particularly useful—it sends a message back to the LLM without ending the turn. This enables flow control like "this tool isn't available, try this other approach" without breaking conversation continuity.
Approval Workflow
Codex CLI's approval system puts the user in control of what tools can do. The three approval modes—Never, Always, and Interactive—provide different tradeoffs between convenience and security.
Three Approval Modes
The AskForApproval enum configures how approvals are requested:
// codex-rs/codex-protocol/src/protocol.rs
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AskForApproval {
Never, // Auto-approve all tool calls
Always, // Auto-reject all tool calls
OnRequest, // Prompt user for each call
}
The mode selection happens at the turn level based on configuration and can be overridden per-client (IDE integrations often use Never since they have their own approval UI).
Approval Requests
When a tool requires approval, the orchestrator calls ToolRuntime::start_approval_async:
// codex-rs/core/src/tools/orchestrator.rs:133
let decision = tool.start_approval_async(req, approval_ctx).await;
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
return Err(ToolError::Rejected(reason));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => { /* continue */ }
ReviewDecision::NetworkPolicyAmendment { .. } => { /* handle policy update */ }
}
The approval request includes context about why approval is needed (retry_reason), the command being executed, and for network requests, the target host and protocol.
Per-Tool Policies
Not all tools require the same approval scrutiny. The ExecApprovalRequirement enum captures three levels:
pub enum ExecApprovalRequirement {
Skip { reason: String }, // No approval needed
Forbidden { reason: String }, // Never allow
NeedsApproval { reason: String }, // Ask the user
}
Tools can declare their requirement via ToolRuntime::exec_approval_requirement. The shell tool, for example, checks against a "known safe" command list to determine if approval is needed:
// codex-rs/core/src/tools/handlers/shell.rs:194
async fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
match &invocation.payload {
ToolPayload::Function { arguments } => {
serde_json::from_str::<ShellToolCallParams>(arguments)
.map(|params| !is_known_safe_command(¶ms.command))
.unwrap_or(true)
}
_ => true,
}
}
🤿 深入底层: The
is_known_safe_commandfunction (codex-shell-command crate) maintains a curated list of read-only commands likels,pwd,echo, etc. Commands not on this list are considered "mutating" and require approval. This whitelist approach is safer than a blacklist (which can never be complete) but requires maintenance to avoid false positives.
Security Implications
The approval system has important security properties:
-
Session-scoped approvals: Once a command pattern is approved "for session," subsequent similar commands auto-approve. This is convenient but requires the
prefix_rulemechanism to prevent overly broad grants. -
Sandbox override: Approval to run "without sandbox" grants significant privilege. The system tracks this separately and requires explicit justification in the tool call.
-
Network approvals: Network access approvals cache at the host level (
HostApprovalKeycombines host, protocol, and port). This granularity prevents broad "allow any network" grants while allowing repeated access to approved services. -
Guardian integration: In enterprise deployments, the Guardian automated reviewer can intercept approvals, applying policy decisions before user prompts. This provides centralized governance without removing local control.
⚠️ 避坑指南: Session-scoped permissions persist across turns but not across conversation restarts. However, the
GrantedTurnPermissionsmechanism can extend permissions for the lifetime of a turn, which might span multiple tool calls. Be aware that granting "for session" is effectively granting "for the rest of this Codex invocation," not the entire terminal session.
Built-in Tools Deep Dive
Three built-in tools form the core of Codex CLI's capabilities: shell for command execution, apply_patch for file editing, and file_search (via tool_search) for fuzzy finding. Understanding these reveals patterns for implementing custom tools.
Shell Tool
The shell tool executes commands through the user's configured shell. Its handler (ShellHandler) demonstrates several key patterns:
// codex-rs/core/src/tools/handlers/shell.rs:223
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation { session, turn, tracker, call_id, tool_name, payload, .. } = invocation;
match payload {
ToolPayload::Function { arguments } => {
let params: ShellToolCallParams = parse_arguments(&arguments)?;
let exec_params = Self::to_exec_params(¶ms, turn.as_ref(), session.conversation_id);
Self::run_exec_like(RunExecLikeArgs {
tool_name: tool_name.clone(),
exec_params,
additional_permissions: params.additional_permissions.clone(),
prefix_rule: params.prefix_rule.clone(),
session, turn, tracker, call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
}).await
}
// ... other payload types
}
}
The handler's real work happens in run_exec_like, which:
- Applies environment variable overrides from the dependency environment
- Merges granted permissions with requested permissions
- Emits tool begin/finish events for UI tracking
- Creates the
ShellRequestand runs it through the orchestrator
The shell tool also intercepts apply_patch commands that come through the shell interface, redirecting them to the dedicated apply_patch handler for better handling.
Apply_patch Tool
The apply_patch tool applies unified diff format patches to files. Its implementation reveals several optimization strategies:
// codex-rs/core/src/tools/handlers/apply_patch.rs:142
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation { session, turn, tracker, call_id, tool_name, payload, .. } = invocation;
let patch_input = match payload {
ToolPayload::Function { arguments } => {
let args: ApplyPatchToolArgs = parse_arguments(&arguments)?;
args.input
}
ToolPayload::Custom { input } => input,
_ => return Err(FunctionCallError::RespondToModel(
"apply_patch handler received unsupported payload".to_string(),
)),
};
// Re-parse and verify the patch
match codex_apply_patch::maybe_parse_apply_patch_verified(&command, &cwd) {
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
// Compute write permissions needed for patch files
let file_paths = file_paths_for_action(&changes);
let write_permissions = write_permissions_for_paths(
&file_paths, &file_system_sandbox_policy, turn.cwd.as_path()
)?;
match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes).await {
InternalApplyPatchInvocation::Output(item) => {
Ok(ApplyPatchToolOutput::from_text(item?))
}
InternalApplyPatchInvocation::DelegateToExec(apply) => {
// Full exec workflow with approval, sandboxing, etc.
// ...
}
}
}
codex_apply_patch::MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
Err(FunctionCallError::RespondToModel(format!(
"apply_patch verification failed: {parse_error}"
)))
}
// ...
}
}
☕ 茶水间时间: The apply_patch tool has a fast path and a slow path. If the patch can be applied entirely within the sandbox's allowed directories, it takes the "Output" path and returns immediately. If it needs to write outside those directories, it takes the "DelegateToExec" path, which involves the full approval/orchestration flow. This optimization avoids prompting users for patches that touch safe files only.
The tool also computes the exact set of directories needing write access by walking the patch's file paths and finding their parent directories. This granular permission request means users grant access only to /home/user/project/src rather than /home/user/project or worse.
File_search Tool
File search happens through the tool_search tool, which provides fuzzy file finding. Rather than a direct handler, it's implemented as a client-side tool—the search executes on the client (TUI or IDE) rather than in the Codex core:
// codex-rs/tools/src/tool_discovery.rs:193
ToolSpec::ToolSearch {
execution: "client".to_string(), // Key: client-side execution
description,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["query".to_string()]),
additional_properties: Some(false.into()),
},
}
When the LLM calls tool_search, the core doesn't dispatch to a handler. Instead, it sends the call to the connected client, which performs the actual file system search using its native APIs. This design keeps the core lightweight and leverages the client's existing file watching and indexing infrastructure.
Integration Patterns
These three tools demonstrate common integration patterns:
| Pattern | Shell | Apply_patch | File_search |
|---|---|---|---|
| Handler type | Direct runtime | Mixed fast/slow path | Client-side |
| Approval flow | Via orchestrator | Via orchestrator | N/A (client) |
| Sandbox mode | User-configurable | Computed from file paths | N/A |
| Permission model | Command-based | Path-based | N/A |
| Event emission | Shell events | ApplyPatch events | N/A |
🤿 深入底层: The
ToolEmitterabstraction (codex-rs/core/src/tools/events.rs) provides structured event emission for all tools. Each tool type has specialized emitter methods (emitter.shell(),emitter.apply_patch()) that include relevant context. Events flow to the UI for display and to hooks for inspection. This unified event stream enables features like the TUI's tool call visualization and hook-based tool logging.
Example: Complete Custom Tool
Here's a complete example of a custom tool that queries a hypothetical API. The tool demonstrates the full lifecycle: spec definition, handler implementation, and registration:
use std::collections::BTreeMap;
use std::sync::Arc;
use codex_tools::JsonSchema;
use codex_tools::ToolSpec;
use codex_tools::ResponsesApiTool;
use codex_protocol::models::FunctionCallOutputContentItem;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::tools::registry::ToolRegistryBuilder;
// 1. Define the tool spec
pub fn create_weather_tool() -> ToolSpec {
let properties = BTreeMap::from([
("location".to_string(), JsonSchema::String {
description: Some("City name or ZIP code".to_string()),
}),
("units".to_string(), JsonSchema::String {
description: Some("Temperature units: 'celsius' or 'fahrenheit'".to_string()),
}),
]);
ToolSpec::Function(ResponsesApiTool {
name: "get_weather".to_string(),
description: "Get current weather for a location".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["location".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
// 2. Implement the handler
pub struct WeatherHandler;
impl ToolHandler for WeatherHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let params = serde_json::from_str::<WeatherParams>(
&match invocation.payload {
crate::tools::context::ToolPayload::Function { arguments } => arguments,
_ => return Err(FunctionCallError::RespondToModel(
"get_weather requires function payload".to_string()
)),
}
).map_err(|e| FunctionCallError::RespondToModel(format!("Invalid parameters: {e}")))?;
// Call the weather API (hypothetical)
let weather_data = fetch_weather(¶ms.location, params.units.as_deref()).await
.map_err(|e| FunctionCallError::RespondToModel(format!("Weather API error: {e}")))?;
Ok(FunctionToolOutput {
body: vec![FunctionCallOutputContentItem::InputText {
text: format!("Weather in {}: {}", params.location, weather_data)
}],
success: Some(true),
post_tool_use_response: None,
})
}
}
#[derive(serde::Deserialize)]
struct WeatherParams {
location: String,
units: Option<String>,
}
async fn fetch_weather(location: &str, units: Option<&str>) -> Result<String, reqwest::Error> {
// Actual API call implementation would go here
Ok(format!("22°{}", units.unwrap_or("C")))
}
// 3. Register the tool
pub fn register_weather_tool(builder: &mut ToolRegistryBuilder) {
builder.push_spec(create_weather_tool());
builder.register_handler("get_weather", Arc::new(WeatherHandler));
}
This complete example shows the three essential pieces: the ToolSpec that tells the LLM what the tool does, the ToolHandler implementation that executes it, and the registration that wires it into the system.
Tools provide atomic capabilities, but complex tasks require orchestration—the skills framework shows how Codex CLI composes tools into multi-step workflows that can handle sophisticated user requests.