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.

Build an agent

An agent is a model in a loop with tools. Each turn, Strata calls the model. If the model asks for tools, Strata runs them and sends the results back. When the model answers without a tool call, the loop ends and the answer is parsed into a type you choose.

In this guide you build a support agent that looks an order up, refunds it, and answers with a typed RefundDecision. You run it in a graph, test it without a live model, and put a person in front of the refund.

The agent is StrataAgent<TOutput> in C# and Agent<O> in Rust. A node runs it, so it gets retries, checkpoints and run events like any node.

  • Add Strata to your project. See Installation. In .NET the agent is in Datalium.Strata.AI, which Datalium.Strata.Hosting brings. In Rust add datalium-strata-ai next to datalium-strata and datalium-strata-memory.
  • Have a chat client: any IChatClient in .NET, an Arc<dyn ChatClient> in Rust. See Call a model. Without an API key, use the scripted model in Test it without a model.
  • Have tools in a ToolRegistry. See Define tools.

The code comes from demo 23, agent-tool-loop, in strata-demos-dotnet and strata-demos-rust. Its support desk has four tools: orders.lookup, shipping.track, refunds.issue and accounts.close.

The agent can call every tool in the registry you give it, and nothing else. Use a subset to leave tools out. Here the support agent gets three of the desk’s four tools. It can’t close accounts.

public partial class SupportDesk
{
// ...
[StrataTool(Name = "orders.lookup", Description = "Looks an order up by its id: customer, total, status, days since delivery.")]
public OrderInfo? Lookup(string orderId)
{
// ...
}
// ...
}
var desk = new SupportDesk();
var tools = new ToolRegistry()
.Register(desk)
.Subset("orders.lookup", "shipping.track", "refunds.issue");

Providers accept only letters, digits, _ and - in a tool name. The agent declares orders.lookup as orders_lookup and maps it back when the model calls it. Your tools, middleware and run events see the Strata name. Declarations (Rust: declarations()) holds the mapping.

The answer type’s JSON schema is the response format of every turn, with every property required and no others allowed. In Rust, derive StrataSchema and Deserialize for it. A validate function checks what the schema can’t say, and returns the problems. An empty list means valid.

sealed record RefundDecision(string OrderId, string Outcome, decimal Refunded, string? RefundId, string Reply);
static IReadOnlyList<string> Validate(RefundDecision decision)
{
var problems = new List<string>();
if (decision.Outcome is not ("refunded" or "declined" or "escalated"))
{
problems.Add($"outcome '{decision.Outcome}' is not one of refunded, declined, escalated");
}
if (decision.Outcome == "refunded" && (decision.RefundId is null || decision.Refunded <= 0))
{
problems.Add("a refund needs a refund id and an amount");
}
return problems;
}

For a plain-text answer, use StrataAgent<string> or Agent<String>. Its output is the answer’s text.

Give the agent a chat client, the registry and its options. The Id names the agent in errors, run events and spans.

var agent = new StrataAgent<RefundDecision>(chatClient, tools, new StrataAgentOptions<RefundDecision>
{
Id = "support-agent",
Chat = new ChatOptions { ModelId = "gpt-5-mini", Temperature = 0 },
MaxTurns = 6,
Validate = Validate,
});

The options you are most likely to set:

C# Rust What it does Default
Chat with_model, with_temperature, with_request Request settings copied to every turn none
MaxTurns with_max_turns The most turns that call tools (see below) 8
MaxRepairs with_max_repairs How many times a bad answer is sent back, per run 1
Validate with_validate Checks each parsed answer none
ToolFailures with_tool_failures What a failing tool does (see below) FailNode
ToolConcurrency with_tool_concurrency How many of a turn’s tool calls run at once 1
ConfigureTurn with_configure_turn Changes one turn’s request, such as requiring a tool call on turn 1 none

AddAgentNode (Rust: add_agent_node) adds a fallible node that runs the agent. prompt builds the messages from the state. apply folds the agent’s AgentResult into the state: the parsed Output, the Turns taken (repairs included), the ToolCalls, the whole conversation in Messages, and the token Usage summed over the turns.

var graph = new StateGraph<Ticket>()
.AddAgentNode("Resolve", agent,
prompt: (ticket, ctx) =>
[
new(ChatRole.System,
"You are a support agent. Look an order up before deciding anything. Refund an order that " +
"arrived damaged within 30 days of delivery; decline other refunds; escalate what you can't do."),
new(ChatRole.User, $"Ticket {ticket.Id} from customer {ticket.Customer}: {ticket.Message}"),
],
apply: (ticket, result) => ticket with { Decision = result.Output, Turns = result.Turns })
.AddNode("Notify", ticket => ticket with { Sent = $"to {ticket.Customer}: {ticket.Decision!.Reply}" })
.AddEdge("Resolve", "Notify")
.SetRecoveryPolicy("Resolve", RecoveryPolicy.Retry(1))
.Compile();
sealed record Ticket(
string Id, string Customer, string Message,
RefundDecision? Decision = null, int Turns = 0, string? Sent = null);

The conversation lives for one attempt of the node and isn’t checkpointed. Only what apply writes into the state is saved. A retry starts the conversation over.

Run the graph as usual (threads and checkpoints).

var store = new InMemoryCheckpointStore();
var result = await graph.RunAsync(
"ticket-1",
new Ticket("T-1", "C-77", "My order A-1001 arrived with the box crushed."),
store);
if (result is GraphExecutionResult<Ticket>.Completed { State: var done })
{
Console.WriteLine($"{done.Decision!.Outcome} after {done.Turns} turns");
Console.WriteLine(done.Sent);
}
else
{
Console.WriteLine($"The run did not complete: {result}");
}

With the scripted model from the next section, you see:

refunded after 3 turns
to C-77: Sorry about the box. I have refunded 39.90 (RF-0001).

Turn 1 called orders_lookup, turn 2 refunds_issue. Turn 3 answered without a tool call, so the answer was parsed, validated and written to the state. A real model decides its own turns.

The agent only sees a chat client, so you can test it with a stand-in. Demo 23’s ScriptedModel plays one scripted turn per request and records every request. A turn is a function of the conversation, so it can read tool results. The script decides everything, every run is the same, and you need no API key.

public sealed class ScriptedModel(params Func<IReadOnlyList<ChatMessage>, Reply>[] turns) : IChatClient
{
private readonly Queue<Func<IReadOnlyList<ChatMessage>, Reply>> _turns = new(turns);
public List<Request> Requests { get; } = [];
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
var conversation = messages.ToList();
var turn = _turns.Count > 0 ? _turns.Dequeue() : throw new InvalidOperationException("the script has no more turns");
var reply = turn(conversation);
// ...
List<AIContent> contents = reply.Text.Length == 0 ? [] : [new TextContent(reply.Text)];
contents.AddRange(reply.Calls.Select(c => FunctionCallContent.CreateFromParsedArguments(
c.Arguments, c.Id, c.Name, static json => JsonSerializer.Deserialize<Dictionary<string, object?>>(json)!)));
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, contents))
{
// ...
});
}
// ...
}

The script for step 5. The model knows the tools by their provider names:

IChatClient chatClient = new ScriptedModel(
_ => Reply.Call(("call_1", "orders_lookup", """{"orderId":"A-1001"}""")),
_ => Reply.Call(("call_2", "refunds_issue", """{"orderId":"A-1001","amount":39.90,"reason":"damaged in transit"}""")),
_ => Reply.Say("""{"orderId":"A-1001","outcome":"refunded","refunded":39.90,"refundId":"RF-0001","reply":"Sorry about the box. I have refunded 39.90 (RF-0001)."}"""));

A script tests your wiring, tools and validator, and can play the failures below. It can’t tell you whether a real model follows your prompt. Compile-time checks cover structure and types; the model can still be wrong, so validate its output.

  1. Each turn is one model call. If the answer has tool calls, the agent dispatches them through the registry, middleware included, with the agent’s id as the caller and the model’s call ID.
  2. The turn’s results go back in one tool message, in the model’s order. .NET wraps each as {"result": …}; Rust sends the tool’s text.
  3. An answer with no tool calls ends the loop. A typed agent strips a Markdown code fence, parses the answer and validates it.
  4. A bad answer goes back as a user message: Your answer was not valid: <problems>. Answer again with one JSON object that matches the schema. Once MaxRepairs is used up, the node fails transiently: agent 'support-agent' gave no valid answer: ….

MaxTurns caps the turns that call tools. Repair turns don’t count. One tool turn more than MaxTurns fails the node transiently, before that turn’s tools run: agent 'support-agent' made 6 tool turns without answering.

The node’s recovery policy retries it with a fresh conversation. With no policy, the run returns a node failure after one attempt.

What happened What the model gets The loop
No tool by that name {"error":"tool 'x' was not found"} goes on
A tool left out of the subset {"error":"tool 'x' is not permitted here"} goes on
Denied by tool middleware {"error":"denied: <reason>"} goes on
Arguments the tool can’t read {"error":"invalid arguments for tool 'x': <problems>"} goes on
Arguments that aren’t a JSON object {"error":"the arguments for tool 'x' aren't a JSON object"} goes on
The tool itself fails nothing the node fails

None of the refused tools runs, and each shows as Refused in ToolCalls.

When the tool itself fails, the default ToolFailures.FailNode fails the node. In .NET a NodeException keeps its kind, an HTTP fault or timeout is transient, and anything else is fatal. In Rust the failure is fatal unless your with_fault_classifier says otherwise. With ToolFailures.ReturnToModel, the model gets {"error":"tool 'x' failed: <message>"} and the loop goes on. In Rust a #[strata_tool] function’s own error says only the tool returned an error; a hand-written StrataTool returning StrataError::ToolCallFailed keeps its message.

Each turn records ModelCallStarted and ModelCallCompleted run events and a chat span carrying the agent’s id. Each tool call records ToolCallStarted and ToolCallCompleted, with the agent’s id as CallerId. Prompts and answers are never recorded. Under RunStreaming (Rust: run_streaming), each turn’s text streams as it arrives; tool calls appear only as run events.

The loop runs inside one node, and an interrupt boundary pauses between nodes. So don’t give the agent the risky tool. Let it propose, pause after its node, and act in the next node once a person approves.

Here the agent can only read, and answers with a RefundProposal (order ID, amount, reason). After approval, IssueRefund calls refunds.issue through the full registry. Ticket gains Proposal and Refund fields.

var deskTools = new ToolRegistry().Register(desk);
// The agent may only read. It can't call refunds.issue.
var proposer = new StrataAgent<RefundProposal>(
chatClient, deskTools.Subset("orders.lookup", "shipping.track"),
new StrataAgentOptions<RefundProposal> { Id = "refund-proposer" });
var approvalGraph = new StateGraph<Ticket>()
.AddAgentNode("Propose", proposer, Prompt, (ticket, result) => ticket with { Proposal = result.Output })
.AddInterruptBoundary("Propose")
.AddFallibleNode("IssueRefund", async (ticket, ctx, ct) =>
{
var arguments = JsonSerializer.Serialize(ticket.Proposal, JsonSerializerOptions.Web);
var refund = await deskTools.DispatchToolAsync("refunds.issue", arguments, ToolCallContext.From(ctx), ct);
return ticket with { Refund = refund };
})
.AddEdge("Propose", "IssueRefund")
.Compile();
// Runs Propose, saves the thread, and returns Interrupted. Nothing is refunded yet.
var paused = await approvalGraph.RunAsync("ticket-2", new Ticket("T-2", "C-77", "A-1001 arrived broken."), store);
// Later: a person reads the proposal in the saved state and approves it.
var resumed = await approvalGraph.ResumeAsync("ticket-2", HumanSignal.Approve("lead@example.com"), store);

Prompt builds the messages, like the prompt in step 4.

The refund runs outside the loop, only after approval. Like any node, IssueRefund runs at least once, so pass its idempotency key to a real payment system. Pause for approval covers inspecting a paused thread and resuming it.

  • Call a model: chat clients, streaming and provider faults.
  • Define tools: writing tools and building a registry.
  • Pause for approval: interrupt boundaries in practice.
  • Human-in-the-loop: how a paused thread is saved and resumed.
  • Durable execution: what survives a crash, and why a node can run more than once.
  • Demo 23 in .NET and Rust: every failure in this guide, run and checked. Demo 03 runs the same loop against a live OpenAI model.