Custom Tools
Openheim's tool system is trait-based. Implementing ToolHandler is all you need to expose a new capability to the agent.
For an external tool source (databases, APIs, third-party services), prefer an MCP server — openheim will load its tools automatically. This guide covers writing a tool that runs in the same process as the agent.
The ToolHandler trait
#[async_trait]
pub trait ToolHandler: Send + Sync {
/// Returns the tool's schema — what it's called and what arguments it accepts.
fn definition(&self) -> Tool;
/// Executes the tool with JSON-encoded arguments and returns the result as a string.
async fn execute(&self, args: &str, turn: &TurnContext<'_>) -> Result<String>;
/// Declares what this tool is. Defaults to `ToolCapabilities::default()`
/// — not read-only, generic `ToolKindHint::Other`, approvals keyed by
/// tool name. Override it to opt into behavior that depends on it (see
/// below).
fn capabilities(&self) -> ToolCapabilities {
ToolCapabilities::default()
}
}
The definition method runs once at startup to populate the list sent to the LLM. The execute method is called each time the LLM decides to use the tool; its result (or error text) is cut to 128 KiB before it reaches the model, so return a summary or one page of a large result rather than all of it. capabilities is consulted three places: Architect mode only exposes tools with read_only: true; the approval-remembering logic uses approval_scope to decide whether "Allow Always" covers every call to the tool (ApprovalScope::ToolName, the default) or only calls with the same arguments (ApprovalScope::ExactArguments, compared as JSON so key order and whitespace don't matter — what execute_command uses, so approving git status can't silently cover git status && rm -rf ~); and the ACP transport uses kind for client-side icon treatment. A read-only tool (e.g. one that only queries an API) should set read_only: true so it works in Architect mode too:
fn capabilities(&self) -> ToolCapabilities {
ToolCapabilities::default()
.with_read_only(true)
.with_kind(ToolKindHint::Search)
}
ToolCapabilities is #[non_exhaustive], so start from default() and use the with_* setters; a struct literal, even with ..Default::default(), doesn't compile outside openheim.
turn is the calling turn's openheim::core::turn::TurnContext. It carries everything the built-in tools use to behave well inside an agent session, and custom tools get exactly the same:
| Field | Type | Use it for |
|---|---|---|
turn.cancel |
&CancellationToken |
Race long-running work against it (tokio::select!) so a session/cancel can interrupt your tool. |
turn.work_dir |
&Path |
The sandbox boundary: nothing your tool touches may lie outside it. |
turn.cwd |
&Path |
The session's working directory, inside work_dir: where relative paths resolve and where a command you spawn should run. |
turn.resolve_path(path) |
method | Resolve any user- or LLM-supplied path before touching the filesystem: relative paths are taken from cwd, symlinks are followed, and anything outside work_dir is rejected. |
turn.client_io |
&dyn ClientIo |
Ask the client (e.g. an editor's unsaved buffers) to read/write a file before falling back to local I/O. Returns None when there is no client to ask. read_file takes a LineRange (openheim::core::client_io::LineRange): line (1-based) and limit, both optional; the default is the whole file. |
turn.permission_gate |
&Arc<dyn PermissionGate> |
Already consulted by the agent loop before your tool runs; only relevant if your tool spawns nested agent turns. |
Ignore the fields you don't need — a tool that calls an HTTP API only cares about turn.cancel, if that.
openheim::tools::args::parse::<T>(args) decodes the arguments into your own #[derive(Deserialize)] struct, which is what the built-ins do. Required schema fields are plain fields, optional ones Option<_> or #[serde(default)], and args::NonEmptyString rejects blank text. On failure it returns the same "failed to parse arguments: …" error the built-ins use (serde names the missing or mistyped field), so the LLM sees consistent feedback. If you'd rather work with a raw serde_json::Value, use parse_args(args) (same "failed to parse arguments: …" error for malformed JSON) and require_str(&value, "key") (returns "missing 'key' argument"); the example below uses them.
Step-by-step example
The following implements a lookup tool that queries a fixed third-party API and returns the response body.
1. Define the struct
use async_trait::async_trait;
use openheim::error::{Error, Result};
use openheim::core::models::Tool;
use openheim::core::turn::TurnContext;
use openheim::tools::ToolHandler;
use openheim::tools::args::{parse_args, require_str};
use serde_json::json;
pub struct LookupTool {
client: reqwest::Client,
}
impl LookupTool {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
2. Implement definition
Return a Tool with a JSON Schema describing the arguments. The LLM uses the description fields to decide when and how to call the tool.
fn definition(&self) -> Tool {
Tool::function(
"lookup",
"Query the example API for a term and return the result as text. \
Use for looking up documentation or reference entries.",
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The term to look up"
}
},
"required": ["query"]
}),
)
}
3. Implement execute
Parse the JSON arguments, run the operation, and return a String. Return Err only for infrastructure failures — for user-visible failures (e.g. HTTP 404), prefer returning a descriptive string so the LLM can react to the failure. Racing the request against turn.cancel lets the user interrupt a slow fetch.
Don't hand an LLM-supplied URL straight to client.get(url).send(). The model can be steered (directly or via prompt injection in fetched content) into requesting internal addresses — cloud metadata endpoints, loopback, RFC1918 ranges — turning the tool into an SSRF primitive. Openheim already ships a hardened web_fetch built-in (scheme allowlist, DNS-resolve-then-check with the checked address pinned for the actual connection so a second, rebound lookup can't bypass it, no automatic redirects, timeout, and a response size cap); prefer registering that instead of writing your own general-purpose fetcher. If your tool genuinely needs its own HTTP call, restrict it to a fixed, non-LLM-controlled endpoint rather than an arbitrary model-supplied URL:
const API_ENDPOINT: &str = "https://api.example.com/v1/lookup";
async fn execute(&self, args: &str, turn: &TurnContext<'_>) -> Result<String> {
let v = parse_args(args)?;
let query = require_str(&v, "query")?; // used as a query param, never as the URL itself
let request = async {
let response = self.client
.get(API_ENDPOINT)
.query(&[("q", query)])
.send()
.await
.map_err(|e| Error::ToolExecutionError(format!("request failed: {e}")))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| Error::ToolExecutionError(format!("failed to read body: {e}")))?;
if !status.is_success() {
return Ok(format!("HTTP {status}: {body}"));
}
Ok(body)
};
tokio::select! {
_ = turn.cancel.cancelled() => Err(Error::ToolExecutionError("lookup cancelled".into())),
result = request => result,
}
}
A tool that touches the filesystem should validate its path first, and prefer the client's view of the file when there is one:
use openheim::core::client_io::LineRange;
async fn execute(&self, args: &str, turn: &TurnContext<'_>) -> Result<String> {
let v = parse_args(args)?;
let path = turn.resolve_path(require_str(&v, "path")?)?;
// `LineRange::default()` is the whole file; `LineRange::new(line, limit)` a range.
let content = match turn.client_io.read_file(&path, LineRange::default()).await {
Some(result) => result?, // the client answered
None => tokio::fs::read_to_string(&path).await?, // no client: local disk
};
Ok(content.lines().count().to_string())
}
4. Register the tool
The simplest path is the client builder, which wires the tool into a fully-configured runtime (sandbox, MCP servers, subagents, history):
let client = OpenheimClient::builder()
.tool(Box::new(FetchUrlTool::new()))
.build()
.await?;
If you're driving the agent loop yourself, use SystemToolExecutor::register and build the TurnContext with TurnContext::new (its cwd is work_dir unless you set another with with_cwd):
use openheim::tools::SystemToolExecutor;
use openheim::core::agent::run_agent;
use openheim::core::client_io::NoClientIo;
use openheim::core::models::Message;
use openheim::core::permission::{AllowAll, PermissionGate};
use openheim::core::turn::TurnContext;
use openheim::config::{AgentConfig, load_config};
use std::path::Path;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() -> openheim::Result<()> {
let app_config = load_config()?;
let agent_config = app_config.resolve(None)?;
let mut executor = SystemToolExecutor::new();
executor.register_builtins(app_config.allow_shell); // built-ins
executor.register(Box::new(FetchUrlTool::new())); // your tool
// Build the LLM client from config
let http = openheim::config::build_http_client(agent_config.timeout_secs)?;
let llm = openheim::config::create_client(&agent_config, &http);
let mut messages = vec![Message::user("Fetch https://example.com and summarise it.")];
let work_dir = std::env::current_dir()?;
let cancel = CancellationToken::new();
let gate: Arc<dyn PermissionGate> = Arc::new(AllowAll);
let turn = TurnContext::new(
&cancel,
&gate,
&work_dir, // sandbox boundary for the file tools
&NoClientIo, // no editor to delegate file I/O to
);
let result = run_agent(
&*llm,
&executor,
&agent_config,
&mut messages,
None, // prompt_builder
&turn,
|_| {}, // or handle each StreamEvent as it happens
)
.await?;
println!("{}", result.final_response);
Ok(())
}
Tool design guidelines
Descriptions matter
The LLM decides when to call a tool based entirely on its description. Write descriptions that include:
- What it does — one clear sentence
- When to use it — scenarios where it applies
- What it returns — especially if the format is non-obvious
Argument schemas
Keep argument schemas flat and explicit. Avoid deeply nested objects. Mark required fields in the "required" array. Add a "description" to every property — the LLM reads these to construct correct calls.
Error as content
When an operation fails in a recoverable way (file not found, HTTP error, permission denied), return the error as a string rather than propagating it as Err. The agent loop feeds tool output back to the LLM, which can then decide to try something else. Reserve Err for unrecoverable failures that should stop the loop.
// Good: LLM can react to this
Ok(format!("Error reading {path}: file not found"))
// Also fine when the failure is unrecoverable or likely a bug
Err(Error::ToolExecutionError("database connection pool exhausted".into()))
Idempotency
Prefer idempotent tools. The agent may call the same tool multiple times with identical arguments across retries. Mutations (writes, POSTs, deletes) should document this in their description so the LLM is careful.
Using MCP instead
For tools backed by an external process or service, use an MCP server instead of implementing ToolHandler directly. MCP servers are loaded automatically from configuration and their tools are available in every session without any code changes.
See Configuration for how to configure MCP servers.