Skip to content
Get Started

Tools

Tools let an agent do more than generate text: they expose your Rust functions to the model so it can call them to fetch data, run computations, or reach out to external systems. When the model decides a tool is needed, Rig parses the call, runs your code, feeds the result back to the model, and continues the loop — turning “just an LLM” into a system that can take actions.

The snippet below defines two tools — an Adder written by hand against the Tool trait and a Subtract generated from a plain function with the tool_macro — and attaches both to an agent.

use rig::{
client::{CompletionClient, ProviderClient},
completion::{Prompt, ToolDefinition},
providers::openai,
tool::Tool,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Deserialize)]
struct OperationArgs {
x: i32,
y: i32,
}
#[derive(Debug)]
struct MathError;
impl std::fmt::Display for MathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "math error")
}
}
impl std::error::Error for MathError {}
// A tool implemented by hand against the `Tool` trait.
#[derive(Deserialize, Serialize)]
struct Adder;
impl Tool for Adder {
const NAME: &'static str = "add";
type Error = MathError;
type Args = OperationArgs;
type Output = i32;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "add".to_string(),
description: "Add x and y together".to_string(),
parameters: json!({
"type": "object",
"properties": {
"x": { "type": "number", "description": "The first number to add" },
"y": { "type": "number", "description": "The second number to add" }
},
"required": ["x", "y"]
}),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
Ok(args.x + args.y)
}
}
// A tool generated from a plain function. The macro creates a tool type
// named after the function in PascalCase (`subtract` -> `Subtract`).
#[rig::tool_macro(description = "Subtract y from x", required(x, y))]
async fn subtract(x: i32, y: i32) -> Result<i32, rig::tool::ToolError> {
Ok(x - y)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let openai = openai::Client::from_env()?;
let calculator = openai
.agent("gpt-5.5")
.preamble("You are a calculator. Use the provided tools to answer arithmetic questions.")
.max_tokens(1024)
.tool(Adder)
.tool(Subtract)
.build();
let answer = calculator.prompt("What is 5 - 2?").await?;
println!("{answer}");
Ok(())
}
3

The rest of this page unpacks each piece.

Every tool implements rig::tool::Tool. The trait ties together everything the model and the runtime need:

  • const NAME — a unique identifier the model uses to reference the tool.
  • type Args — a Deserialize type the model’s JSON arguments are parsed into.
  • type Output — what your tool returns on success.
  • type Error — your error type, returned when a call fails.
  • definition(&self, prompt) — returns a ToolDefinition (name, description, and a JSON-schema parameters object) that is sent to the provider.
  • call(&self, args) — the execution logic.

The parameters schema is what tells the model how to call your tool, so keep the descriptions clear.

Writing the parameters JSON by hand is error-prone. The schemars crate can generate it from a Rust struct instead: derive schemars::JsonSchema and describe each field with a /// doc comment.

#[derive(Deserialize, Serialize, schemars::JsonSchema)]
struct OperationArgs {
/// The first number to add.
x: i32,
/// The second number to add.
y: i32,
}

Then generate the schema inside definition instead of writing it out:

async fn definition(&self, _prompt: String) -> ToolDefinition {
let parameters = schemars::schema_for!(OperationArgs);
ToolDefinition {
name: "add".to_string(),
description: "Add x and y together".to_string(),
parameters: serde_json::to_value(parameters).unwrap(),
}
}

For simple tools you rarely need a full trait impl. The tool_macro turns a plain function into a tool type (named after the function in PascalCase, so subtract becomes Subtract):

#[rig::tool_macro(description = "Perform basic arithmetic operations", required(x, y, operation))]
async fn calculator(x: i32, y: i32, operation: String) -> Result<i32, rig::tool::ToolError> {
match operation.as_str() {
"add" => Ok(x + y),
"subtract" => Ok(x - y),
"multiply" => Ok(x * y),
"divide" if y == 0 => Err(rig::tool::ToolError::ToolCallError("Division by zero".into())),
"divide" => Ok(x / y),
_ => Err(rig::tool::ToolError::ToolCallError(
format!("Unknown operation: {operation}").into(),
)),
}
}

The macro derives the argument struct, the JSON schema, and the Tool impl for you. The generated type is passed to .tool(...) just like a hand-written one.

Your call returning Err does not abort the prompt. Rig converts the error to its string representation and sends it back to the model as the tool result, and the agent loop continues — the model reads the error and can retry with corrected arguments, try a different tool, or explain the failure to the user. Two consequences for how you write tools:

  • Make error messages instructive. The model is the audience. "Division by zero" lets it recover; "error 500" doesn’t. Say what was wrong and, when you can, what a valid call looks like.
  • Budget turns for recovery. A retry costs a turn, so a prompt that may need self-correction wants .max_turns(...) headroom.

A different failure — the model calling a tool that doesn’t exist — fails the prompt immediately by default; a prompt hook can opt into retry/repair/skip recovery, as covered on the Agents page.

The model chooses tools by reading their names, descriptions, and parameter schemas — that text is the interface, and most tool-use failures trace back to it rather than to the model.

  • Name tools descriptively, in snake_case, without abbreviations: search_orders beats so.
  • Write descriptions for the model, not for docs. Say what the tool does, when to use it, and when not to. Recommendations and short examples in the description measurably help.
  • Describe every parameter and keep parameters few and primitive. A model fills in three well-described string/number fields far more reliably than one nested object.
  • Offer few tools per request. Selection quality degrades as the tool list grows (roughly past the ten-to-twenty range). Split large tool inventories across specialized agents, or use dynamic tool retrieval so each request only sees the relevant few.
  • Return compact results. Tool output is prompt input on the next turn: filter and summarize in the tool rather than dumping raw API responses into the context. For large or sensitive data, have the tool return an ID your code resolves later instead of the payload itself.

Tools reach the model in two ways.

Static tools are always available. Add each with .tool(...):

let agent = client
.agent("gpt-5.5")
.preamble("You are a calculator.")
.tool(Adder)
.tool(Subtract)
.build();

Dynamic tools are retrieved from a vector store at prompt time based on the user’s query — useful when you have many tools and don’t want to send every definition on every request. See the next section.

To make a tool retrievable by semantic similarity, also implement ToolEmbedding. It provides the text that gets embedded (embedding_docs) plus the state/context needed to reconstruct the tool:

use rig::tool::ToolEmbedding;
#[derive(Debug)]
struct InitError;
impl std::fmt::Display for InitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "init error")
}
}
impl std::error::Error for InitError {}
impl ToolEmbedding for Adder {
type InitError = InitError;
type Context = ();
type State = ();
fn init(_state: Self::State, _context: Self::Context) -> Result<Self, Self::InitError> {
Ok(Adder)
}
fn embedding_docs(&self) -> Vec<String> {
vec!["Add x and y together".into()]
}
fn context(&self) -> Self::Context {}
}

Embed the tools into a VectorStoreIndex, then attach them with .dynamic_tools(n, index, toolset). At each turn the agent fetches the n most relevant tools and offers only those to the model:

let agent = client
.agent("gpt-5.5")
.preamble("You are a calculator.")
.dynamic_tools(2, tool_index, toolset)
.build();

For a full walkthrough of building the index and toolset, see Vector Stores & RAG and the rag_dynamic_tools example in the Rig repository.

Sharing a mutable tool set across async tasks usually means reaching for Arc<Mutex<T>>, which can cause lock contention or deadlocks. Rig’s tool server (available since v0.22.0) avoids this: it runs the tools in a dedicated Tokio task and communicates with them over message passing. As long as one copy of the handle is alive, the server keeps running.

use rig::tool::server::{ToolServer, ToolServerHandle};
let tool_server: ToolServerHandle = ToolServer::new()
.tool(Adder)
.run();

Like agents, tool servers accept static tools, dynamic tools, and MCP tools (via rmcp). Attach the handle with AgentBuilder::tool_server_handle(...); handing several agents clones of one handle lets them share a single set of tools.

Tools don’t have to live in your crate. The Model Context Protocol lets an agent consume tools served by external processes — filesystem access, browsers, databases, third-party SaaS integrations — through one standard interface. Rig connects to MCP servers via the rmcp crate and exposes their tools with AgentBuilder::rmcp_tool(...) / rmcp_tools(...), side by side with your native tools. See Model Context Protocol for setup and a worked example.

Tools are collected in a ToolSet, which registers tools, looks them up by name, routes calls to the right implementation, and (for ToolEmbedding tools) produces the embeddings used for dynamic retrieval. When you attach tools to an agent, Rig handles the rest of the integration: converting each ToolDefinition into the provider’s format, parsing model output into tool calls, executing them, and returning results to the model.