Skip to content

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.

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 = 13

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

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: a Deserialize type 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. Any Serialize type works (see Tool output).
  • type Error: your error type. It must implement std::error::Error.
  • description() and parameters(): 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. ctx is the ToolContext for 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).

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

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_weather becomes GetWeather), which you pass to .tool(...);
  • an argument struct (GetWeatherParameters) with a derived JSON Schema;
  • the tool trait impl, plus a static GET_WEATHER: GetWeather instance.
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.

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.

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, a reqwest::Error, your own enum) is wrapped by the default map_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 through error.message() and error.downcast_ref::<E>().
  • A ToolExecutionError you 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, and other. 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).

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>() returns Ok(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 the tool_result_outcomes example 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.

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.

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

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

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.

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.

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.

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.

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.