Tools with context
Attach metadata to tools and decide per call what the loop should do with them.
Sometimes you want different rules for different tools: read-only ones run on their own, destructive ones wait for approval, expensive ones get deferred. chat-rs lets each tool carry metadata, and lets you pair a tool collection with a strategy that reads that metadata to decide what happens on every call.
One thing to keep clear up front: metadata informs the strategy's decision; it is never passed into the tool's body. It only changes how the loop interacts with the tool, not what the tool does.
Describe the metadata
Add #[tool(key = value)] attributes to your tools and define a struct that
deserializes them. Anything not set falls back to the struct's defaults.
use serde::Deserialize;
use tools_rs::tool;
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
struct ApprovalMeta {
requires_approval: bool,
}
#[tool(requires_approval = true)]
/// Deletes files matching a glob pattern.
async fn delete_files(pattern: String) -> String {
format!("(pretend) deleted files matching: {pattern}")
}
#[tool]
/// Reads the contents of a file.
async fn read_file(path: String) -> String {
format!("(pretend) contents of {path}: hello world")
}Write a strategy
A strategy is a plain closure, Fn(&FunctionCall, &M) -> Action. It inspects the
call and its metadata and returns one of the loop-flow actions:
Action::Executeruns the tool now.Action::RequireApprovalpauses for a human (see Human in the loop).Action::Defer { at }pauses until a later time.Action::Reject { reason }declines the call and tells the model why.
use chat_rs::Action;
use tools_rs::FunctionCall;
fn approval_strategy(_call: &FunctionCall, meta: &ApprovalMeta) -> Action {
if meta.requires_approval {
Action::RequireApproval
} else {
Action::Execute
}
}Wire it up
Collect the tools as a typed collection with collect_tools::<M>(), pair it with
your strategy via ScopedCollection::new, and register it with
with_scoped_tools. You can add more than one scoped collection; each tool call
routes to the collection that owns its name.
use chat_rs::{ChatBuilder, ScopedCollection, claude::ClaudeBuilder};
use tools_rs::ToolCollection;
let raw_tools: ToolCollection<ApprovalMeta> = ToolCollection::collect_tools()?;
let scoped = ScopedCollection::new(raw_tools, approval_strategy);
let client = ClaudeBuilder::new()
.with_model("claude-sonnet-4-20250514")
.build();
let mut chat = ChatBuilder::new()
.with_model(client)
.with_scoped_tools(scoped)
.with_max_steps(8)
.build();Now, on every tool call, the loop looks up that tool's metadata and asks your strategy what to do. Because the strategy is an ordinary closure, it can also close over things outside the metadata, like feature flags, rate-limit buckets, or the current user's session, when deciding how a tool should run.