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_schema function (codex-rs/tools/src/json_schema.rs:77) performs defensive normalization of incoming schemas. It infers missing type fields from structural hints—properties implies object, items implies 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_mutating default returns false, but this is intentionally defensive—tools that might mutate should override this to return true. 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 returning true if the effects aren't crystal clear.

Built-in vs Custom Tools

Codex CLI distinguishes between three categories of tools:

  1. Built-in tools compiled into the binary (shell, apply_patch, file_search)
  2. MCP tools dynamically loaded from MCP servers at runtime
  3. 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 Arc clones extensively for handler sharing. Multiple tool specs (like shell and shell_command) can share the same underlying ShellHandler instance. 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:

  1. Collecting MCP server tool definitions via McpConnectionManager
  2. Parsing dynamic tool specs from the dynamic_tools.toml configuration
  3. Discovering available connectors and plugins
  4. Building a unified ToolRegistryPlan containing 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:

CategoryExamplesHandler Kind
Executionshell, shell_command, exec_commandShell, ShellCommand
File Operationsapply_patch, list_dirApplyPatch, ListDir
Discoverytool_search, tool_suggestToolSearch, ToolSuggest
Agentsspawn_agent_v2, wait_agent_v2SpawnAgentV2, WaitAgentV2
MCPAll MCP server toolsMcp
Interactiverequest_user_input, view_imageRequestUserInput, ViewImage

🤿 深入底层: The ToolHandlerKind enum (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 in build_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:

  1. Schema validation via parse_tool_input_schema ensures the input conforms to the declared schema
  2. Argument parsing via parse_arguments<T> deserializes into the Rust struct
  3. 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_arguments function uses serde_json::from_str under the hood. If the LLM sends malformed JSON, this fails with a FunctionCallError::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:

  1. First attempt: Run with the sandbox selected by policy
  2. On denial: If the tool fails with SandboxDenied, request approval for escalation
  3. 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(&params.command))
                .unwrap_or(true)
        }
        _ => true,
    }
}

🤿 深入底层: The is_known_safe_command function (codex-shell-command crate) maintains a curated list of read-only commands like ls, 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:

  1. Session-scoped approvals: Once a command pattern is approved "for session," subsequent similar commands auto-approve. This is convenient but requires the prefix_rule mechanism to prevent overly broad grants.

  2. Sandbox override: Approval to run "without sandbox" grants significant privilege. The system tracks this separately and requires explicit justification in the tool call.

  3. Network approvals: Network access approvals cache at the host level (HostApprovalKey combines host, protocol, and port). This granularity prevents broad "allow any network" grants while allowing repeated access to approved services.

  4. 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 GrantedTurnPermissions mechanism 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(&params, 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:

  1. Applies environment variable overrides from the dependency environment
  2. Merges granted permissions with requested permissions
  3. Emits tool begin/finish events for UI tracking
  4. Creates the ShellRequest and 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:

PatternShellApply_patchFile_search
Handler typeDirect runtimeMixed fast/slow pathClient-side
Approval flowVia orchestratorVia orchestratorN/A (client)
Sandbox modeUser-configurableComputed from file pathsN/A
Permission modelCommand-basedPath-basedN/A
Event emissionShell eventsApplyPatch eventsN/A

🤿 深入底层: The ToolEmitter abstraction (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(&params.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.