Define tools
A tool is a function that a model, or your own code, calls by name with JSON arguments. In this guide you turn a C# method or a Rust function into a tool, read the JSON schema a model sees, register tools in a ToolRegistry, and call one from a graph node. You also handle the ways a call can fail, including cancellation.
Strata generates each tool’s schema and dispatch code at compile time: a source generator in C#, macros in Rust. Neither uses reflection to find or call a tool.
This guide calls tools from your own code. To let a model choose the tools, see Build an agent.
Before you start
Section titled “Before you start”Add Strata to your project as Installation shows.
- C#: the
[StrataTool]source generator ships inside theDatalium.Stratapackage, andDatalium.Strata.Hostingbrings it too. Reference one of them from the project that declares the tools. A package’s analyzers don’t pass through aProjectReference. - Rust: add
datalium-strataandserdewith thederivefeature. The examples below also useserde_jsonandtokio.
You need to know what a node is and that an async node gets a NodeContext. Graphs, nodes and edges explains both.
Write a tool
Section titled “Write a tool”Put [StrataTool] on a method of a partial class. The generator adds a second part of that class that implements IStrataToolProvider: a manifest and a switch that calls your methods.
using Datalium.Strata.Tools;
public partial class InventoryTools{ private static readonly Dictionary<string, int> Stock = new() { ["AB-12"] = 7, ["CD-34"] = 0 };
[StrataTool(Name = "inventory.lookup", Description = "Units in stock for a SKU.")] public static int Lookup(string sku, string? warehouse = null) => Stock.GetValueOrDefault(sku);
[StrataTool(Name = "inventory.reserve", Description = "Reserves units of a SKU for the calling tenant.")] public static async Task<string> ReserveAsync( string sku, int count, ToolCallContext context, CancellationToken cancellationToken) { // Stands in for a call to the warehouse system. await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken); if (count > Stock.GetValueOrDefault(sku)) { throw new InvalidOperationException($"only {Stock.GetValueOrDefault(sku)} x {sku} in stock"); }
var tenant = context.Metadata.GetValueOrDefault("tenant", "(no tenant)"); return $"reserved {count} x {sku} for {tenant}"; }}NameandDescriptionare both required.- Each parameter becomes an argument of the tool, under the parameter’s name. A parameter with a default value, or of a nullable type, is optional.
- Argument types are limited to
string,booland the built-in numeric types, optionally nullable. Any other type is a compile error. - A
ToolCallContextparameter and aCancellationTokenparameter receive the call’s context and token. They can go in any position, and they aren’t arguments. - The method can be static or an instance method, so a provider can take its dependencies through its constructor.
- It can return a value,
Task<T>,ValueTask<T>,void,TaskorValueTask. The result comes back as JSON:{"result": ...}, or{}when there is no value.
The generator reports STRATA001 when the class isn’t partial, STRATA002 for an argument type it can’t map, and STRATA003 when two methods in one class use the same tool name.
A Rust tool is a free async fn that takes one arguments struct. Derive Deserialize and StrataSchema on the struct, and put #[strata_tool] on the function.
use std::time::Duration;
use datalium_strata::prelude::*;use serde::Deserialize;
fn stock(sku: &str) -> u32 { match sku { "AB-12" => 7, _ => 0, }}
#[derive(Deserialize, StrataSchema)]struct LookupArgs { sku: String, warehouse: Option<String>,}
#[strata_tool(name = "inventory.lookup", description = "Units in stock for a SKU.")]async fn inventory_lookup(args: LookupArgs) -> Result<String, StrataError> { Ok(stock(&args.sku).to_string())}
#[derive(Deserialize, StrataSchema)]struct ReserveArgs { sku: String, count: u32,}
#[strata_tool( name = "inventory.reserve", description = "Reserves units of a SKU for the calling tenant.")]async fn inventory_reserve(args: ReserveArgs, ctx: ToolCallContext) -> Result<String, String> { // Stands in for a call to the warehouse system. tokio::select! { _ = tokio::time::sleep(Duration::from_millis(50)) => {} _ = ctx.cancellation().cancelled() => return Err("cancelled".to_string()), } let in_stock = stock(&args.sku); if args.count > in_stock { eprintln!("inventory.reserve failed: only {in_stock} x {} in stock", args.sku); return Err(format!("only {in_stock} x {} in stock", args.sku)); } let tenant = ctx.metadata().get("tenant").map_or("(no tenant)", String::as_str); Ok(format!("reserved {} x {} for {tenant}", args.count, args.sku))}nameanddescriptionare both required.- The function returns
Result<String, E>, with any error typeE. TheStringis the tool’s result. - An optional second parameter of type
ToolCallContextreceives the call’s context. It isn’t an argument. - The macro generates a handle named after the function in PascalCase, plus
Tool:InventoryLookupTool,InventoryReserveTool. You register the handle. - Each field of the struct is an argument.
Option<T>fields and#[serde(default)]fields are optional.#[serde(rename)]and#[serde(rename_all)]name them as serde does. - Field types can be strings,
bool, numbers,Vecand other sequences, maps with string keys, fieldless enums, and structs that deriveStrataSchemathemselves. - A tool needs an arguments struct. For a tool with no arguments, use an empty one:
struct NoArgs {}.
Neither engine has descriptions for single arguments. XML doc comments and Rust doc comments don’t reach the schema, so say what the model needs to know about the arguments in the tool’s description.
Read the manifest
Section titled “Read the manifest”The manifest is the list of tools a model sees: each tool’s name, description, and a JSON schema of its arguments. The context and the token aren’t in it.
string manifest = new InventoryTools().GetToolsManifest();let entry: serde_json::Value = InventoryLookupTool.schema();For inventory.lookup, the entry has the same content in both engines. Rust may print the keys in another order:
{ "name": "inventory.lookup", "description": "Units in stock for a SKU.", "parameters": { "type": "object", "properties": { "sku": { "type": "string" }, "warehouse": { "type": "string" } }, "required": ["sku"] }}The manifest is provider-neutral. Each model provider wants its own tool format, and the AI packages map it for you. Property names follow each language: a C# parameter orderId stays orderId, and a Rust field order_id stays order_id unless you rename it.
Register tools in a registry
Section titled “Register tools in a registry”A ToolRegistry holds tools from several places and dispatches to them by name. Use it rather than calling a provider directly: every call through a registry gets a strata.tool span, the strata.tool.* metrics, and, when the call comes from a node, ToolCallStarted and ToolCallCompleted run events.
// BillingTools declares billing.refund, like InventoryTools above.var tools = new ToolRegistry() .Register(new InventoryTools()) .Register(new BillingTools());
// One agent's view: only these two tools.var support = tools.Subset("inventory.lookup", "inventory.reserve");tools.GetToolsManifest() returns the merged manifest, and tools.Tools lists each tool as a parsed ToolDescriptor.
// BillingRefundTool is the handle of billing_refund, a tool like the ones above.let mut tools = ToolRegistry::new();tools .register(Box::new(InventoryLookupTool)) .register(Box::new(InventoryReserveTool)) .register(Box::new(BillingRefundTool));
// One agent's view: only these two tools.let support = Arc::new( tools .subset(["inventory.lookup", "inventory.reserve"]) .expect("both tools are registered"),);tools.manifest() returns the merged manifest as a serde_json::Value.
The registry refuses a duplicate name when you register it. In C#, Register throws DuplicateToolException and adds nothing from that provider. In Rust, register panics, and try_register returns Err(StrataError::DuplicateTool(name)) instead.
A subset keeps the registry’s order, whatever order you name the tools in. A name the registry doesn’t have is refused when you make the subset. A subset can only narrow: calling a tool it left out is refused as not permitted, which is a different error from not found.
Call a tool from a node
Section titled “Call a tool from a node”In an async node, build a ToolCallContext from the node’s context and pass it with the call. The context carries the thread ID, run ID, node path, attempt, run metadata, span and services. In Rust it also carries the run’s cancellation token. In C# you pass the node’s CancellationToken as its own argument.
var graph = new StateGraph<Order>() .AddNode("Reserve", async (state, ctx, ct) => { var arguments = JsonSerializer.Serialize(new { sku = state.Sku, count = state.Count }); var json = await support.DispatchToolAsync( "inventory.reserve", arguments, ToolCallContext.From(ctx), ct); var result = JsonDocument.Parse(json).RootElement.GetProperty("result").GetString(); return state with { Confirmation = result }; }) .Compile();
var run = await graph.RunAsync( "order-1001", new Order("AB-12", 2), new InMemoryCheckpointStore(), new RunOptions { Metadata = { ["tenant"] = "acme" } });
public sealed record Order(string Sku, int Count, string? Confirmation = null);let node_tools = support.clone();let graph = StateGraph::<Order>::new() .add_async_node("Reserve", move |state: Order, ctx: NodeContext| { let tools = node_tools.clone(); async move { let arguments = serde_json::json!({ "sku": state.sku, "count": state.count }).to_string(); let result = tools .dispatch_with("inventory.reserve", &arguments, ToolCallContext::from(&ctx)) .await; match result { Ok(confirmation) => Order { confirmation: Some(confirmation), ..state }, Err(error) => Order { confirmation: Some(format!("failed: {error:?}")), ..state }, } } }) .compile() .expect("the graph is valid");
let store = Arc::new(InMemoryCheckpointStore::new());let initial = Order { sku: "AB-12".to_string(), count: 2, confirmation: None };let run = graph .run_with("order-1001", initial, store, RunConfig::new().with_metadata("tenant", "acme")) .await;The run completes with the confirmation in the state:
reserved 2 x AB-12 for acmeThe tool read the tenant from the run’s metadata, through its context. To say who is calling, add a caller ID and the model’s call ID: ToolCallContext.From(ctx) with { CallerId = "support-agent" } in C#, or .with_caller_id("support-agent") and .with_call_id(...) in Rust. When there is no call ID, the registry generates one.
Outside a graph, call DispatchToolAsync(name, arguments) or dispatch(name, arguments) without a context. The tool then gets ToolCallContext.None (C#) or ToolCallContext::default() (Rust), where every field is empty.
Handle tool errors
Section titled “Handle tool errors”A call can fail before the tool runs, or in the tool itself:
| What happened | C# | Rust |
|---|---|---|
| No tool of that name | ToolNotFoundException |
StrataError::ToolNotFound(name) |
| The subset left the tool out | ToolNotPermittedException, which derives from ToolNotFoundException |
StrataError::ToolNotPermitted(name) |
| The arguments don’t fit the schema | ToolArgumentsException, with ToolName and Problems |
StrataError::ToolArgumentParseFailure(problems) |
| The tool failed | The tool’s own exception, unwrapped | StrataError::ToolExecutionFailure(name) |
| The call was cancelled | OperationCanceledException |
StrataError::ToolCancelled(name) |
The problems with the arguments use the same words in both engines, for example argument 'count' is missing or argument 'count' must be an integer, so you can pass them back to a model to fix.
In Rust, ToolExecutionFailure keeps only the tool’s name, not the error the tool returned. Log the error inside the tool before you return it, as inventory_reserve does. See Known limitations.
This helper turns each outcome into text you could give back to a model:
static async Task<string> AskAsync( ToolRegistry tools, string name, string arguments, ToolCallContext context, CancellationToken ct){ try { var json = await tools.DispatchToolAsync(name, arguments, context, ct); return JsonDocument.Parse(json).RootElement.GetProperty("result").ToString(); } catch (ToolArgumentsException bad) { return $"invalid arguments: {bad.Problems}"; } catch (ToolNotPermittedException) { return "not permitted for this agent"; } catch (ToolNotFoundException) { return "no such tool"; } catch (Exception ex) when (ex is not OperationCanceledException) { return $"the tool failed: {ex.Message}"; }}Catch ToolNotPermittedException before ToolNotFoundException to tell them apart.
async fn ask(tools: &ToolRegistry, name: &str, arguments: &str, ctx: ToolCallContext) -> String { match tools.dispatch_with(name, arguments, ctx).await { Ok(result) => result, Err(StrataError::ToolArgumentParseFailure(problems)) => format!("invalid arguments: {problems}"), Err(StrataError::ToolNotPermitted(_)) => "not permitted for this agent".to_string(), Err(StrataError::ToolNotFound(_)) => "no such tool".to_string(), Err(StrataError::ToolExecutionFailure(tool)) => format!("{tool} failed"), Err(other) => format!("{other:?}"), }}With the support subset from above:
| Call | C# result | Rust result |
|---|---|---|
inventory.reserve with {"sku":"AB-12"} |
invalid arguments: argument 'count' is missing |
the same |
inventory.reserve with {"sku":"AB-12","count":9} |
the tool failed: only 7 x AB-12 in stock |
inventory.reserve failed |
billing.refund |
not permitted for this agent |
the same |
inventory.restock |
no such tool |
the same |
An error you don’t handle in a node is the node’s error. In C#, a plain node’s exception escapes RunAsync. In Rust, an async node returns a state, so you handle the Result in the node. To retry or fall back instead, make the node fallible.
Cancel a tool call
Section titled “Cancel a tool call”The token reaches the tool. In C# it is the CancellationToken parameter. In Rust it is ctx.cancellation(). When you build the context from a node, the token is the node’s: it fires when the run is cancelled, its deadline passes, or the node times out.
- Before the call: if the token has already fired, the registry doesn’t run the tool. C# throws
OperationCanceledException, and Rust returnsToolCancelled. - During the call: cancellation is cooperative. The registry doesn’t abandon a tool that ignores its token, so pass the token on to the I/O the tool does, as
ReserveAsyncpasses it toTask.Delayandinventory_reserveraces it withtokio::select!. In C#, the tool’sOperationCanceledExceptionreaches the caller. In Rust, any error a tool returns after its token fired comes back asToolCancelled.
A tool has no time limit of its own. Set a timeout on the node that calls it, or cancel the run. See Known limitations.
Related
Section titled “Related”- Build an agent: give a registry to a model and let it choose the tools.
- Graphs, nodes and edges: async nodes,
NodeContextand fallible nodes. - Known limitations: tool time limits and the Rust tool limits.
- Demo
02-tool-calling(C#, Rust): one tool called from a node. - Demo
26-tool-registry-and-middleware(C#, Rust): several providers in one registry, subsets, the tool context and middleware.