An AI agent is a model that works in a loop: it calls the tools you give it, reads their results, and keeps going until the task is done. With OneLLM the tools are ordinary C# methods and the agent is a short YAML file.
dotnet add package RecursiveAI.OneLlmRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
using OneLlm; using OneLlm.Agents; Llm.Default.Tools.Add<StoreTools>(); var input = new Dictionary<string, object?> { ["message"] = "Where is my order 1182?", }; AgentResult r = await Llm.RunAgentAsync( "agents/support.yaml", input); Console.WriteLine(r.Text);
Download this example (.zip): unzip, then dotnet run.
Prints a line from each tool as the agent calls it, then the agent’s reply: Your order 1182 (2 bags of house blend) shipped on Nov 3 and should arrive Friday, Nov 7. The snippet needs two more pieces, both shown below and both in the download, which holds three files: agents/support.yaml (the agent), Program.cs (with the StoreTools class) and the project file. The agent uses gpt-6-luna, a model name from OneLLM’s model catalog, so a real run needs OPENAI_API_KEY. The download runs with no key, using OneLLM’s scripted provider to make the tool calls a model would.
The full program
The agent is a file, agents/support.yaml in the download:
name: support model: gpt-6-luna systemPrompt: >- You answer customers of an online store. Use the tools to look up their order. When you know the answer, call task_complete with your reply to the customer. inputs: message: "" initialPrompts: - user: - text: "{{ .inputs.message }}" references: - tools: [get_order, get_delivery_estimate, task_complete]
name: the agent’s name, saved with each run.model: the model it calls, a catalog name orprovider:model.systemPrompt: the instructions sent with every call.inputs: the values the caller passes in, with their defaults; here the customer’smessage.initialPrompts: the first user message, a template;{{ .inputs.message }}puts the input in.references: the tools the agent may call, by name.
task_complete is a tool built into OneLLM. When the model calls it with a result and a summary, the run ends and the result becomes the agent’s answer. That’s why the system prompt tells the model to call it.
This is the Program.cs. The two tools are static methods marked [Tool]. Each prints a line when it runs, so you can see the agent use it.
using OneLlm; using OneLlm.Agents; using OneLlm.Messages; using OneLlm.Testing; using OneLlm.Tools; var runtime = Llm.Default; // the runtime every Llm.* call goes through runtime.Tools.Add<StoreTools>(); // --- scripted: a stand-in for OpenAI that makes the tool calls a model would, so this runs --- // without a key. To use gpt-6-luna for real, delete this block and the one at the end, and set // OPENAI_API_KEY. var openai = new ScriptedProvider("openai"); runtime.Providers.Set("openai", openai); openai.Enqueue( Call("c1", "get_order", """{"orderId":1182}""", 410, 18), Call("c2", "get_delivery_estimate", """{"trackingNumber":"1Z-58-0042"}""", 520, 22), Call("c3", "task_complete", """{"result":"Your order 1182 (2 bags of house blend) shipped on Nov 3 and should arrive Friday, Nov 7.","summary":"Looked up order 1182 and its delivery estimate."}""", 610, 48)); // --- end scripted --- AgentResult result = await Llm.RunAgentAsync("agents/support.yaml", new Dictionary<string, object?> { ["message"] = "Where is my order 1182?" }); Console.WriteLine($"{result.StopReason} after {result.Turns} turns, cost {result.Cost}"); Console.WriteLine(result.Text); // --- scripted: what the model was offered, from the request the stand-in received. --- foreach (var tool in openai.Requests[0].Tools) Console.WriteLine($"tool {tool.Name}: {tool.Description}"); Console.WriteLine(openai.Requests[0].Tools.First(t => t.Name == "get_order").InputSchema.ToJsonString()); static ScriptStep Call(string id, string tool, string args, long tokensIn, long tokensOut) => new() { ToolCalls = [new ScriptedToolCall(id, tool, args)], Usage = new Usage { InputTokens = tokensIn, OutputTokens = tokensOut }, }; // --- end scripted --- public sealed partial class StoreTools { /// <summary>Looks up an order: its items, status and tracking number.</summary> /// <param name="orderId">The order number, for example 1182.</param> [Tool] public static string GetOrder(int orderId) { Console.WriteLine($" -> get_order({orderId})"); return orderId == 1182 ? "Order 1182: 2 x house blend coffee, shipped 2026-11-03, tracking 1Z-58-0042" : $"No order {orderId}."; } /// <summary>Gives the carrier's delivery estimate for a tracking number.</summary> /// <param name="trackingNumber">The tracking number from the order.</param> [Tool] public static string GetDeliveryEstimate(string trackingNumber) { Console.WriteLine($" -> get_delivery_estimate({trackingNumber})"); return "Estimated delivery: Friday 2026-11-07"; } }
dotnet run prints:
-> get_order(1182) -> get_delivery_estimate(1Z-58-0042) Completed after 3 turns, cost $0.000198 Your order 1182 (2 bags of house blend) shipped on Nov 3 and should arrive Friday, Nov 7. tool get_order: Looks up an order: its items, status and tracking number. tool get_delivery_estimate: Gives the carrier's delivery estimate for a tracking number. tool task_complete: Signal that the assigned task has been completed. Call this tool when you have finished the work requested by the user. Provide the result of the task and a summary of the actions taken. Optionally provide a short title. Ends the run and returns a plain-text confirmation. {"type":"object","properties":{"orderId":{"type":"integer","description":"The order number, for example 1182.","minimum":-2147483648,"maximum":2147483647}},"required":["orderId"],"additionalProperties":false}
The model looked up the order, used its tracking number to get the estimate, then ended the run with task_complete, whose result became result.Text. Three turns of gpt-6-luna (1,540 tokens in, 88 out) cost $0.000198 at the catalog’s prices. The last lines show what the model was offered: the source generator turned each method’s name into get_order and get_delivery_estimate, its XML <summary> into the description, and its parameters (with their <param> text) into a JSON Schema.
A tool that throws, or a call with bad arguments, doesn’t stop the run: the model gets an error result and the loop goes on (see the variations). To run against OpenAI, set OPENAI_API_KEY and delete the two blocks from // --- scripted to // --- end scripted ---.
Variations
Ask a person before a tool runs. Mark the tool RequiresApproval. The run pauses with the call held, and continues when you approve or deny it. The agent file, agents/refunds.yaml:
name: refunds model: gpt-6-luna systemPrompt: You handle refund requests. Call task_complete when done. inputs: message: "" initialPrompts: - user: - text: "{{ .inputs.message }}" references: - tools: [refund_order, task_complete]
using OneLlm; using OneLlm.Agents; using OneLlm.Testing; using OneLlm.Tools; var openai = new ScriptedProvider("openai"); var runtime = new Runtime(); runtime.Providers.Set("openai", openai); runtime.Tools.Add<RefundTools>(); Llm.Default = runtime; openai.Enqueue( new ScriptStep { ToolCalls = [new ScriptedToolCall("r1", "refund_order", """{"orderId":1182,"amount":29.00}""")] }, new ScriptStep { ToolCalls = [new ScriptedToolCall("r2", "task_complete", """{"result":"Refunded $29.00 for order 1182.","summary":"Refund issued."}""")] }); AgentRun run = await Llm.StartAgentAsync("agents/refunds.yaml", new Dictionary<string, object?> { ["message"] = "Please refund order 1182, the bag arrived torn." }); AgentResult paused = await run.AwaitAsync(); Console.WriteLine($"{paused.StopReason}: {run.PendingApprovals[0].Tool}"); await run.ApproveAsync(); // or run.DenyAsync(reason: "...") AgentResult done = await run.AwaitAsync(); Console.WriteLine($"{done.StopReason}: {done.Text}"); public sealed partial class RefundTools { /// <summary>Refunds an order.</summary> /// <param name="orderId">The order number.</param> /// <param name="amount">The amount to refund, in dollars.</param> [Tool(RequiresApproval = true)] public static string RefundOrder(int orderId, decimal amount) => $"Refunded {amount:0.00} on order {orderId}."; }
WaitingForApproval: refund_order Completed: Refunded $29.00 for order 1182.
Tools from lambdas, without a class. Tool.Create reads the delegate’s signature by reflection, so unlike [Tool] it isn’t trim or NativeAOT safe:
using OneLlm; using OneLlm.Agents; using OneLlm.Testing; using OneLlm.Tools; var openai = new ScriptedProvider("openai"); var runtime = new Runtime(); runtime.Providers.Set("openai", openai); Llm.Default = runtime; openai.Enqueue( new ScriptStep { ToolCalls = [new ScriptedToolCall("c1", "get_order", """{"orderId":1182}""")] }, new ScriptStep { ToolCalls = [new ScriptedToolCall("c2", "get_delivery_estimate", """{"trackingNumber":"1Z-58-0042"}""")] }, new ScriptStep { ToolCalls = [new ScriptedToolCall("c3", "task_complete", """{"result":"Arrives Friday.","summary":"Done."}""")] }); runtime.Tools.Add(Tool.Create("get_order", (int orderId) => $"Order {orderId}: shipped, tracking 1Z-58-0042", "Looks up an order by number.")); runtime.Tools.Add(Tool.Create("get_delivery_estimate", (string trackingNumber) => "Estimated delivery: Friday", "Gives the delivery estimate for a tracking number.")); AgentResult result = await Llm.RunAgentAsync("agents/support.yaml", new Dictionary<string, object?> { ["message"] = "Where is my order 1182?" }); Console.WriteLine($"{result.StopReason}: {result.Text}");
Completed: Arrives Friday.When the model gets a call wrong. This uses the same agents/support.yaml. The scripted model passes a word where the schema wants a number, then calls a tool that doesn’t exist, then answers without calling task_complete:
using OneLlm; using OneLlm.Agents; using OneLlm.Messages; using OneLlm.Testing; using OneLlm.Tools; var openai = new ScriptedProvider("openai"); var runtime = new Runtime(); runtime.Providers.Set("openai", openai); Llm.Default = runtime; runtime.Tools.Add(Tool.Create("get_order", (int orderId) => "Order: shipped", "Looks up an order by number.")); runtime.Tools.Add(Tool.Create("get_delivery_estimate", (string trackingNumber) => "Friday", "Gives the delivery estimate.")); openai.Enqueue( new ScriptStep { ToolCalls = [new ScriptedToolCall("c1", "get_order", """{"orderId":"eleven"}""")] }, new ScriptStep { ToolCalls = [new ScriptedToolCall("c2", "cancel_order", """{"orderId":1182}""")] }, ScriptStep.Text("Sorry, I couldn't find that order.")); AgentResult result = await Llm.RunAgentAsync("agents/support.yaml", new Dictionary<string, object?> { ["message"] = "Where is order eleven?" }); Console.WriteLine($"{result.StopReason} after {result.Turns} turns: {result.Text}"); foreach (var m in result.Instance.Messages) foreach (var call in m.ToolCalls) Console.WriteLine($" {call.Tool} -> {(call.IsError ? "error: " : "")}{string.Join("", call.Result.OfType<TextPart>().Select(p => p.Text))}");
Waiting after 3 turns: Sorry, I couldn't find that order. get_order -> error: Argument 'orderId' does not match its schema: The JSON value could not be converted to System.Int32. Path: $ | LineNumber: 0 | BytePositionInLine: 8. cancel_order -> error: Unknown tool 'cancel_order'. Available tools: get_delivery_estimate, get_order, task_complete.
An answer with no tool call leaves the agent Waiting for a next message, rather than Completed.
Related recipes
- Unit test LLM calls in C# without an API key: the scripted provider that drives the agent here.
- Track OpenAI API cost per call in C#: spend caps for agent runs.
- Call Ollama from C#: run the agent on a local model.
- Get LLM structured output into a C# record
The short answer
To build an AI agent with tool calling in C#, add the RecursiveAI.OneLlm package and write each tool as a method marked [Tool] on a partial class, with XML comments describing it. Register the class with Tools.Add<T>(), list the tools in an agent file (model, system prompt, inputs, references: - tools: [...]), and run it with Llm.RunAgentAsync. OneLLM sends the tools’ schemas to the model, runs each call it makes, returns the results, and repeats until the model calls task_complete or answers without a tool. The AgentResult holds the reply, the stop reason, the number of turns and the total cost.
OneLLM is free and open source under MIT or Apache 2.0, with no native dependencies. The rest of this page answers the questions people ask next.
What is tool calling in AI agents?
Tool calling (also called function calling) lets a model ask your program to run a function. You describe each function with a name, a description and a JSON Schema for its arguments. The model answers with a call (a tool name and JSON arguments) instead of text. Your code runs the function and sends the result back, and the model continues with that result in hand.
An agent is that exchange in a loop. In OneLLM’s words, from the agents docs: “Each turn: the model is called with the definition’s tools; the tool calls it makes run; the turn is saved with its results.” When a model makes several calls in one turn, they run together unless a tool is marked not parallel-safe, and the results go back “in call order, not in finishing order.”
How does the model know which tool to call?
From the descriptions and schemas you write, and nothing else. The model sees each tool’s name, its description and its argument schema, and picks one by matching them to the task. That makes the XML comments part of your prompt. In the full program, get_order’s description says it returns a tracking number, which is why a model would go on to call get_delivery_estimate with it.
OneLLM builds the schema from the method signature at compile time, by the rules in its tool signature notes. An int parameter becomes an integer with the type’s range, a parameter without a default is required, and the object is closed with "additionalProperties": false. A call with arguments that don’t match is refused before your method runs, as the variation shows.
Can I build AI agents using C#?
Yes. A C# agent needs four things, and OneLLM provides each one in the main package:
| Part | In OneLLM |
|---|---|
| Tools | [Tool] methods, with schemas and invokers made by a source generator, or Tool.Create for delegates |
| The loop | Llm.RunAgentAsync to run to the end, Llm.StartAgentAsync for a run you can follow, message, approve and cancel |
| Limits | maxIterations (default 200 model turns), spend caps on the Runtime, approvals per tool |
| A record | every run is a saved instance with its messages, tool calls and cost, which you can list, fork and resume |
The loop ends when a tool such as task_complete ends it, when the model answers with no tool call, when a call needs approval, or when maxIterations is reached. Failures don’t end it: “a tool that throws or times out gives the model an error result and the loop goes on; an unknown tool name or malformed arguments do too.” Approvals are saved in the instance, so the docs say a decision “survives a restart.”
The built-in tools that touch files, the shell or the network ask the host first. In the capabilities docs: “What a tool may touch is decided by the host, never by a definition.” Files are allowed under the working directory by default, and the shell and network are off.
What is the best AI model for tool calling?
Test it on your task. A good tool-calling model picks the right tool, fills in valid arguments and knows when it’s done, and that varies by model and by how clearly your tools are described. With OneLLM, the model is one line of the agent file, so trying another is cheap: gpt-6-luna, gemini-flash, claude-sonnet or a local ollama: model. Each run reports its cost, which matters as much as accuracy over many runs.
On what has been checked: OneLLM’s results say a live smoke test at release, which included a client tool round trip, passed against OpenAI (8 of 8 cases) and Gemini (7 of 7), and each of the eight example agents ran its task once on an OpenAI and a Gemini model. Anthropic, Azure OpenAI / Foundry, Amazon Bedrock and Vertex AI were not run live, and are covered by recorded fixtures only.