Skip to content
These docs cover Strata 0.9.0-preview.1.It is a preview, and the API can still change before 1.0.

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.

Add Strata to your project as Installation shows.

  • C#: the [StrataTool] source generator ships inside the Datalium.Strata package, and Datalium.Strata.Hosting brings it too. Reference one of them from the project that declares the tools. A package’s analyzers don’t pass through a ProjectReference.
  • Rust: add datalium-strata and serde with the derive feature. The examples below also use serde_json and tokio.

You need to know what a node is and that an async node gets a NodeContext. Graphs, nodes and edges explains both.

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}";
}
}
  • Name and Description are 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, bool and the built-in numeric types, optionally nullable. Any other type is a compile error.
  • A ToolCallContext parameter and a CancellationToken parameter 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, Task or ValueTask. 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.

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.

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

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.

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.

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.

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

The run completes with the confirmation in the state:

reserved 2 x AB-12 for acme

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

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.

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.

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 returns ToolCancelled.
  • 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 ReserveAsync passes it to Task.Delay and inventory_reserve races it with tokio::select!. In C#, the tool’s OperationCanceledException reaches the caller. In Rust, any error a tool returns after its token fired comes back as ToolCancelled.

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.

  • Build an agent: give a registry to a model and let it choose the tools.
  • Graphs, nodes and edges: async nodes, NodeContext and 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.