Codex · EN · Part 4
Ch 13 Building Custom Tools
13-ch-13-Building-Custom-Tools.md
第13章 Building Custom Tools
Tool Anatomy
A complete tool implementation consists of several interconnected pieces: the trait definition, the specification schema, parameter parsing, execution logic, and result formatting. Understanding how these components fit together is the foundation for building effective custom tools.
At the core of every tool lies a simple contract: take structured input, perform some operation, and return structured output. The ResponsesApiTool structure from codex-rs/tools/src/responses_api.rs captures this contract:
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiTool {
pub name: String,
pub description: String,
pub strict: bool, // [1]
#[serde(skip_serializing_if = "Option::is_none")]
pub defer_loading: Option<bool>,
pub parameters: JsonSchema, // [2]
#[serde(skip)]
pub output_schema: Option<Value>, // [3]
} // [1] When true, all parameters must be marked as required
// [2] Defines the expected input structure using JSON Schema
// [3] Optional schema defining the tool's output format
The ToolSpec enum wraps different tool types, each representing a different way the AI can interact with your tool:
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool), // Standard function-calling tool
#[serde(rename = "tool_search")]
ToolSearch { ... }, // Tool discovery integration
#[serde(rename = "local_shell")]
LocalShell {}, // Direct shell access
#[serde(rename = "web_search")]
WebSearch { ... }, // Built-in web search
#[serde(rename = "custom")]
Freeform(FreeformTool), // Custom format tools (grammar-based)
}
🤿 深入底层:The
#[serde(tag = "type")]attribute creates a "tagged enum" serialization. When converted to JSON, thetypefield determines which variant the JSON represents. This is how the OpenAI Responses API distinguishes between tool types at runtime.
The execution flow for any tool follows a predictable sequence orchestrated by ToolOrchestrator in codex-rs/core/src/tools/orchestrator.rs:
- Approval check: The tool requests user approval if required by policy
- Sandbox selection: The system determines the appropriate sandbox level
- Execution: The tool runs within the selected security boundary
- Result formatting: Output is structured and returned to the agent
For execution, tools implement the ToolRuntime trait which combines two key capabilities: Approvable (handles approval flow) and Sandboxable (sandbox preferences):
pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {
async fn run(
&mut self,
req: &Req,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<Out, ToolError>;
}
Parameter Definition with JSON Schema
JSON Schema is the language that describes tool parameters to both Codex and the AI model. Codex's JsonSchema enum in codex-rs/tools/src/json_schema.rs provides a type-safe Rust representation of a JSON Schema subset:
#[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>,
},
#[serde(alias = "integer")]
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", ...)]
additional_properties: Option<AdditionalProperties>,
},
}
Here's how you'd define parameters for a hypothetical file upload tool:
use std::collections::BTreeMap;
let properties = BTreeMap::from([
("file_path".to_string(), JsonSchema::String {
description: Some("Path to the file to upload".to_string()),
}),
("destination".to_string(), JsonSchema::String {
description: Some("Target URL or bucket name".to_string()),
}),
("max_retries".to_string(), JsonSchema::Number {
description: Some("Maximum retry attempts (default: 3)".to_string()),
}),
("tags".to_string(), JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("Optional metadata tags for the uploaded file".to_string()),
}),
("options".to_string(), JsonSchema::Object {
properties: BTreeMap::from([
("compress".to_string(), JsonSchema::Boolean {
description: Some("Enable compression before upload".to_string()),
}),
("encrypt".to_string(), JsonSchema::Boolean {
description: Some("Enable encryption during transfer".to_string()),
}),
]),
required: None,
additional_properties: Some(false.into()),
}),
]);
let parameters = JsonSchema::Object {
properties,
required: Some(vec!["file_path".to_string(), "destination".to_string()]),
additional_properties: Some(false.into()),
};
This schema produces the following JSON when serialized:
{
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to upload"
},
"destination": {
"type": "string",
"description": "Target URL or bucket name"
},
"max_retries": {
"type": "number",
"description": "Maximum retry attempts (default: 3)"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Optional metadata tags for the uploaded file"
},
"options": {
"type": "object",
"properties": {
"compress": {
"type": "boolean",
"description": "Enable compression before upload"
},
"encrypt": {
"type": "boolean",
"description": "Enable encryption during transfer"
}
},
"additionalProperties": false
}
},
"required": ["file_path", "destination"],
"additionalProperties": false
}
⚠️ 避坑指南:Always set
additional_properties: falseon your top-level parameter object. Without this, the AI might invent parameters that don't exist in your tool, causing runtime errors when your code tries to access undefined fields.
The sanitize_json_schema function in json_schema.rs handles validation and error reporting by filling in missing defaults:
fn sanitize_json_schema(value: &mut JsonValue) {
// Infers type from context if missing
if schema_type.is_none() {
if map.contains_key("properties") {
schema_type = Some("object".to_string());
} else if map.contains_key("items") {
schema_type = Some("array".to_string());
}
// ... more inference rules
}
// Fills in required child fields with permissive defaults
if schema_type == "object" && !map.contains_key("properties") {
map.insert("properties".to_string(), JsonValue::Object(serde_json::Map::new()));
}
if schema_type == "array" && !map.contains_key("items") {
map.insert("items".to_string(), json!({ "type": "string" }));
}
}
Implementing Tool Logic
Once your parameters are defined, you need to implement the actual tool logic. Codex supports several common patterns for tool execution.
Shell command pattern is the most common—your tool executes an external program and returns its output. The create_exec_command_tool in local_tool.rs demonstrates this pattern:
pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
let properties = BTreeMap::from([
("cmd".to_string(), JsonSchema::String {
description: Some("Shell command to execute.".to_string()),
}),
("workdir".to_string(), JsonSchema::String {
description: Some("Optional working directory.".to_string()),
}),
("tty".to_string(), JsonSchema::Boolean {
description: Some("Whether to allocate a TTY.".to_string()),
}),
]);
ToolSpec::Function(ResponsesApiTool {
name: "exec_command".to_string(),
description: "Runs a command in a PTY...".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(unified_exec_output_schema()),
})
}
File operation pattern handles reading and writing files. The apply_patch tool in apply_patch_tool.rs shows a file-editing implementation:
pub fn create_apply_patch_json_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"input".to_string(),
JsonSchema::String {
description: Some("The entire contents of the apply_patch command".to_string()),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "apply_patch".to_string(),
description: APPLY_PATCH_JSON_TOOL_DESCRIPTION.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
HTTP request pattern enables API interactions. While Codex doesn't include a built-in HTTP tool template, you can create one using reqwest:
async fn execute_http_request(url: &str, method: &str, body: Option<&str>)
-> Result<String, ToolError> {
let client = reqwest::Client::new();
let request = match method {
"GET" => client.get(url),
"POST" => client.post(url).body(body.unwrap_or("").to_string()),
"PUT" => client.put(url).body(body.unwrap_or("").to_string()),
"DELETE" => client.delete(url),
_ => return Err(ToolError::Rejected("Invalid HTTP method".to_string())),
};
let response = request.send().await
.map_err(|e| ToolError::Codex(CodexErr::Generic(e.to_string())))?;
response.text().await
.map_err(|e| ToolError::Codex(CodexErr::Generic(e.to_string())))
}
Multi-step workflows combine multiple operations. The ToolOrchestrator in orchestrator.rs manages complex execution flows:
pub async fn run<Rq, Out, T>(
&mut self,
tool: &mut T,
req: &Rq,
tool_ctx: &ToolCtx,
turn_ctx: &TurnContext,
approval_policy: AskForApproval,
) -> Result<OrchestratorRunResult<Out>, ToolError>
where
T: ToolRuntime<Rq, Out>,
{
// 1) Approval
let requirement = tool.exec_approval_requirement(req)?;
match requirement {
ExecApprovalRequirement::Skip { .. } => { /* proceed */ }
ExecApprovalRequirement::Forbidden { reason } => {
return Err(ToolError::Rejected(reason));
}
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
let decision = tool.start_approval_async(req, approval_ctx).await;
match decision {
ReviewDecision::Denied => return Err(ToolError::Rejected("rejected".to_string())),
ReviewDecision::Approved => { /* proceed */ }
_ => { /* handle other variants */ }
}
}
}
// 2) Sandbox selection and execution
let attempt = SandboxAttempt { /* ... */ };
let result = tool.run(req, &attempt, tool_ctx).await?;
Ok(OrchestratorRunResult { output: result, ... })
}
Error handling in tools follows a specific pattern using the ToolError enum:
#[derive(Debug)]
pub(crate) enum ToolError {
Rejected(String), // User denied approval
Codex(CodexErr), // Runtime error
}
☕ 茶水间时间:Why separate
RejectedfromCodexerrors? The orchestrator treats them differently—rejections are final and don't trigger retry logic, whileCodexerrors might be transient (network timeout, file lock) and can trigger escalation to a less restrictive sandbox.
Timeout management prevents tools from hanging indefinitely:
async fn run_with_timeout<F, T>(
future: F,
timeout_ms: u64,
) -> Result<T, ToolError>
where
F: Future<Output = Result<T, ToolError>>,
{
tokio::time::timeout(
Duration::from_millis(timeout_ms),
future,
)
.await
.map_err(|_| ToolError::Codex(CodexErr::Generic("Tool execution timed out".to_string())))?
}
Cancellation uses async Rust's cooperative cancellation model:
pub async fn run_cancellable(
cancel_token: CancellationToken,
operation: impl Future<Output = Result<String, ToolError>>,
) -> Result<String, ToolError> {
tokio::select! {
result = operation => result,
_ = cancel_token.cancelled() => {
Err(ToolError::Codex(CodexErr::Generic("Operation cancelled".to_string())))
}
}
}
Sandbox and Approval Integration
Tools don't run in a vacuum—they interact with Codex's security systems. The sandbox limits what resources a tool can access, while the approval system decides whether execution should proceed.
Sandbox integration is expressed through the Sandboxable trait:
pub(crate) trait Sandboxable {
fn sandbox_preference(&self) -> SandboxablePreference;
fn escalate_on_failure(&self) -> bool { true }
}
The SandboxablePreference enum (from codex_sandboxing) declares your tool's security requirements:
pub enum SandboxablePreference {
PreferSandbox, // Run sandboxed if possible
RequireSandbox, // Must run sandboxed
AllowUnsandboxed, // Can run without sandbox
}
When a tool executes, the SandboxAttempt structure provides the execution environment:
pub(crate) struct SandboxAttempt<'a> {
pub sandbox: SandboxType, // None, Landlock, Bubblewrap
pub policy: &'a SandboxPolicy,
pub file_system_policy: &'a FileSystemSandboxPolicy,
pub network_policy: NetworkSandboxPolicy,
pub manager: &'a SandboxManager,
// ... more fields
}
To execute a command within the sandbox, tools use the env_for method:
let exec_request = attempt.env_for(
SandboxCommand::new(cmd_vec),
ExecOptions { timeout: Some(Duration::from_secs(30)), ..Default::default() },
None, // No network proxy
)?;
Approval requests use the Approvable trait:
pub(crate) trait Approvable<Req> {
type ApprovalKey: Hash + Eq + Clone + Debug + Serialize;
fn approval_keys(&self, req: &Req) -> Vec<Self::ApprovalKey>;
fn start_approval_async<'a>(
&'a mut self,
req: &'a Req,
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision>;
}
The ApprovalCtx provides context for the approval decision:
pub(crate) struct ApprovalCtx<'a> {
pub session: &'a Arc<Session>,
pub turn: &'a Arc<TurnContext>,
pub call_id: &'a str,
pub retry_reason: Option<String>,
pub network_approval_context: Option<NetworkApprovalContext>,
}
Tools can specify custom approval requirements:
fn exec_approval_requirement(&self, _req: &Req) -> Option<ExecApprovalRequirement> {
Some(ExecApprovalRequirement::NeedsApproval {
reason: Some("This tool modifies production data".to_string()),
proposed_execpolicy_amendment: None,
})
}
Approval response handling caches session-level approvals:
pub(crate) async fn with_cached_approval<K, F, Fut>(
services: &SessionServices,
tool_name: &str,
keys: Vec<K>,
fetch: F,
) -> ReviewDecision
where
K: Serialize,
F: FnOnce() -> Fut,
Fut: Future<Output = ReviewDecision>,
{
// Check if already approved for session
let already_approved = {
let store = services.tool_approvals.lock().await;
keys.iter().all(|key| matches!(
store.get(key),
Some(ReviewDecision::ApprovedForSession)
))
};
if already_approved {
return ReviewDecision::ApprovedForSession;
}
// Request approval
let decision = fetch().await;
// Cache session approvals
if matches!(decision, ReviewDecision::ApprovedForSession) {
let mut store = services.tool_approvals.lock().await;
for key in keys {
store.put(key, ReviewDecision::ApprovedForSession);
}
}
decision
}
Security best practices for custom tools:
- Declare sandbox requirements explicitly—don't assume unrestricted access
- Request approval for destructive operations—deletes, writes, network calls
- Validate all input parameters—even though JSON Schema provides validation
- Use the sandbox for all external commands—never bypass without explicit approval
- Handle cancellation gracefully—check cancel_token in long-running operations
- Log security-relevant events—approvals, escalations, policy violations
⚠️ 避坑指南:Never construct shell commands by concatenating user input. Always use structured argument passing. The
SandboxCommandAPI handles this safely by taking aVec<String>of arguments rather than a raw command string.
Complete Example: Database Query Tool
Let's build a complete custom tool from start to finish—a database query tool that lets the AI execute read-only SQL queries against a PostgreSQL database.
Schema definition comes first:
use std::collections::BTreeMap;
fn create_db_query_tool() -> ToolSpec {
let properties = BTreeMap::from([
("query".to_string(), JsonSchema::String {
description: Some("SQL SELECT query to execute".to_string()),
}),
("database".to_string(), JsonSchema::String {
description: Some("Database name (from configured connections)".to_string()),
}),
("timeout_ms".to_string(), JsonSchema::Number {
description: Some("Query timeout in milliseconds".to_string()),
}),
("max_rows".to_string(), JsonSchema::Number {
description: Some("Maximum rows to return (default: 1000)".to_string()),
}),
]);
ToolSpec::Function(ResponsesApiTool {
name: "db_query".to_string(),
description: "Execute a read-only SQL query against a configured PostgreSQL database.
Only SELECT statements are allowed; modifications are rejected."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["query".to_string(), "database".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(db_query_output_schema()),
})
}
fn db_query_output_schema() -> Value {
json!({
"type": "object",
"properties": {
"rows": {
"type": "array",
"description": "Query result rows",
"items": {
"type": "object",
"additionalProperties": { "type": ["string", "number", "boolean", "null"] }
}
},
"columns": {
"type": "array",
"description": "Column names in result order",
"items": { "type": "string" }
},
"row_count": {
"type": "number",
"description": "Number of rows returned"
},
"execution_time_ms": {
"type": "number",
"description": "Query execution time in milliseconds"
}
},
"required": ["rows", "columns", "row_count", "execution_time"],
"additionalProperties": false
})
}
Request structure defines the input:
#[derive(Debug, Clone, Deserialize)]
pub struct DbQueryRequest {
pub query: String,
pub database: String,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
#[serde(default = "default_max_rows")]
pub max_rows: usize,
}
fn default_timeout() -> u64 { 30000 } // 30 seconds
fn default_max_rows() -> usize { 1000 }
Result structure defines the output:
#[derive(Debug, Clone, Serialize)]
pub struct DbQueryResult {
pub rows: Vec<serde_json::Value>,
pub columns: Vec<String>,
pub row_count: usize,
pub execution_time_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct DbQueryError {
pub error: String,
pub error_code: Option<String>,
pub hint: Option<String>,
}
Connection handling manages database connections:
use sqlx::PgPool;
pub struct DbQueryTool {
pools: std::collections::HashMap<String, PgPool>,
}
impl DbQueryTool {
pub async fn new(config: &DbConfig) -> Result<Self, ToolError> {
let mut pools = std::collections::HashMap::new();
for (name, conn_str) in &config.connections {
let pool = PgPool::connect(conn_str).await
.map_err(|e| ToolError::Codex(CodexErr::Generic(
format!("Failed to connect to database {}: {}", name, e)
)))?;
pools.insert(name.clone(), pool);
}
Ok(DbQueryTool { pools })
}
fn get_pool(&self, database: &str) -> Result<&PgPool, ToolError> {
self.pools.get(database).ok_or_else(|| {
ToolError::Rejected(format!("Database '{}' not configured", database))
})
}
}
Query execution implements the core logic:
impl DbQueryTool {
pub async fn execute_query(&self, req: &DbQueryRequest)
-> Result<DbQueryResult, ToolError> {
// Validate query is read-only
Self::validate_read_only(&req.query)?;
let pool = self.get_pool(&req.database)?;
let start = std::time::Instant::now();
// Execute with timeout
let result = tokio::time::timeout(
Duration::from_millis(req.timeout_ms),
Self::fetch_rows(pool, &req.query, req.max_rows),
)
.await
.map_err(|_| {
ToolError::Codex(CodexErr::Generic("Query timed out".to_string()))
})?
.map_err(|e: sqlx::Error| {
ToolError::Codex(CodexErr::Generic(format!("Query failed: {}", e)))
})?;
let execution_time_ms = start.elapsed().as_millis() as u64;
Ok(DbQueryResult {
row_count: result.rows.len(),
execution_time_ms,
..result
})
}
fn validate_read_only(query: &str) -> Result<(), ToolError> {
let normalized = query.to_uppercase();
// Block modification keywords
let forbidden = ["INSERT", "UPDATE", "DELETE", "DROP", "CREATE",
"ALTER", "TRUNCATE", "GRANT", "REVOKE"];
for keyword in &forbidden {
if normalized.contains(keyword) {
return Err(ToolError::Rejected(format!(
"Only SELECT queries are allowed. Found keyword: {}", keyword
)));
}
}
// Must start with SELECT or WITH (CTE)
if !normalized.starts_with("SELECT") && !normalized.starts_with("WITH") {
return Err(ToolError::Rejected(
"Query must start with SELECT or WITH".to_string()
));
}
Ok(())
}
async fn fetch_rows(
pool: &PgPool,
query: &str,
max_rows: usize,
) -> Result<DbQueryResult, sqlx::Error> {
use sqlx::Row;
let mut result_set = sqlx::query(query)
.fetch_all(pool)
.await?;
// Get column names
let columns = result_set.columns()
.iter()
.map(|c| c.name().to_string())
.collect();
// Convert rows to JSON
let rows: Vec<serde_json::Value> = result_set
.drain(..)
.take(max_rows)
.map(|row| {
let mut obj = serde_json::Map::new();
for (i, col) in row.columns().iter().enumerate() {
let name = col.name();
let value: Option<String> = row.try_get(i).ok();
obj.insert(name.to_string(),
serde_json::to_value(value).unwrap_or(serde_json::Value::Null));
}
serde_json::Value::Object(obj)
})
.collect();
Ok(DbQueryResult {
rows,
columns,
row_count: 0, // Will be set by caller
execution_time_ms: 0, // Will be set by caller
})
}
}
Integration with ToolRuntime makes it work with the orchestrator:
impl Approvable<DbQueryRequest> for DbQueryTool {
type ApprovalKey = (String, String); // (database, query_hash)
fn approval_keys(&self, req: &DbQueryRequest) -> Vec<Self::ApprovalKey> {
let query_hash = format!("{:x}", md5::compute(req.query.as_bytes()));
vec![(req.database.clone(), query_hash)]
}
fn exec_approval_requirement(&self, _req: &DbQueryRequest)
-> Option<ExecApprovalRequirement> {
// Database queries always need approval due to potential data exposure
Some(ExecApprovalRequirement::NeedsApproval {
reason: Some("Database queries may access sensitive data".to_string()),
proposed_execpolicy_amendment: None,
})
}
fn start_approval_async<'a>(
&'a mut self,
req: &'a DbQueryRequest,
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision> {
Box::pin(async move {
let prompt = format!(
"Allow query on database '{}'?\n\nQuery: {}",
req.database,
req.query.lines().take(5).collect::<Vec<_>>().join("\n")
);
with_cached_approval(
&ctx.session.services,
"db_query",
self.approval_keys(req),
|| async {
ctx.session.request_approval(
&ctx.turn.sub_id,
&ctx.call_id,
&prompt,
None, // No proposed amendment
).await
}
).await
})
}
}
impl Sandboxable for DbQueryTool {
fn sandbox_preference(&self) -> SandboxablePreference {
// Database queries don't need filesystem access
SandboxablePreference::AllowUnsandboxed
}
}
impl ToolRuntime<DbQueryRequest, DbQueryResult> for DbQueryTool {
async fn run(
&mut self,
req: &DbQueryRequest,
_attempt: &SandboxAttempt<'_>,
_ctx: &ToolCtx,
) -> Result<DbQueryResult, ToolError> {
self.execute_query(req).await
}
}
Testing the tool requires a test database:
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_read_only_validation() {
assert!(DbQueryTool::validate_read_only("SELECT * FROM users").is_ok());
assert!(DbQueryTool::validate_read_only("WITH cte AS (SELECT 1) SELECT * FROM cte").is_ok());
assert!(DbQueryTool::validate_read_only("DROP TABLE users").is_err());
assert!(DbQueryTool::validate_read_only("UPDATE users SET x=1").is_err());
assert!(DbQueryTool::validate_read_only("INSERT INTO users VALUES (1)").is_err());
}
#[tokio::test]
async fn test_max_rows_limit() {
let pool = PgPool::connect("postgresql://localhost/test_db").await.unwrap();
let tool = DbQueryTool {
pools: std::collections::HashMap::from([
("test".to_string(), pool)
]),
};
let req = DbQueryRequest {
query: "SELECT generate_series(1, 5000)".to_string(),
database: "test".to_string(),
timeout_ms: 5000,
max_rows: 100,
};
let result = tool.execute_query(&req).await.unwrap();
assert_eq!(result.row_count, 100); // Limited by max_rows
}
}
Debugging database tools requires careful error handling:
// In your tool implementation, add detailed error context
.map_err(|e: sqlx::Error| {
let error_context = match e {
sqlx::Error::Database(db_err) => {
format!("{}: {}", db_err.message(), db_err.code().unwrap_or(""))
}
sqlx::Error::PoolTimedOut => {
"Connection pool timeout - check database connectivity".to_string()
}
sqlx::Error::RowNotFound => {
"No rows returned from query".to_string()
}
_ => e.to_string()
};
ToolError::Codex(CodexErr::Generic(format!(
"Database query failed: {}", error_context
)))
})?;
The complete database query tool demonstrates all key aspects of custom tool development: JSON Schema parameter definition, validation, execution logic, error handling, sandbox integration, and the approval workflow.
Chapter Exercises
- Build a tool that calls a REST API with configurable authentication headers
- Create a tool with complex nested parameters (objects within objects, arrays of objects)
- Add approval handling to a tool that requires different approval levels for different operations
Tools extend what Codex can do, but skills determine how it uses those capabilities. The next chapter covers advanced skill authoring, where you'll learn to create sophisticated behaviors that orchestrate tools into powerful workflows.