Tool calling for AI agents in C#
Summarize with AI
The assistants open a new chat that asks for a short summary of this post.
The idea: the model never runs your code. It sees a name, a description and a schema, asks for a call, and your code runs it.
A model answers from its training and from the text in its prompt. Ask a kitchen assistant what to cook tonight and it suggests something reasonable for a kitchen it has never seen. The household’s pantry is in your code, not in the model. A tool fixes that: a C# method the model can ask the agent to run, with the result sent back to the model before it answers. Two small pantry tools are enough for the assistant to check what is in the kitchen before it suggests dinner. The app also prints every tool call the model made.
Who this is for: C# developers who have built a first agent, as in Microsoft Agent Framework: your first C# agent.
Versions: Microsoft Agent Framework 1.24.0, .NET 10.
Code: samples/agent-framework/03-tools, a runnable version of this post with its tests.
Note
The code runs on Claude Haiku 5.5, but it depends only on IChatClient: the first post shows the swap to OpenAI or Ollama.
Start from a chat client for Claude
A console app with two packages, Microsoft.Agents.AI and Microsoft.Agents.AI.Anthropic, and a chat client for Claude Haiku 5.5 that reads its key from ANTHROPIC_API_KEY:
using Anthropic;using Anthropic.Core;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");
IChatClient chatClient = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsIChatClient("claude-haiku-5-5", 1024);Write the pantry as a plain C# class
The pantry is a dictionary in a class of its own, in PantryTools.cs. Use-by dates are relative to today, so the example still has something about to expire whenever it runs:
using System.ComponentModel;
sealed record PantryItem(string Amount, DateOnly UseBy);
sealed class PantryTools{ static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Today);
readonly Dictionary<string, PantryItem> _items = new(StringComparer.OrdinalIgnoreCase) { ["rice"] = new("1 kg", Today.AddMonths(6)), ["eggs"] = new("6", Today.AddDays(9)), ["onions"] = new("3", Today.AddDays(20)), ["spinach"] = new("200 g", Today.AddDays(1)), ["feta"] = new("150 g", Today.AddDays(3)), };
[Description("Looks up one ingredient in the household's pantry.")] public string FindInPantry( [Description( "One ingredient as a plain noun, for example eggs.")] string ingredient) { if (_items.TryGetValue(ingredient, out PantryItem? item)) { return $"Today is {Today:O}. {ingredient}: " + $"{item.Amount}, use by {item.UseBy:O}."; }
return $"No {ingredient} in the pantry. " + $"The pantry has: {string.Join(", ", _items.Keys)}."; }
// ... ListExpiringSoon, shown next}A second method in the same class answers the question the assistant needs most, what has to be used first:
[Description("Lists pantry items that expire soon, soonest first.")] public string ListExpiringSoon( [Description("How many days ahead to look, from 1 to 14.")] int days) { if (days is < 1 or > 14) { return $"days must be 1 to 14, got {days}. " + "Call again with a value in that range."; }
var expiring = _items .Where(i => i.Value.UseBy <= Today.AddDays(days)) .OrderBy(i => i.Value.UseBy) .Select(i => $"{i.Key} ({i.Value.Amount}, " + $"use by {i.Value.UseBy:O})") .ToList();
return expiring.Count == 0 ? $"Nothing expires in the next {days} " + (days == 1 ? "day." : "days.") : $"Today is {Today:O}. Expiring: " + string.Join("; ", expiring) + "."; }Nothing in this class knows about agents. The [Description] attributes from System.ComponentModel are the only addition, and they are written for the model.
Turn each method into an AIFunction
Back in Program.cs, AIFunctionFactory.Create turns each method into an AIFunction, the Microsoft.Extensions.AI tool type for a C# function. It derives from AITool, the type the agent’s tools parameter takes (IList<AITool>). An AIFunction[] passes as an IList<AITool> because C# arrays are covariant:
var pantry = new PantryTools();
AIFunction[] tools =[ AIFunctionFactory.Create( pantry.FindInPantry, name: "find_in_pantry"), AIFunctionFactory.Create( pantry.ListExpiringSoon, name: "list_expiring_soon"),];
foreach (AIFunction tool in tools){ Console.WriteLine($"{tool.Name}: {tool.Description}"); Console.WriteLine(tool.JsonSchema);}The name argument overrides the C# method name. Without it, the tool would be called FindInPantry. That works, but renaming the method would then also rename the tool, without any warning. A fixed name keeps the tool the same when the code changes.
Methods are not the only source of tools. AsAIFunction() turns another agent into an AIFunction the model can call, and McpClient.ListToolsAsync from the ModelContextProtocol package returns an MCP server’s tools as McpClientTool objects, which are AIFunctions too.
The model sees a name, a description and a schema
The loop prints the whole of what the model gets to know about each tool. The schemas are single lines in the output and formatted here:
find_in_pantry: Looks up one ingredient in the household's pantry.{ "type": "object", "properties": { "ingredient": { "description": "One ingredient as a plain noun, for example eggs.", "type": "string" } }, "required": ["ingredient"]}list_expiring_soon: Lists pantry items that expire soon, soonest first.{ "type": "object", "properties": { "days": { "description": "How many days ahead to look, from 1 to 14.", "type": "integer" } }, "required": ["days"]}The model never sees the method body. The name, the description and the parameter schema are all it has to decide whether a tool fits the question and what to pass. The schema comes from the parameter types: int days became "integer", and both parameters are required because neither has a default. A parameter declared as string? note = null would come out as optional, with a type of ["string", "null"] and "default": null. The range 1 to 14 appears only in the description here. [Range(1, 14)] from System.ComponentModel.DataAnnotations would also put it in the schema, as minimum and maximum. Either way it is a hint to the model, not a check, so the method checks the value itself.
Every tool definition goes out with every request to the model, whether the model calls the tool or not.
The agent runs the tool loop
The tools go to AsAIAgent next to the instructions:
AIAgent agent = chatClient.AsAIAgent( name: "kitchen-assistant", instructions: """ You help one household decide what to cook. Check the pantry with your tools before suggesting a dish. Prefer ingredients that expire soon. Answer in at most three sentences. """, tools: tools);
AgentResponse response = await agent.RunAsync( "What should I cook tonight so nothing goes to waste?");
Console.WriteLine(response.Text);That one RunAsync call can be several requests to the model:
- The agent sends the instructions, the question and the two tool definitions.
- The model answers with a function call, for example
list_expiring_soonwithdays: 3. - The agent finds the matching
AIFunction, converts the arguments from JSON and runsListExpiringSoon. - It adds the result to the conversation and sends everything to the model again.
- The model either asks for another call, and the loop repeats, or writes its answer.
Steps 3 and 4, and the repeat, happen in FunctionInvokingChatClient from Microsoft.Extensions.AI. An IChatClient can hold another IChatClient as its inner client, so the client the agent calls is a stack of them. Each layer receives the request, can act on it, and passes it on to the next. FunctionInvokingChatClient is the layer that runs the loop you would otherwise write yourself: read the tool calls, run them, send the results back. ChatClientAgent adds it to the chat client’s pipeline unless the client already has one. Its MaximumIterationsPerRequest caps the rounds of tool calls in one request at 40 by default; after that the model gets one last request without tools and has to answer.
Seeing the calls in the response
response.Text holds only the final answer. response.Messages holds everything the run produced, including each call and each result:
foreach (ChatMessage message in response.Messages){ foreach (AIContent content in message.Contents) { switch (content) { case FunctionCallContent call: var arguments = string.Join(", ", call.Arguments? .Select(a => $"{a.Key}: {a.Value}") ?? []); Console.WriteLine($"call {call.Name}({arguments})"); break; case FunctionResultContent result: Console.WriteLine($"result {result.Result}"); if (result.Exception is { } ex) { Console.WriteLine( $" threw {ex.GetType().Name}"); } break; case TextContent text: Console.WriteLine($"text {text.Text}"); break; } }}The model's request: the tool's name and arguments.
What went back to the model, with the value in Result.
FunctionCallContent is the model’s request: the tool’s Name and the Arguments it chose. FunctionResultContent is what went back, with Result holding the value the method returned. A CallId on both ties each result to its call when the model asks for more than one at a time.
Tonight, cook something that uses the 200 g of spinach (expires tomorrow, October 18) and the 150 g of feta (expires October 20), such as a spinach and feta omelette or a spinach and feta pasta. Both are quick, and the feta keeps a few days if you want to wait on it. I only checked items expiring within three days, so I can't tell you what else is in the pantry to add.call list_expiring_soon(days: 3)result Today is 2026-10-17. Expiring: spinach (200 g, use by 2026-10-18); feta (150 g, use by 2026-10-20).text Tonight, cook something that uses the 200 g of spinach (expires tomorrow, October 18) and the 150 g of feta (expires October 20), such as a spinach and feta omelette or a spinach and feta pasta. Both are quick, and the feta keeps a few days if you want to wait on it. I only checked items expiring within three days, so I can't tell you what else is in the pantry to add.In this run the model made one call, to list_expiring_soon with days: 3, and answered from that result without calling find_in_pantry.
When it is not clear where an answer came from, this list shows whether the model called a tool at all, with which arguments, and what it got back.
A thrown exception reaches the model as a bare error
A first version of ListExpiringSoon might guard its input the usual .NET way:
public string ListExpiringSoon(int days) { ArgumentOutOfRangeException.ThrowIfLessThan(days, 1); ArgumentOutOfRangeException.ThrowIfGreaterThan(days, 14); // ... }If the model asks for 30 days, the exception does not reach RunAsync. FunctionInvokingChatClient catches it and sends the model the text Error: Function failed. The message is left out on purpose. Its IncludeDetailedErrors setting defaults to false, so that the model cannot repeat exception details to the person using the app. To change it, set it on the agent’s own layer, agent.GetService<FunctionInvokingChatClient>()!.IncludeDetailedErrors = true, or add one yourself with chatClient.AsBuilder().UseFunctionInvocation(configure: f => f.IncludeDetailedErrors = true).Build(), which the agent then reuses. Your code can still read the exception on FunctionResultContent.Exception in that run’s response messages. It is not serialized, so a saved session does not keep it. That is what the threw line in the loop prints.
The model now knows that the call failed but not why, so it can only guess what to change. Failures are also counted. MaximumConsecutiveErrorsPerRequest allows three failed iterations in a row, so the model gets three failed attempts back. A fourth failure in a row is rethrown to the caller instead of going back to the model.
Returning the problem as text, as the final version does, tells the model what was wrong and what to do instead:
days must be 1 to 14, got 30. Call again with a value in that range.Exceptions still fit failures the model cannot fix, such as a database that is down. For input the model chose, a result it can act on is better than an exception. A returned error is an ordinary result, though: it does not count toward MaximumConsecutiveErrorsPerRequest, and your code does not see it as a failure unless it logs it.
Return results the model can act on
The return value is the only thing the model learns from a call, so the same rule applies to results that are not errors. If the model asks for egg, FindInPantry does not only say no:
No egg in the pantry. The pantry has: rice, eggs, onions, spinach, feta.The model can see that eggs exists and call again with it, instead of telling the household they have no eggs.
Every answer with a use-by date starts with Today is ... because a model does not know today’s date. Without it, “use by” dates mean nothing to the model. The dates use yyyy-MM-dd. In a format that depends on the culture, the model cannot tell which number is the month: 09/10/2026 is 9 October in the United Kingdom and 10 September in the United States. The O format gives yyyy-MM-dd for a DateOnly in every culture; a yyyy-MM-dd format string would follow the culture’s calendar, which on a Thai system writes the year as 2569. Today is read from the system clock once, when the class is first used, which keeps the example short. In a process that runs past midnight it goes stale, and a test cannot set it; real code would take the date from a TimeProvider.
Each tool answers one question. A single get_pantry returning every item with every detail would also work. But it sends the whole pantry back on every call and leaves the filtering to the model, and filtering is the part the code does reliably.
Tool output is input to the model
A tool result is text in the conversation like any other, and models can follow instructions in it. That is why “Call again with a value in that range” works. The same holds for text nobody in the household wrote. Say a tool returns a note a guest typed into a shared shopping list. The model treats that note the same way as the hint above. Anything a tool reads from an untrusted source is untrusted input to the model. A tool that reads text other people wrote should not sit next to tools that change or send things, unless a person approves those calls.
Try it
- Remove the
[Description]from a parameter, print the tool’s schema again, and compare. That description is all the model knows about the parameter. - Make
find_in_pantrythrow an exception and look at the result inresponse.Messages. The model receivesError: Function failed., not your exception.