Skip to content

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.

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 })
}
}
  • name — the identifier the model uses to call the tool. Must be unique within the agent. Use snake_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. List required fields and describe each property.
  • execute — your logic. Receives the arguments as a serde_json::Value (already validated against nothing — you parse it), returns a ToolResult.
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 })
}

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.

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.

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.

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).
  • A tool = name + description + JSON-schema parameters + async execute.
  • The description and schema are prompt engineering — be precise.
  • Return failures as ToolResult { is_error: true } so the model can recover; reserve Err for unrecoverable faults.
  • Register with .tool(...) or a ToolRegistry; the agent handles selection and execution.
  • Built-in fs/shell/web tools ship behind feature flags with a security policy.