Function calling (also called tool use) lets a large language model ask your C# code to run an operation, such as looking up an order, querying a database or creating a ticket, and then use the result in its answer. It is the mechanism behind every useful assistant and agent, and it is where most production incidents in LLM apps start. This guide shows how function calling works with Microsoft.Extensions.AI, how to design tools models use correctly, and how to secure, bound and test them.

What Is Function Calling?#

A chat model on its own can only produce text. With function calling, your request also describes a set of tools: a name, a natural-language description and a JSON schema for the parameters. When the model decides that a tool would help, it does not answer the user. Instead, it returns a structured request such as "call GetOrderStatus with {"orderId":"SO-10042"}".

The key point is who executes what. The model only proposes calls; your application executes them. The model never touches your database or network. Your code decides whether to run the call, runs it with your credentials, and sends the result back as a new message. Because of this split, you stay in control of security, and the quality of your tool definitions largely determines the quality of the model's decisions.

Providers expose the feature under different names (OpenAI tools, Azure OpenAI function calling, Anthropic tool use, Gemini function declarations), but the protocol shape is the same everywhere. Microsoft.Extensions.AI normalizes it into provider-neutral types, so the same C# tools work with OpenAI, Azure OpenAI, Ollama and any other IChatClient.

How Tool Calling Works#

A single user question can trigger several model round trips. Here is the message flow for one question:

Text
1. App   -> Model : system + user messages, tool definitions (names, descriptions, schemas)
2. Model -> App   : assistant message with FunctionCallContent(callId: "c1", GetOrderStatus, {...})
3. App            : validates and invokes GetOrderStatus("SO-10042")
4. App   -> Model : history + tool message with FunctionResultContent(callId: "c1", {...})
5. Model -> App   : either more FunctionCallContent (back to step 3) or a final text answer

In Microsoft.Extensions.AI, those messages are ordinary ChatMessage objects whose Contents include FunctionCallContent (the model's request) and FunctionResultContent (your answer, correlated by CallId). You could run this loop yourself, but FunctionInvokingChatClient does it for you. It is a middleware IChatClient that intercepts function-call requests, invokes the matching AIFunction, appends the results and calls the model again until it produces a final answer or hits a limit.

Two costs are easy to miss. First, tool definitions are sent with every request and count as input tokens, so 40 verbose tools make every call more expensive and slower. Second, every tool round trip is a full model call. A question that needs three sequential lookups costs roughly four model invocations.

Getting Started: Your First Tool in C#

You need Microsoft.Extensions.AI (which includes FunctionInvokingChatClient) and a provider adapter such as Microsoft.Extensions.AI.OpenAI, which is stable at 10.10.0 as of September 2026. The following console app registers one tool and lets the middleware handle the loop:

C#
using System.ComponentModel;
using Microsoft.Extensions.AI;
using OpenAI;

IChatClient client = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!)
    .GetChatClient("gpt-5")
    .AsIChatClient()
    .AsBuilder()
    .UseFunctionInvocation()
    .Build();

var options = new ChatOptions
{
    Tools = [AIFunctionFactory.Create(GetUtcTime)],
};

ChatResponse response = await client.GetResponseAsync(
    "What time is it in Tokyo right now?", options);

Console.WriteLine(response.Text);

[Description("Returns the current UTC date and time in ISO 8601 format.")]
static string GetUtcTime() => DateTimeOffset.UtcNow.ToString("O");

The model sees a tool named GetUtcTime with your description, calls it, receives the timestamp and converts it to Tokyo time in its answer. Nothing in this code is OpenAI-specific except the first three lines. Swap the adapter for Azure OpenAI or Ollama and the tool keeps working. For the broader client pipeline, see the Microsoft.Extensions.AI guide.

Creating Tools with AIFunctionFactory#

AIFunctionFactory.Create turns a delegate or MethodInfo into an AIFunction. It derives everything the model needs from ordinary .NET metadata:

  • Name. By default, the method name is used, and the Async suffix is removed from async methods, so GetOrderStatusAsync becomes GetOrderStatus. You can override the name with the name argument or AIFunctionFactoryOptions.Name.
  • Description. It comes from [Description] (System.ComponentModel) on the method and on each parameter.
  • Parameter schema. A JSON schema is generated from the parameter types, including enums, records and nullability. You can inspect it through the function's JsonSchema property.
  • Special parameters. A CancellationToken, an IServiceProvider or an AIFunctionArguments parameter is bound from the invocation context and excluded from the schema, so the model never sees or supplies it.

The most important design decision is where trusted state comes from. The library's own documentation says that arguments supplied by the AI service must be treated as unvalidated and untrusted. The recommended pattern is to bind tools to an instance that already holds trusted context, such as the authenticated customer:

C#
using System.ComponentModel;

public enum OrderScope { Open, Delivered, All }

public sealed record OrderSummary(string OrderId, string Status, decimal Total, DateOnly Placed);

public sealed class OrderTools(IOrderRepository orders, string customerId)
{
    [Description("Gets status, carrier and tracking link for one of the customer's orders.")]
    public async Task<OrderSummary?> GetOrderStatusAsync(
        [Description("Order number, for example SO-10042.")] string orderId,
        CancellationToken cancellationToken)
    {
        // customerId comes from the authenticated user, never from the model.
        return await orders.FindAsync(customerId, orderId, cancellationToken);
    }

    [Description("Lists the customer's most recent orders, newest first.")]
    public async Task<IReadOnlyList<OrderSummary>> ListRecentOrdersAsync(
        [Description("Which orders to include.")] OrderScope scope = OrderScope.Open,
        [Description("Maximum number of orders to return, 1 to 20.")] int limit = 5,
        CancellationToken cancellationToken = default)
    {
        limit = Math.Clamp(limit, 1, 20); // Never trust sizes chosen by the model.
        return await orders.ListAsync(customerId, scope, limit, cancellationToken);
    }
}

Enums become JSON schema enumerations, so the model can only choose Open, Delivered or All. Default values make parameters optional, and the return values are serialized to JSON automatically. You get two tools from one class with AIFunctionFactory.Create(tools.GetOrderStatusAsync) and AIFunctionFactory.Create(tools.ListRecentOrdersAsync).

Configuring FunctionInvokingChatClient#

In an ASP.NET Core app, register the chat client pipeline once and create the tools per request, because tools often carry per-user state. UseFunctionInvocation accepts a callback for tuning the loop:

C#
using System.Security.Claims;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IOrderRepository, SqlOrderRepository>();
builder.Services
    .AddChatClient(_ => new AzureOpenAIClient(
            new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!),
            new DefaultAzureCredential())
        .GetChatClient(builder.Configuration["AzureOpenAI:Deployment"]!)
        .AsIChatClient())
    .UseFunctionInvocation(configure: f =>
    {
        f.MaximumIterationsPerRequest = 8;        // default 40
        f.MaximumConsecutiveErrorsPerRequest = 2; // default 3
        f.AllowConcurrentInvocation = true;       // default false
        f.IncludeDetailedErrors = false;          // default false; keep it off in production
    })
    .UseOpenTelemetry();

var app = builder.Build();

app.MapPost("/support/chat", async (SupportQuestion question, ClaimsPrincipal user,
    IOrderRepository orders, IChatClient chat, CancellationToken ct) =>
{
    // Identity comes from the validated token. Load any prior history server-side:
    // accepting assistant or tool messages from the client lets callers forge them.
    var tools = new OrderTools(orders, user.FindFirstValue(ClaimTypes.NameIdentifier)!);
    var options = new ChatOptions
    {
        Instructions = "You are Contoso's order assistant. Use tools for any order data.",
        Tools =
        [
            AIFunctionFactory.Create(tools.GetOrderStatusAsync),
            AIFunctionFactory.Create(tools.ListRecentOrdersAsync),
        ],
    };

    ChatResponse response = await chat.GetResponseAsync(question.Text, options, ct);
    return Results.Ok(response.Text);
}).RequireAuthorization();

app.Run();

public sealed record SupportQuestion(string Text);

The properties you will actually tune, with their defaults in Microsoft.Extensions.AI 10.x:

PropertyDefaultWhat it controls
MaximumIterationsPerRequest40Maximum model round trips for one request, including the first
MaximumConsecutiveErrorsPerRequest3Consecutive failing iterations before the exception is rethrown
AllowConcurrentInvocationfalseWhether multiple calls from one response run in parallel
IncludeDetailedErrorsfalseWhether exception messages are sent to the model
TerminateOnUnknownCallsfalseWhether a call to an unknown tool ends the loop instead of returning an error result
AdditionalToolsnullTools the client may invoke without sending them in ChatOptions.Tools
FunctionInvokernullCustom delegate that replaces the default invocation logic

Inside a tool, FunctionInvokingChatClient.CurrentContext exposes the current iteration, the function being called, the chat options and the messages, which is handy for logging and auditing.

Parallel Tool Calls#

Modern models can request several tool calls in one response. For example, they might ask for the status of three orders at once. This saves round trips and latency. Two separate settings control it:

  • ChatOptions.AllowMultipleToolCalls tells the provider whether the model may emit multiple calls per response. The OpenAI adapter maps it to the parallel tool calls flag. Set it to false when calls must be strictly sequential.
  • FunctionInvokingChatClient.AllowConcurrentInvocation decides whether your application executes those calls concurrently. It is false by default, so calls run one after another even when the model requests several.

Enable concurrent invocation only when your tools are thread-safe. A tool that uses a scoped DbContext is not safe to run concurrently, because EF Core contexts do not support parallel operations. The same applies to anything that touches a specific HttpContext. Either give such tools their own scope per call, or keep invocation serial.

Controlling Tool Choice with ChatToolMode#

By default, the model decides whether to call a tool (ChatToolMode.Auto). ChatOptions.ToolMode lets you override that per request:

  • ChatToolMode.None: tools are described, but the model must answer in text.
  • ChatToolMode.RequireAny: the model must call at least one tool.
  • ChatToolMode.RequireSpecific("name"): the model must call that exact tool.

Forcing a specific tool is a robust way to extract data. For example, you can require CreateSupportTicket on the first turn of a ticket-intake flow. Remember that FunctionInvokingChatClient sends the tool results back automatically. If you keep a required mode on every iteration, you can create a loop that only ends at the iteration limit. Use required modes for a single step, then switch back to Auto.

C#
var extractOptions = new ChatOptions
{
    Tools = [AIFunctionFactory.Create(tickets.CreateSupportTicketAsync)],
    ToolMode = ChatToolMode.RequireSpecific("CreateSupportTicket"),
    AllowMultipleToolCalls = false,
};

For pure data extraction without side effects, structured output is often simpler than a forced tool. The structured outputs guide compares the two approaches.

Designing Good Tools#

Models choose and fill tools based only on names, descriptions and schemas. Treat those as a user interface for the model and review them like public API design:

AspectWeak designStrong design
NameProcess, DoActionGetOrderStatus, CancelOrder (verb plus noun)
Description"Gets data"What it returns, when to use it and when not to
ParametersOne free-form string queryTyped parameters, enums and documented formats
GranularityOne tool that runs arbitrary SQLNarrow tools for the operations users need
OutputEntire entity graphsCompact records with only the fields the model needs
ErrorsExceptions with stack tracesShort, actionable messages the model can act on
CountEvery tool on every requestThe few tools relevant to this conversation

A few principles explain the table. Narrow, typed tools reduce hallucinated arguments and make validation straightforward. Descriptions should say when not to use a tool ("Do not use for refunds; use RequestRefund"), because confusion between similar tools is a common failure. Small outputs keep the context window focused and cheap. And fewer tools per request improves both accuracy and latency, so select tools based on the user's role or the current workflow step instead of registering everything globally.

Security: Least Privilege, Approvals and Validation#

Function calling turns model output into actions, which is why the OWASP GenAI LLM Top 10 2026 lists Excessive Agency as LLM03:2026. It names three root causes: excessive functionality, excessive permissions and excessive autonomy. Prompt Injection (LLM01:2026) is the usual trigger. It is either direct, from the user, or indirect, from content your tools return, such as an email, a web page or a support ticket that contains instructions aimed at the model. The responsible AI and LLM security guide covers the wider threat model. For tools, apply these controls:

  • Least privilege. Tools run with your application's credentials, so scope them tightly. Take identity from the authenticated principal, never from a model argument named userId. Prefer read-only tools, and use separate, narrowly scoped credentials for write paths.
  • Validate every argument. Check ranges, formats, ownership and business rules in the tool itself, exactly as you would for a public API endpoint. Schema adherence, even with OpenAI strict mode, is not authorization.
  • Treat tool output as untrusted input. Content fetched by a tool can carry injected instructions. Label it clearly, strip what the model does not need, and never let it widen the set of available tools.
  • Require approval for consequential actions. Refunds, emails, deletions and payments should need a human in the loop.

Microsoft.Extensions.AI has built-in support for approvals. Wrap a function in ApprovalRequiredAIFunction, and FunctionInvokingChatClient returns a ToolApprovalRequestContent instead of invoking it. Your application shows the request to a person, then sends back the response created by CreateResponse. In the 10.0 release, this API was experimental and used FunctionApprovalRequestContent. Current 10.x releases use the stable ToolApprovalRequestContent and ToolApprovalResponseContent names.

C#
AIFunction lookup = AIFunctionFactory.Create(tools.GetOrderStatusAsync);
AIFunction refund = new ApprovalRequiredAIFunction(
    AIFunctionFactory.Create(tools.IssueRefundAsync));

var options = new ChatOptions { Tools = [lookup, refund] };
List<ChatMessage> history = [new(ChatRole.User, "SO-10042 arrived broken, refund it.")];

ChatResponse response = await client.GetResponseAsync(history, options, ct);
history.AddMessages(response);

List<ToolApprovalRequestContent> pending = GetApprovalRequests(response);
while (pending.Count > 0)
{
    List<AIContent> decisions = [];
    foreach (ToolApprovalRequestContent request in pending)
    {
        var call = (FunctionCallContent)request.ToolCall;
        bool approved = await approvals.AskOperatorAsync(call.Name, call.Arguments, ct);
        decisions.Add(request.CreateResponse(approved, approved ? null : "Declined by agent"));
    }

    history.Add(new ChatMessage(ChatRole.User, decisions));
    response = await client.GetResponseAsync(history, options, ct);
    history.AddMessages(response);
    pending = GetApprovalRequests(response);
}

static List<ToolApprovalRequestContent> GetApprovalRequests(ChatResponse r) =>
    r.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();

One subtlety is documented in the source: if a single model response contains a call that requires approval, every call in that response also requires approval. To keep low-risk lookups automatic, set AllowMultipleToolCalls = false in flows that include approval-gated tools.

Error Handling and Iteration Limits#

Tools fail. Databases time out, models send malformed arguments, and records do not exist. FunctionInvokingChatClient catches exceptions from a tool and sends an error result to the model, so the model can retry with different arguments or explain the problem. By default, the model receives a generic error message. IncludeDetailedErrors = true sends the exception message instead. That helps self-correction, but it can leak internal details, so the documentation recommends keeping it off in production.

A better production pattern is a custom FunctionInvoker that turns known failures into safe, actionable messages, adds a per-call timeout and logs everything else:

C#
.UseFunctionInvocation(configure: f =>
{
    f.FunctionInvoker = async (context, ct) =>
    {
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
        timeout.CancelAfter(TimeSpan.FromSeconds(10));
        try
        {
            return await context.Function.InvokeAsync(context.Arguments, timeout.Token);
        }
        catch (ToolValidationException ex) // App-defined exception thrown by tools.
        {
            // Business-rule failures are safe to show; the model can fix its arguments.
            return $"Invalid request: {ex.Message}";
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        {
            return $"The {context.Function.Name} tool timed out. Try again later.";
        }
    };
})

Unexpected exceptions still propagate to the default handling, which counts them toward MaximumConsecutiveErrorsPerRequest. When that limit is exceeded, the exception is rethrown to your code. Also treat MaximumIterationsPerRequest as a cost and safety budget, not just a loop guard. The default of 40 suits open-ended agents, but a support bot rarely needs more than five to eight round trips. A low limit caps the damage from a confused model or an injection that tries to make it loop.

Testing Tools#

Test tool calling at three levels, from cheap and deterministic to realistic and statistical:

  1. Unit-test the tool methods as plain C#, including validation and authorization rules.
  2. Test the wiring deterministically by scripting the model's responses with a fake IChatClient. This verifies names, argument binding and result flow without network calls.
  3. Evaluate real model behavior with a set of representative prompts. The ToolCallAccuracyEvaluator in Microsoft.Extensions.AI.Evaluation.Quality scores how well a model uses the tools you supply. The AI evaluation guide covers running such suites in CI.
C#
public sealed class ScriptedChatClient(params ChatResponse[] script) : IChatClient
{
    private int _turn;
    public List<ChatMessage[]> Requests { get; } = [];

    public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages,
        ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        Requests.Add([.. messages]);
        return Task.FromResult(script[_turn++]);
    }

    public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null,
        CancellationToken cancellationToken = default) => throw new NotSupportedException();

    public object? GetService(Type serviceType, object? serviceKey = null) => null;
    public void Dispose() { }
}

[Fact]
public async Task Order_status_tool_is_invoked_with_model_arguments()
{
    var call = new FunctionCallContent("call_1", "GetOrderStatus",
        new Dictionary<string, object?> { ["orderId"] = "SO-10042" });
    var fake = new ScriptedChatClient(
        new ChatResponse(new ChatMessage(ChatRole.Assistant, [call])),
        new ChatResponse(new ChatMessage(ChatRole.Assistant, "It shipped yesterday.")));

    IChatClient client = fake.AsBuilder().UseFunctionInvocation().Build();
    var tools = new OrderTools(new InMemoryOrderRepository(), customerId: "cust-7");

    ChatResponse response = await client.GetResponseAsync("Where is SO-10042?",
        new ChatOptions { Tools = [AIFunctionFactory.Create(tools.GetOrderStatusAsync)] });

    Assert.Equal("It shipped yesterday.", response.Text);
    FunctionResultContent result = fake.Requests[1]
        .SelectMany(m => m.Contents).OfType<FunctionResultContent>().Single();
    Assert.Equal("call_1", result.CallId);
    Assert.Null(result.Exception);
}

It is also worth snapshot-testing each tool's JsonSchema. Renaming a parameter or changing a type silently changes what the model sees, and a failing snapshot makes that change visible in code review.

Best Practices#

  • Keep the toolset small and contextual. Register only the tools needed for the current user and workflow step.
  • Write descriptions for the model. State the purpose, the expected formats and when not to use the tool, and document units, formats and limits on parameters.
  • Bind trusted state outside the schema. Identity, tenant and permissions come from the host, never from arguments.
  • Clamp and validate everything. Apply page sizes, date ranges and amounts limits inside the tool.
  • Make side effects idempotent. Retries and repeated calls happen, so accept an idempotency key or check state before acting.
  • Gate irreversible actions. Use ApprovalRequiredAIFunction or a confirmation step for anything that moves money, sends messages or deletes data.
  • Set explicit limits. Lower MaximumIterationsPerRequest, add per-tool timeouts, and trace every call with OpenTelemetry.

Common Pitfalls#

  • Trusting model-supplied identifiers. A customerId parameter invites the model, or an attacker, to query other customers' data.
  • Returning huge payloads. Serializing whole entities floods the context window, raises cost and hides the relevant facts.
  • Enabling concurrency with non-thread-safe tools. Parallel calls against one scoped DbContext fail at runtime.
  • Leaking internals through errors. IncludeDetailedErrors = true can reveal connection details or SQL to the model and, through it, to the user.
  • Forcing tools on every iteration. A permanent RequireAny mode can loop until the iteration limit.
  • Assuming every model supports tools. Many small local models handle tools poorly or not at all, so test the exact model and version you deploy.

Function Calling vs Structured Output vs MCP#

ApproachBest forWho executesNotes
Function calling with AIFunctionActions and lookups inside your own appYour process, in-lineLowest latency, full control over validation and identity
Structured output (JSON schema)Extracting or classifying data without side effectsNobody; you parse the resultSimpler and cheaper when no action is needed
Model Context Protocol (MCP) toolsSharing tools across apps, IDEs and agentsAn MCP server, local or remoteTools are discovered at runtime and can be consumed as AIFunction instances

These approaches compose well. Agents built with Microsoft Agent Framework (stable, 1.x) use the same AIFunction abstraction for local tools, and the MCP C# SDK guide shows how to expose the same methods to other hosts.

Frequently Asked Questions#

Does the LLM execute my C# functions directly?#

No. The model only returns a request that names a tool and supplies JSON arguments. Your application, usually through FunctionInvokingChatClient, decides whether to invoke it, runs it with your credentials, and sends the result back as a message. This is why validation and authorization belong in your tool code.

How do I stop an LLM from calling tools in an infinite loop?#

Set MaximumIterationsPerRequest on FunctionInvokingChatClient to a value that fits your scenario, because the default of 40 is generous. Also keep MaximumConsecutiveErrorsPerRequest low, avoid permanently required tool modes, and pass a CancellationToken with a timeout to the whole request.

Can I run multiple tool calls in parallel in .NET?#

Yes. Allow the model to emit multiple calls with ChatOptions.AllowMultipleToolCalls, then set AllowConcurrentInvocation = true on FunctionInvokingChatClient to execute them concurrently. Only do this when the tools are thread-safe, which scoped EF Core contexts are not.

How do I require human approval before a tool runs?#

Wrap the function in ApprovalRequiredAIFunction. The invoking client then returns a ToolApprovalRequestContent instead of running the tool. You collect a decision, send back the response created by CreateResponse(approved), and call the model again.

Which models support function calling?#

Most current hosted models from OpenAI, Azure OpenAI, Anthropic, Google and Mistral support tools, and many support parallel calls. Support among small local models varies widely by model and version. Check the provider documentation, and test tool accuracy with your real prompts before you commit to a model.

Summary#

  • The model proposes tool calls and your code executes them, which keeps security and correctness in your hands.
  • AIFunctionFactory builds tools from methods, and FunctionInvokingChatClient runs the loop with configurable limits, concurrency and error handling.
  • Good tools are narrow, typed, well described and return compact results. Trusted state is bound outside the schema.
  • Excessive agency is the core risk, so apply least privilege, validate arguments, treat tool output as untrusted and gate irreversible actions with approvals.
  • Test at three levels: plain unit tests, scripted fake clients and model evaluations.

Further Reading#