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 external systems. When the model asks for a tool, Rig parses the arguments, runs your code, sends the result back to the model, and continues the loop.
A complete example
Section titled “A complete example”The program below defines two tools, an Add written by hand against the Tool trait and a
Subtract generated from a plain function by the #[rig::rig_tool] macro, and attaches both to an
agent.
use rig::prelude::*;use rig::providers::openai::{self, OpenAI};use rig::tool::{ToolContext, ToolExecutionError};use serde::Deserialize;use serde_json::json;
#[derive(Deserialize)]struct AddArgs { x: i64, y: i64,}
// A tool implemented by hand against the `Tool` trait.struct Add;
impl Tool for Add { const NAME: &'static str = "add"; type Args = AddArgs; type Output = i64; type Error = ToolExecutionError;
fn description(&self) -> String { "Add x and y together".to_string() }
fn parameters(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "x": { "type": "integer", "description": "The first number" }, "y": { "type": "integer", "description": "The second number" } }, "required": ["x", "y"] }) }
async fn call(&self, _ctx: &mut ToolContext, args: AddArgs) -> Result<i64, ToolExecutionError> { Ok(args.x + args.y) }}
// A tool generated from a function. The macro creates a unit struct named// after the function in PascalCase (`subtract` -> `Subtract`).#[rig::rig_tool(description = "Subtract y from x")]fn subtract(x: i64, y: i64) -> Result<i64, ToolExecutionError> { Ok(x - y)}
#[tokio::main]async fn main() -> anyhow::Result<()> { let calculator = AgentBuilder::new(OpenAI::from_env()?.completion(openai::GPT_5_5)) .preamble("You are a calculator. Use the provided tools to answer arithmetic questions.") .tool(Add) .tool(Subtract) .build();
let answer = calculator .prompt("What is (5 - 2) + 10?") .max_turns(4) .await? .output(); println!("{answer}");
Ok(())}(5 - 2) + 10 = 13Every tool call costs a model turn: the model asks for the tool, then reads the result on the next
call. A prompt that chains tools needs a turn budget (.max_turns(n) on the request, or
.default_max_turns(n) on the builder); running out fails the run with PromptError::MaxTurns.
The Tool trait
Section titled “The Tool trait”Every typed tool implements rig::tool::Tool.
use rig::prelude::*; brings Tool and ToolContext into scope; the snippets on this page assume it.
const NAME: the name the model uses to call the tool. It must be unique within the agent.type Args: aDeserializetype the model’s JSON arguments are parsed into. Arguments that don’t parse never reach your code; the model gets an “invalid arguments” result instead.type Output: what a successful call returns. AnySerializetype works (see Tool output).type Error: your error type. It must implementstd::error::Error.description()andparameters(): the text and JSON Schema sent to the provider. The schema is how the model learns to call the tool, so describe every field.call(&self, ctx, args): the work itself.ctxis theToolContextfor this call; ignore it if you don’t need it.map_error(&self, error): optional. Turns your error into what the model and your hooks see (see When a tool fails).
Deriving the schema with schemars
Section titled “Deriving the schema with schemars”Writing the parameters JSON by hand is error-prone. Add schemars = "1" to Cargo.toml, derive
JsonSchema on the argument struct, and describe each field with a /// doc comment:
use schemars::JsonSchema;
#[derive(Deserialize, JsonSchema)]struct AddArgs { /// The first number to add. x: i64, /// The second number to add. y: i64,}
struct Add;
impl Tool for Add { const NAME: &'static str = "add"; type Args = AddArgs; type Output = i64; type Error = std::convert::Infallible;
fn description(&self) -> String { "Add x and y together".to_string() }
fn parameters(&self) -> serde_json::Value { schemars::schema_for!(AddArgs).to_value() }
async fn call(&self, _ctx: &mut ToolContext, args: AddArgs) -> Result<i64, Self::Error> { Ok(args.x + args.y) }}The #[rig::rig_tool] macro
Section titled “The #[rig::rig_tool] macro”For most tools you don’t need a hand-written impl. Put #[rig::rig_tool] on a function (sync or
async) that returns Result<T, E>, and the macro generates:
- a unit struct named after the function in PascalCase (
get_weatherbecomesGetWeather), which you pass to.tool(...); - an argument struct (
GetWeatherParameters) with a derived JSON Schema; - the tool trait impl, plus a
static GET_WEATHER: GetWeatherinstance.
use rig::tool::ToolExecutionError;
/// Look up the current temperature for a city.#[rig::rig_tool(params(city = "City name, e.g. `Paris`"))]async fn get_weather( city: String, /// Temperature unit: `celsius` (default) or `fahrenheit`. unit: Option<String>,) -> Result<String, ToolExecutionError> { let unit = unit.unwrap_or_else(|| "celsius".to_string()); if city.is_empty() { return Err(ToolExecutionError::invalid_args("`city` must not be empty")); } Ok(format!("It is 21 degrees {unit} in {city}."))}The macro takes these options, all optional:
name = "...": the tool name. Defaults to the function name.description = "...": the tool description. Defaults to the function’s doc comment.params(arg = "...", ...): argument descriptions. A///doc comment on the argument works too. Arguments with neither get a placeholder description, so write one.required(arg, ...): which arguments the model must send.
Without required(...), every argument that is not an Option is required and every Option
argument is optional. With an explicit required(...) list, arguments left out of it are optional and
fall back to Default::default() when the model omits them, so their types must implement Default.
Listing an Option argument in required(...) is a compile error.
A function whose first parameter is &mut rig::tool::ToolContext gets the tool context;
the context is never part of the model’s arguments. If you import ToolContext under a shorter path,
mark the parameter with #[rig(context)] so the macro can recognize it.
Tool output
Section titled “Tool output”type Output can be any Serialize type. Rig turns it into the tool result the model sees: a
String becomes a text block, and everything else (numbers, structs, serde_json::Value) becomes a
JSON block. To control the content yourself, return a
ToolOutput:
use rig::tool::ToolOutput;
let text = ToolOutput::text("Search complete: 3 results.");let json = ToolOutput::json(json!({ "count": 3 }));// Several blocks, including images, for multimodal tools.let blocks = ToolOutput::content(vec![ ToolResultContent::text("Here is the chart:"), ToolResultContent::json(json!({ "series": [1, 2, 3] })),])?;Tool output is prompt input for the next turn. Keep it compact: filter and summarize inside the tool instead of returning a raw API response.
When a tool fails
Section titled “When a tool fails”A tool that returns Err does not end the run. Rig turns the error into a failed tool result,
sends it to the model, and the loop continues: the model can retry with corrected arguments, try
another tool, or explain the failure to the user.
What the model reads depends on the error. Rig converts every tool error into a
ToolExecutionError, which
keeps two texts apart: the message for your logs and hooks, and the model output the model
reads.
- An arbitrary error type (an
io::Error, areqwest::Error, your own enum) is wrapped by the defaultmap_error. The model only sees generic feedback,the tool failed, so internal details such as paths, hosts or credentials don’t leak into the conversation. The original error stays available to your code througherror.message()anderror.downcast_ref::<E>(). - A
ToolExecutionErroryou build yourself shows its message to the model. Use the constructors, one per kind of failure:invalid_args,not_found,permission_denied,timeout,rate_limited,network,provider,cancelled, andother.refused(..)marks a deliberate refusal.
Write these messages for the model: "Division by zero; y must not be 0" lets it recover,
"error 500" doesn’t. Each recovery attempt costs a turn, so leave .max_turns(..) headroom.
To keep a typed error for your own code and still give the model a useful message, override
map_error:
use rig::tool::ToolExecutionError;
#[derive(Debug, thiserror::Error)]enum LookupError { #[error("no customer with id {0}")] NotFound(String), #[error("database unreachable at {0}")] Database(String),}
#[derive(Deserialize)]struct LookupArgs { id: String,}
struct LookupCustomer;
impl Tool for LookupCustomer { const NAME: &'static str = "lookup_customer"; type Args = LookupArgs; type Output = String; type Error = LookupError;
fn description(&self) -> String { "Look up a customer by id".to_string() }
fn parameters(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }) }
fn map_error(&self, error: LookupError) -> ToolExecutionError { match error { // Safe and useful for the model: say what went wrong. LookupError::NotFound(_) => ToolExecutionError::not_found(error.to_string()), // The message names an internal host: log it, tell the model less. LookupError::Database(_) => ToolExecutionError::network(error.to_string()) .with_model_feedback("the customer database is unavailable; try again later") .with_code("DB_DOWN") .with_source(error), } }
async fn call(&self, _ctx: &mut ToolContext, args: LookupArgs) -> Result<String, LookupError> { Err(LookupError::NotFound(args.id)) }}with_code, with_retryable and with_http_status attach structured facts your
hooks can act on; none of them is sent to the model.
Two other failures are handled differently. Arguments that don’t match Args become an
invalid_args result for the model, like any other tool error. A call to a tool that doesn’t exist
fails the run with PromptError::UnknownToolCall unless a hook recovers it (see
Hooks).
Tool context
Section titled “Tool context”Some values a tool needs should never come from the model: the signed-in user, a tenant id, a
request id. Pass them through the
ToolContext instead. Context values
are typed: derive ContextValue (plus Serialize and Deserialize) on a type, insert it before
the run, and read it inside the tool.
use rig::tool::ToolExecutionError;
/// The signed-in user. Set by the host, never by the model.#[derive(Serialize, Deserialize, rig::ContextValue)]struct UserId(String);
/// Host-only metadata the tool reports back about its call.#[derive(Serialize, Deserialize, rig::ContextValue)]#[context(key = "orders.rows_read")]struct RowsRead(usize);
#[derive(Deserialize)]struct NoArgs {}
struct ListOrders;
impl Tool for ListOrders { const NAME: &'static str = "list_orders"; type Args = NoArgs; type Output = Vec<String>; type Error = ToolExecutionError;
fn description(&self) -> String { "List the signed-in user's recent orders".to_string() }
fn parameters(&self) -> serde_json::Value { json!({ "type": "object", "properties": {} }) }
async fn call(&self, ctx: &mut ToolContext, _args: NoArgs) -> Result<Vec<String>, ToolExecutionError> { let user = ctx.require::<UserId>()?; let orders = vec![format!("order #1001 for {}", user.0)]; ctx.insert_result(RowsRead(orders.len()))?; Ok(orders) }}Set the context per run with .tool_context(..). Rig hands each tool call its own copy:
use rig::tool::ToolContext;
let mut context = ToolContext::new();context.insert(UserId("u_42".to_string()))?;
let reply = agent .prompt("What did I order recently?") .tool_context(context) .max_turns(3) .await?;ctx.get::<T>()returnsOk(None)when the value is absent;ctx.require::<T>()returns an error, which?turns into a failed tool result.ctx.insert_result(..)attaches host-only metadata to this call’s result. The model never sees it. Hooks read it from the outcome event, which is how thetool_result_outcomesexample decides whether a failure should stop the run.- Each slot is keyed by
ContextValue::KEY: the type name by default, or#[context(key = "...")]. Values are stored as serialized data, so put shared handles (Arc<Mutex<_>>, connection pools, channels) on the tool struct instead.
Runtime-defined tools
Section titled “Runtime-defined tools”When a tool’s name, description or schema is only known at run time (loaded from config, generated
from an API spec), build a
DynamicTool from a name, a
description, a JSON Schema and an async closure over the raw JSON arguments:
use rig::message::ToolName;use rig::tool::{DynamicTool, ToolExecutionError, ToolOutput};
let echo = DynamicTool::new( ToolName::new("echo")?, "Repeat the input text back", json!({ "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }), |args| { Box::pin(async move { let text = args["text"] .as_str() .ok_or_else(|| ToolExecutionError::invalid_args("`text` must be a string"))?; Ok(ToolOutput::text(text)) }) },);
let agent = AgentBuilder::new(OpenAI::from_env()?.completion(openai::GPT_5_5)) .dynamic_tool(echo) .build();ToolName::new rejects an empty name. Add several at once with .dynamic_tools(vec![..]), and use
DynamicTool::new_with_context when the closure needs the tool context.
Portable tools and built-in tools
Section titled “Portable tools and built-in tools”A tool that never needs a context can implement
PortableTool instead. It has the same
items as Tool, but call(&self, args) takes no context. Every PortableTool is also a Tool, so
it attaches with .tool(..) as usual. The #[rig::rig_tool] macro generates a PortableTool when
the function has no context parameter.
Rig ships one built-in tool, rig::tool::builtin::ThinkTool. It gives the model a think tool that
returns its input unchanged: a scratchpad for reasoning between tool calls.
use rig::tool::builtin::ThinkTool;
let agent = AgentBuilder::new(OpenAI::from_env()?.completion(openai::GPT_5_5)) .tool(ThinkTool) .build();Designing good tools
Section titled “Designing good tools”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_ordersbeatsso. - Write descriptions for the model. Say what the tool does, when to use it, and when not to.
- Describe every parameter and keep parameters few and simple. A model fills in three well-described string or number fields far more reliably than one nested object.
- Offer few tools per request. Selection gets worse as the tool list grows (roughly past ten to twenty). Split large inventories across specialized agents, or use retrieved tools so each request only sees the relevant few.
- Return compact results. For large or sensitive data, return an id your code resolves later instead of the payload itself.
Retrieved tools
Section titled “Retrieved tools”With many tools, sending every definition on every request wastes tokens and confuses the model. Retrieved tools are embedded into a vector store. Before every model call the agent searches it with the prompt’s text (or, when the turn only carries tool results, the latest user message’s text) and offers the model only the closest matches.
The example below builds the index with EmbeddingsBuilder and InMemoryVectorStore; see
Embeddings and Vector Stores & RAG if those are new.
A retrievable tool also implements
ToolEmbedding, which supplies the
text to embed and how to rebuild the tool:
use rig::tool::ToolEmbedding;
#[derive(Debug, thiserror::Error)]#[error("init error")]struct InitError;
#[derive(Deserialize)]struct AddArgs { x: i64, y: i64 }
struct Add;
impl Tool for Add { const NAME: &'static str = "add"; type Args = AddArgs; type Output = i64; type Error = std::convert::Infallible;
fn description(&self) -> String { "Add x and y together".to_string() }
fn parameters(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "x": { "type": "integer" }, "y": { "type": "integer" } }, "required": ["x", "y"] }) }
async fn call(&self, _ctx: &mut ToolContext, args: AddArgs) -> Result<i64, Self::Error> { Ok(args.x + args.y) }}
impl ToolEmbedding for Add { type InitError = InitError; type Context = (); type State = ();
fn init(_state: (), _context: ()) -> Result<Self, InitError> { Ok(Add) }
fn embedding_docs(&self) -> Vec<String> { vec!["Add x and y together".into(), "sum, plus, addition".into()] }
fn context(&self) {}}Collect the tools in a ToolSet, embed
their schemas, and attach the index with .retrieved_tools(n, index, toolset):
use rig::tool::ToolSet;
let client = OpenAI::from_env()?;let embedding_model = client.embedding(openai::TEXT_EMBEDDING_3_SMALL, None).erase();
let mut toolset = ToolSet::default();toolset.add_retrieved_tool(Add)?;
let embeddings = EmbeddingsBuilder::new(embedding_model.clone()) .documents(toolset.schemas()?)? .build() .await?;let index = InMemoryVectorStore::from_documents_with_id_f(embeddings, |tool| tool.name.clone()) .index(embedding_model);
let agent = AgentBuilder::new(client.completion(openai::GPT_5_5)) .preamble("You are a calculator.") .retrieved_tools(1, index, toolset) .build();Static tools added with .tool(..) are always offered; retrieved tools are offered only when they
rank in the top n. The full program is the
rag_dynamic_tools
example.
Sharing tools between agents
Section titled “Sharing tools between agents”Each agent’s tools live in a tool server. Build one yourself to share a single set of tools between several agents, or to add and remove tools while agents are running:
use rig::tool::server::ToolServer;
let tools = ToolServer::new().tool(ThinkTool).run();
let model = OpenAI::from_env()?.completion(openai::GPT_5_5);let researcher = AgentBuilder::new(model.clone()) .tool_server_handle(tools.clone()) .build();let writer = AgentBuilder::new(model) .tool_server_handle(tools.clone()) .build();
// Changes to the server reach both agents.tools.remove_tool("think");The handle is cheap to clone; the server keeps running while any clone is alive.
Forcing a tool call
Section titled “Forcing a tool call”AgentBuilder::tool_choice(ToolChoice::Required) forces a tool call on every turn, so the model
never gets a turn to write its final answer and the run ends in PromptError::MaxTurns. Use it only
for single-turn work. To force a tool on the first turn and then let the model answer, set the
choice from a hook that checks ctx.turn(); see Request patches
and the
force_tool_first_turn
example.
Running tools yourself
Section titled “Running tools yourself”You can skip the agent loop and handle tool calls in your own code: send the tool definitions with a
raw completion request, read the tool calls out of the response, and run
them with a ToolSet:
use rig::completion::CompletionRequest;use rig::message::{AssistantContent, Message};use rig::tool::ToolSet;
let model = OpenAI::from_env()?.completion(openai::GPT_5_5);let mut tools = ToolSet::default();tools.add_tool(ThinkTool);
let request = CompletionRequest::new(Message::user("Think about 2 + 2, then answer.")) .tools(tools.tool_definitions());let response = model.call(request).await?;
for content in &response.choice { if let AssistantContent::ToolCall(call) = content { let args = serde_json::to_string(&call.function.arguments)?; let result = tools .execute(&call.function.name, args, &mut ToolContext::new()) .await; println!("{} -> {}", call.function.name, result.output().render()); }}ToolSet::execute never fails: it returns a
ToolResult that is a success, an
error, a refusal, or a skip, with result.output() holding what the model should read. Send it back
as a tool-result message and loop until the model stops calling tools. The
manual_tool_calls
example shows the full loop.
MCP tools
Section titled “MCP tools”Tools don’t have to live in your crate. The Model Context Protocol
lets an agent use tools served by other processes (filesystem access, browsers, databases, SaaS
integrations) next to your own. Rig’s MCP support is in rig::tool::rmcp behind the rmcp feature;
see Model Context Protocol for setup and a worked
example.
