Skip to content

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.

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.

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 required fields. Omit optional ones from required.
  • Use enums ("enum": ["celsius", "fahrenheit"]) to constrain choices — the model reliably respects them.

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.

This is the most important habit. Recall from Reliability & Errors:

  • Something the model could recover fromOk(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.

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()?;

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).

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.

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.

  • A tool is a type with name, description, parameters_schema, and execute.
  • Descriptions and schemas are prompt engineering — be specific, use enums, mark required.
  • Parse arguments defensively; a typed serde struct scales best.
  • Return recoverable problems as is_error: true; reserve Err for real faults.
  • Tools can hold thread-safe state; test execute directly with #[tokio::test].