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.
Before you start
Section titled “Before you start”- Add Strata to your project. See Installation. In .NET the agent is in
Datalium.Strata.AI, whichDatalium.Strata.Hostingbrings. In Rust adddatalium-strata-ainext todatalium-strataanddatalium-strata-memory. - Have a chat client: any
IChatClientin .NET, anArc<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.
1. Choose the agent’s tools
Section titled “1. Choose the agent’s tools”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");#[strata_tool( name = "orders.lookup", description = "Looks an order up by its id: customer, total, status, days since delivery.")]async fn orders_lookup(args: OrderArgs, ctx: ToolCallContext) -> Result<String, StrataError> { let desk = desk(&ctx)?; // ...}let tools = support_desk::registry() .subset(["orders.lookup", "shipping.track", "refunds.issue"]) .expect("the desk has these tools");A #[strata_tool] is a free function, so the demo’s tools find the desk as a run service (ctx.service::<SupportDesk>()). You pass the desk when you run the graph, in step 5.
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.
2. Define the answer type
Section titled “2. Define the answer type”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;}#[derive(Debug, Clone, Serialize, Deserialize, StrataSchema)]#[serde(rename_all = "camelCase")]struct RefundDecision { order_id: String, outcome: String, refunded: f64, refund_id: Option<String>, reply: String,}
fn validate(decision: &RefundDecision) -> Vec<String> { let mut problems = Vec::new(); if !["refunded", "declined", "escalated"].contains(&decision.outcome.as_str()) { problems.push(format!( "outcome '{}' is not one of refunded, declined, escalated", decision.outcome )); } if decision.outcome == "refunded" && (decision.refund_id.is_none() || decision.refunded <= 0.0) { problems.push("a refund needs a refund id and an amount".to_string()); } problems}For a plain-text answer, use StrataAgent<string> or Agent<String>. Its output is the answer’s text.
3. Create the agent
Section titled “3. Create the agent”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,});let agent = Arc::new(Agent::<RefundDecision>::new( chat_client, Arc::new(tools), AgentOptions::new("support-agent") .with_model("gpt-5-mini") .with_temperature(0.0) .with_max_turns(6) .with_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 |
4. Add the agent to a graph
Section titled “4. Add the agent to a graph”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);fn prompt(ticket: &Ticket, _: &NodeContext) -> Vec<Message> { vec![ Message::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.", ), Message::user(format!( "Ticket {} from customer {}: {}", ticket.id, ticket.customer, ticket.message )), ]}
let graph = StateGraph::<Ticket>::new() .add_agent_node( "Resolve", agent, prompt, |ticket: Ticket, result: AgentResult<RefundDecision>| Ticket { decision: Some(result.output), turns: result.turns, ..ticket }, ) .add_node("Notify", |ticket: Ticket| Ticket { sent: ticket .decision .as_ref() .map(|d| format!("to {}: {}", ticket.customer, d.reply)), ..ticket }) .add_edge("Resolve", "Notify") .set_recovery_policy("Resolve", RecoveryPolicy::retry(1)) .compile() .expect("the graph is valid");add_agent_node comes from the StateGraphAiExt trait. Ticket derives Clone, Default, Serialize, Deserialize, holds id, customer, message, decision: Option<RefundDecision>, turns: u32 and sent: Option<String>, and has a Ticket::new(id, customer, message) constructor.
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.
5. Run it
Section titled “5. Run it”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}");}let store: Arc<dyn CheckpointStore> = Arc::new(InMemoryCheckpointStore::new());let desk = SupportDesk::new();let result = graph .run_with( "ticket-1", Ticket::new("T-1", "C-77", "My order A-1001 arrived with the box crushed."), store.clone(), RunConfig::new().with_service(desk.clone()), ) .await;
match result { GraphExecutionResult::Completed(ticket) => { let decision = ticket.decision.expect("the agent answered"); println!("{} after {} turns", decision.outcome, ticket.turns); println!("{}", ticket.sent.unwrap_or_default()); } GraphExecutionResult::Failed(error) => println!("The run failed: {error:?}"), _ => println!("The run did not complete."),}With the scripted model from the next section, you see:
refunded after 3 turnsto 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.
Test it without a model
Section titled “Test it without a model”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)."}"""));impl ChatClient for ScriptedModel { fn provider(&self) -> &str { "scripted" }
fn default_model(&self) -> Option<&str> { Some(MODEL) }
fn complete(&self, request: ChatRequest) -> BoxFuture<'_, Result<ChatResponse, ChatError>> { let answer = self.answer(request); Box::pin(async move { // `ChatUpdates` assembles the answer as a real client's does: it // parses each call's arguments, and keeps arguments that aren't a // JSON object as their text, for the agent to refuse. let mut assembled = ChatUpdates::new(); for update in answer? { assembled.push(update); } assembled.finish() }) }
// `stream` plays the same answer as a stream. // ...}The script for step 5. The model knows the tools by their provider names. turn boxes a closure:
let chat_client: Arc<dyn ChatClient> = ScriptedModel::new(vec![ turn(|_| Reply::call(&[("call_1", "orders_lookup", r#"{"orderId":"A-1001"}"#)])), turn(|_| { Reply::call(&[( "call_2", "refunds_issue", r#"{"orderId":"A-1001","amount":39.9,"reason":"damaged in transit"}"#, )]) }), turn(|_| { Reply::say( r#"{"orderId":"A-1001","outcome":"refunded","refunded":39.9,"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.
How the loop works
Section titled “How the loop works”- 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.
- 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. - An answer with no tool calls ends the loop. A typed agent strips a Markdown code fence, parses the answer and validates it.
- 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.OnceMaxRepairsis used up, the node fails transiently:agent 'support-agent' gave no valid answer: ….
Stop a runaway loop
Section titled “Stop a runaway loop”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.
When a tool call goes wrong
Section titled “When a tool call goes wrong”| 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.
Watch an agent run
Section titled “Watch an agent run”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.
Put a person before a risky action
Section titled “Put a person before a risky action”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.
let desk_tools = Arc::new(support_desk::registry());
// The agent may only read. It can't call refunds.issue.let proposer = Arc::new(Agent::<RefundProposal>::new( chat_client, Arc::new(desk_tools.subset(["orders.lookup", "shipping.track"]).expect("known tools")), AgentOptions::new("refund-proposer"),));
let approval_graph = StateGraph::<Ticket>::new() .add_agent_node("Propose", proposer, prompt, |ticket: Ticket, result: AgentResult<RefundProposal>| { Ticket { proposal: Some(result.output), ..ticket } }) .add_interrupt_boundary("Propose") .add_async_fallible_node("IssueRefund", move |ticket: Ticket, ctx: NodeContext| { let desk_tools = desk_tools.clone(); async move { let arguments = serde_json::to_string(&ticket.proposal).expect("serializable"); let refund = desk_tools .dispatch_with("refunds.issue", &arguments, ToolCallContext::from(&ctx)) .await .map_err(|error| NodeError::Fatal(format!("{error:?}")))?; Ok(Ticket { refund: Some(refund), ..ticket }) } }) .add_edge("Propose", "IssueRefund") .compile() .expect("the graph is valid");
// Runs Propose, saves the thread, and returns Interrupted. Nothing is refunded yet.let paused = approval_graph .run_with("ticket-2", Ticket::new("T-2", "C-77", "A-1001 arrived broken."), store.clone(), config()) .await;
// Later: a person reads the proposal in the saved state and approves it.let resumed = approval_graph .resume_with("ticket-2", HumanSignal::approve("lead@example.com"), store.clone(), config()) .await;config() returns RunConfig::new().with_service(desk.clone()), so the desk reaches the tool on the run and on the resume.
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.
Related
Section titled “Related”- 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.