Writing a Custom Tool
Tools are where an agent gets its powers. This guide takes you from a trivial tool to a robust, stateful, well-tested one. If you haven’t yet, skim the Tools concept first.
The minimal tool
Section titled “The minimal tool”use kova_sdk::prelude::*;use async_trait::async_trait;use serde_json::{json, Value};
struct Echo;
#[async_trait]impl Tool for Echo { fn name(&self) -> &str { "echo" } fn description(&self) -> &str { "Echo the input text back" } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }) } async fn execute(&self, args: Value) -> Result<ToolResult, KovaError> { let text = args["text"].as_str().unwrap_or_default(); Ok(ToolResult { content: text.to_string(), is_error: false }) }}Register it with .tool(Arc::new(Echo)) and the model can call it.
Writing a good description and schema
Section titled “Writing a good description and schema”The model decides whether and how to call your tool based entirely on the
description and parameters_schema. Treat them as prompt engineering.
Weak:
fn description(&self) -> &str { "database" }Strong:
fn description(&self) -> &str { "Look up a customer by their email address. Returns the customer's name, \ plan tier, and signup date. Use this before answering any account question."}
fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { "email": { "type": "string", "format": "email", "description": "The customer's email address, lowercased" } }, "required": ["email"] })}Guidelines:
- Say what it does and when to use it.
- Describe every property, not just its type.
- Mark
requiredfields. Omit optional ones fromrequired. - Use enums (
"enum": ["celsius", "fahrenheit"]) to constrain choices — the model reliably respects them.
Parsing arguments safely
Section titled “Parsing arguments safely”execute receives a serde_json::Value. The model usually respects your
schema, but never assume it. Two robust patterns:
Manual extraction with graceful fallback:
async fn execute(&self, args: Value) -> Result<ToolResult, KovaError> { let Some(email) = args["email"].as_str() else { // in-band error: the model can read this and retry return Ok(ToolResult { content: "missing required 'email' string argument".into(), is_error: true, }); }; // …}Deserialize into a typed struct with serde:
use serde::Deserialize;
#[derive(Deserialize)]struct Args { email: String, #[serde(default)] include_history: bool,}
async fn execute(&self, args: Value) -> Result<ToolResult, KovaError> { let args: Args = match serde_json::from_value(args) { Ok(a) => a, Err(e) => return Ok(ToolResult { content: format!("invalid arguments: {e}"), is_error: true, }), }; // use args.email, args.include_history …}The typed approach is cleaner for anything beyond one or two fields.
Error handling: in-band vs. fatal
Section titled “Error handling: in-band vs. fatal”This is the most important habit. Recall from Reliability & Errors:
- Something the model could recover from →
Ok(ToolResult { is_error: true, … }). The message goes back to the model. - A genuine fault you can’t recover from →
Err(KovaError::…). Aborts the turn.
async fn execute(&self, args: Value) -> Result<ToolResult, KovaError> { let email = args["email"].as_str().unwrap_or_default();
match self.db.lookup(email).await { Ok(Some(customer)) => Ok(ToolResult { content: serde_json::to_string(&customer)?, // ? maps serde error to KovaError is_error: false, }), Ok(None) => Ok(ToolResult { // recoverable: model can try again content: format!("no customer found for {email}"), is_error: true, }), Err(db_err) => Ok(ToolResult { // even DB errors are often recoverable content: format!("lookup failed: {db_err}"), is_error: true, }), }}Returning structured content (JSON) is fine and often better — the model parses it happily and you avoid ambiguity.
Stateful tools
Section titled “Stateful tools”Tools are just types, so they can hold state — a database pool, an HTTP client,
a counter. Because tools run concurrently, shared mutable state must be
thread-safe (Arc, Mutex, atomics):
use std::sync::Arc;use sqlx::PgPool;
struct CustomerLookup { pool: PgPool, // cheap to clone, internally reference-counted}
impl CustomerLookup { fn new(pool: PgPool) -> Self { Self { pool } }}
#[async_trait]impl Tool for CustomerLookup { fn name(&self) -> &str { "customer_lookup" } fn description(&self) -> &str { "Look up a customer by email" } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"] }) } async fn execute(&self, args: Value) -> Result<ToolResult, KovaError> { let email = args["email"].as_str().unwrap_or_default(); let row = sqlx::query!("SELECT name FROM customers WHERE email = $1", email) .fetch_optional(&self.pool).await .map_err(|e| KovaError::ToolExecution { tool_name: "customer_lookup".into(), message: e.to_string(), })?; match row { Some(r) => Ok(ToolResult { content: r.name, is_error: false }), None => Ok(ToolResult { content: "not found".into(), is_error: true }), } }}
let agent = AgentBuilder::new() .provider(provider) .tool(Arc::new(CustomerLookup::new(pool))) .build()?;Testing a tool in isolation
Section titled “Testing a tool in isolation”Because execute is a plain async method, you can unit-test it without a model:
#[tokio::test]async fn echo_returns_input() { let tool = Echo; let result = tool.execute(serde_json::json!({ "text": "hi" })).await.unwrap(); assert_eq!(result.content, "hi"); assert!(!result.is_error);}
#[tokio::test]async fn missing_arg_is_in_band_error() { let tool = Echo; let result = tool.execute(serde_json::json!({})).await.unwrap(); assert_eq!(result.content, ""); // Echo defaults to empty}To test the whole loop end-to-end without spending tokens, use a mock provider
that returns a canned tool-call response (Kova’s own test suite uses a
MockLlmProvider for exactly this).
Sensitive tools: require approval
Section titled “Sensitive tools: require approval”For tools that write, delete, spend money, or run shell commands, gate them
behind a ToolApprovalHandler so a human (or a policy) approves each call. See
Human-in-the-Loop Approvals.
Don’t reinvent common tools
Section titled “Don’t reinvent common tools”Filesystem, shell, and web-fetch tools already ship with the SDK behind feature flags, complete with a security policy. Reach for those before writing your own. See Built-in Tools & ToolPolicy.
Key takeaways
Section titled “Key takeaways”- A tool is a type with
name,description,parameters_schema, andexecute. - Descriptions and schemas are prompt engineering — be specific, use enums,
mark
required. - Parse arguments defensively; a typed
serdestruct scales best. - Return recoverable problems as
is_error: true; reserveErrfor real faults. - Tools can hold thread-safe state; test
executedirectly with#[tokio::test].