Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Writing custom tools

Custom tools are the primary extension mechanism in agentkit. This chapter shows how to implement tools from simple to sophisticated, including preflight actions, custom permission types, and shared resources.

A minimal tool

#![allow(unused)]
fn main() {
use agentkit_tools_core::*;
use agentkit_core::*;
use async_trait::async_trait;
use serde_json::json;

pub struct EchoTool {
    spec: ToolSpec,
}

impl EchoTool {
    pub fn new() -> Self {
        Self {
            spec: ToolSpec {
                name: ToolName::new("echo"),
                description: "Return the input unchanged".into(),
                input_schema: json!({
                    "type": "object",
                    "properties": {
                        "message": { "type": "string" }
                    },
                    "required": ["message"]
                }),
                annotations: ToolAnnotations {
                    read_only_hint: true,
                    ..Default::default()
                },
                metadata: MetadataMap::new(),
            },
        }
    }
}

#[async_trait]
impl Tool for EchoTool {
    fn spec(&self) -> &ToolSpec {
        &self.spec
    }

    async fn invoke(
        &self,
        request: ToolRequest,
        _ctx: &mut ToolContext<'_>,
    ) -> Result<ToolResult, ToolError> {
        let message = request.input["message"]
            .as_str()
            .ok_or_else(|| ToolError::InvalidInput("missing message".into()))?;

        Ok(ToolResult {
            result: ToolResultPart {
                call_id: request.call_id,
                output: ToolOutput::Text(message.to_string()),
                is_error: false,
                metadata: MetadataMap::new(),
            },
            duration: None,
            metadata: MetadataMap::new(),
        })
    }
}
}

Register it:

#![allow(unused)]
fn main() {
let registry = ToolRegistry::new().with(EchoTool::new());
}

Using ToolContext

Tools receive a ToolContext that provides access to the current session, permissions, cancellation state, and shared resources:

#![allow(unused)]
fn main() {
async fn invoke(&self, request: ToolRequest, ctx: &mut ToolContext<'_>) -> Result<ToolResult, ToolError> {
    // Check cancellation
    if let Some(ref cancel) = ctx.cancellation {
        if cancel.is_cancelled() {
            return Err(ToolError::Cancelled);
        }
    }

    // Access shared resources
    let resources = ctx.resources;

    // Access session identity
    let session_id = ctx.capability.session_id;

    // ...
}
}

Adding preflight permission requests

For tools with side effects, override proposed_requests on the Tool trait to expose proposed actions before execution:

#![allow(unused)]
fn main() {
impl Tool for DeployTool {
    fn spec(&self) -> &ToolSpec { &self.spec }

    fn proposed_requests(
        &self,
        request: &ToolRequest,
    ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
        let env = request.input["environment"].as_str().unwrap_or("unknown");
        Ok(vec![Box::new(DeployPermissionRequest {
            environment: env.to_string(),
            service: "my-service".into(),
            metadata: MetadataMap::new(),
        })])
    }

    async fn invoke(&self, request: ToolRequest, ctx: &mut ToolContext<'_>)
        -> Result<ToolResult, ToolError> { /* ... */ }
}
}

The executor evaluates these before calling invoke(). If any are denied or require approval, execution stops before any side effects occur.

Custom permission requests

Define your own permission request types:

#![allow(unused)]
fn main() {
pub struct DeployPermissionRequest {
    pub environment: String,
    pub service: String,
    pub metadata: MetadataMap,
}

impl PermissionRequest for DeployPermissionRequest {
    fn kind(&self) -> &'static str { "myapp.deploy" }
    fn summary(&self) -> String {
        format!("Deploy {} to {}", self.service, self.environment)
    }
    fn metadata(&self) -> &MetadataMap { &self.metadata }
    fn as_any(&self) -> &dyn Any { self }
}
}

Host policies can match on kind() generically, or downcast through as_any() for type-safe field access.

Shared resources via ToolResources

If your tool needs session-scoped state (like the filesystem tools’ read-before-write tracker), implement ToolResources:

#![allow(unused)]
fn main() {
pub trait ToolResources: Send + Sync {
    fn as_any(&self) -> &dyn Any;
}
}

Register resources when building the agent, and downcast in your tool’s invoke() method.

Tool composition patterns

Nesting agents as tools

A powerful pattern: implement a tool that runs a nested agent loop. The outer agent calls the tool with a task description, the tool starts an inner agent, runs it to completion, and returns the result.

Outer agent (orchestrator):
  Model: "I need to research this codebase and write a report"
  Model: ToolCall(subagent, { task: "Find all uses of unsafe code", tools: ["fs", "shell"] })
         │
         ▼
  Inner agent (researcher):
    Model: ToolCall(fs.read_file, { path: "src/lib.rs" })
    Model: ToolCall(shell.exec, { executable: "grep", argv: ["-r", "unsafe", "src/"] })
    Model: "Found 3 uses of unsafe in parser.rs, codec.rs, and ffi.rs..."
         │
         ▼
  Outer agent receives: "Found 3 uses of unsafe..."
  Model: "Based on my research, here's the report..."

The inner agent has its own transcript, tools, and session. It doesn’t share state with the outer agent — this isolation prevents context pollution and makes the sub-agent’s scope explicit.

The openrouter-subagent-tool example shows a complete implementation of this pattern.

Tool registries from crates

Organize related tools into crate-level registry() functions:

#![allow(unused)]
fn main() {
pub fn registry() -> ToolRegistry {
    ToolRegistry::new()
        .with(ToolA::default())
        .with(ToolB::default())
}
}

Host applications merge registries from multiple crates:

#![allow(unused)]
fn main() {
let registry = my_tools::registry()
    .merge(agentkit_tool_fs::registry())
    .merge(agentkit_tool_shell::registry());
}

Stateful tools

Tools that need to maintain state across invocations (counters, caches, connection pools) should use ToolResources:

#![allow(unused)]
fn main() {
struct MyToolResources {
    cache: Mutex<HashMap<String, String>>,
    http_client: reqwest::Client,
}

impl ToolResources for MyToolResources {
    fn as_any(&self) -> &dyn Any { self }
}

// In your tool's invoke():
let resources = ctx.resources
    .as_any()
    .downcast_ref::<MyToolResources>()
    .expect("MyToolResources not registered");

let mut cache = resources.cache.lock().unwrap();
}

Register resources when building the agent:

#![allow(unused)]
fn main() {
let agent = Agent::builder()
    .model(adapter)
    .resources(MyToolResources::new())
    .build()?;
}

All tools in the session share the same ToolResources instance. This is how the filesystem tools share their read-before-write tracker — FileSystemToolResources implements ToolResources and is downcast in each tool’s invoke().

Example: openrouter-subagent-tool implements a custom tool that runs a nested agent as a tool call.

Crate: agentkit-tools-coreTool, ToolRegistry, ToolResources, ToolContext.