Tools & the Registry
Tools are what turn a chatbot into an agent. A tool is a function the model can decide to call: fetch a URL, run a query, look up an order, send an email. You describe the tool; the model chooses when and with what arguments to use it; Kova runs it and feeds the result back.
The Tool trait
Section titled “The Tool trait”A tool is any type implementing four methods:
#[async_trait]pub trait Tool: Send + Sync { fn name(&self) -> &str; // unique identifier the model calls fn description(&self) -> &str; // what it does — the model reads this fn parameters_schema(&self) -> Value; // JSON Schema for the arguments async fn execute(&self, args: Value) -> Result<ToolResult, KovaError>;}Here’s a complete one:
use kova_sdk::prelude::*;use async_trait::async_trait;use serde_json::{json, Value};
struct GetWeather;
#[async_trait]impl Tool for GetWeather { fn name(&self) -> &str { "get_weather" }
fn description(&self) -> &str { "Get the current weather for a city" }
fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { "city": { "type": "string", "description": "City name, e.g. \"Tokyo\"" } }, "required": ["city"] }) }
async fn execute(&self, args: Value) -> Result<ToolResult, KovaError> { let city = args["city"].as_str().unwrap_or("unknown"); Ok(ToolResult { content: format!("72°F, sunny in {city}"), is_error: false }) }}The four methods, and why each matters
Section titled “The four methods, and why each matters”name— the identifier the model uses to call the tool. Must be unique within the agent. Usesnake_case.description— the model’s only explanation of what the tool does and when to use it. This is prompt engineering: a vague description means the model calls the tool wrongly or not at all. Be specific.parameters_schema— a JSON Schema object describing the arguments. The model uses it to produce well-formed arguments. Listrequiredfields and describe each property.execute— your logic. Receives the arguments as aserde_json::Value(already validated against nothing — you parse it), returns aToolResult.
ToolResult: success and failure
Section titled “ToolResult: success and failure”pub struct ToolResult { pub content: String, // what the model sees pub is_error: bool, // whether this was a failure}The golden rule: report tool failures in-band, not as Err. A bad city
lookup should return ToolResult { content: "No such city", is_error: true },
not Err(KovaError::…). Why? An in-band error goes back to the model, which
can read it and recover (“that city wasn’t found, let me try a different
spelling”). An Err aborts the whole turn.
async fn execute(&self, args: Value) -> Result<ToolResult, KovaError> { let Some(city) = args["city"].as_str() else { // recoverable — tell the model what went wrong return Ok(ToolResult { content: "missing 'city' argument".into(), is_error: true }); }; // … reserve Err(KovaError) for genuine transport faults you can't recover from. Ok(ToolResult { content: lookup(city).await?, is_error: false })}Registering tools
Section titled “Registering tools”Register tools on the builder. .tool(...) is repeatable:
let agent = AgentBuilder::new() .provider(provider) .tool(Arc::new(GetWeather)) .tool(Arc::new(GetForecast)) .build()?;Under the hood these live in a ToolRegistry — a thread-safe map
(Arc<RwLock<HashMap<String, Arc<dyn Tool>>>>). You can build one directly and
pass it in, which composes with any .tool(...) calls:
use kova_sdk::tool::registry::ToolRegistry;
let registry = ToolRegistry::new();registry.register(Arc::new(GetWeather)).await;
let tool = registry.get("get_weather").await; // Option<Arc<dyn Tool>>let names = registry.list().await; // Vec<String>let defs = registry.tool_definitions().await; // Vec<ToolDefinition> sent to the LLM
let agent = AgentBuilder::new() .provider(provider) .tool_registry(registry) // composes with .tool(...) .build()?;Cloning a ToolRegistry shares the same inner map, so the agent can hand clones
to concurrent tasks cheaply. tool_definitions() (the JSON the model sees) is
cached and invalidated on registration.
How the model picks a tool
Section titled “How the model picks a tool”You never call execute yourself. On each provider call, Kova sends the model
your tools’ ToolDefinitions (name + description + schema). The model responds
with a StopReason::ToolUse and a ContentBlock::ToolUse { id, name, input }.
Kova looks the tool up by name, calls execute(input), wraps the output in a
ContentBlock::ToolResult, and loops. See
The Agentic Loop.
If the model names a tool that isn’t registered, Kova returns
KovaError::ToolNotFound.
Built-in tools
Section titled “Built-in tools”You don’t have to write everything from scratch. With the tools /
web-tools features, Kova ships filesystem, shell, and web-fetch tools guarded
by an injectable ToolPolicy:
kova-sdk = { version = "0.9", features = ["web-tools"] }use kova_sdk::tools::{ToolPolicy, register_all_tools_with_policy};
let policy = Arc::new(ToolPolicy { workspace_root: Some("/srv/project".into()), ..ToolPolicy::default()});let mut builder = AgentBuilder::new().provider(provider);for tool in register_all_tools_with_policy(policy) { builder = builder.tool(tool);}let agent = builder.build()?;See Built-in Tools & ToolPolicy.
Gating and observing tool calls
Section titled “Gating and observing tool calls”For anything sensitive (writes, shell, payments) you can require approval before a tool runs, or observe every start/finish:
ToolApprovalHandler— consulted before each execution; can approve, deny, or deny-with-a-reason the model can read. See Human-in-the-Loop Approvals.ToolLifecycleHook— observes tool start and end (for logging, metrics, progress UIs).
Key takeaways
Section titled “Key takeaways”- A tool =
name+description+ JSON-schemaparameters+ asyncexecute. - The
descriptionand schema are prompt engineering — be precise. - Return failures as
ToolResult { is_error: true }so the model can recover; reserveErrfor unrecoverable faults. - Register with
.tool(...)or aToolRegistry; the agent handles selection and execution. - Built-in fs/shell/web tools ship behind feature flags with a security policy.