.NETAI AgentsMicrosoft Agent FrameworkTutorial

Build Your First Tool-Using AI Agent in C#

A step-by-step tutorial: build an AI agent in C# with the Microsoft Agent Framework that calls your own tools, remembers the conversation, and chains multiple tool calls.

Build Your First Tool-Using AI Agent in C#

An LLM on its own can only talk. The moment you want it to do something — look up an order, check inventory, send a notification — it needs tools: functions it can call. This tutorial builds a small but complete tool-using agent in C# with the Microsoft Agent Framework, starting from an empty console app and ending with an agent that chains multiple tool calls and remembers the conversation.

If you’re new to the framework itself, skim our Microsoft Agent Framework guide first. Otherwise, let’s build.

The setup

Create a console app and add the two packages you need — the framework and the Azure OpenAI client (any IChatClient-compatible provider works; Azure OpenAI is used here):

dotnet new console -n AgentDemo
cd AgentDemo
dotnet add package Microsoft.Agents.AI
dotnet add package Azure.AI.OpenAI

You’ll need an Azure OpenAI resource with a chat model deployed (for example gpt-4o-mini). Set the endpoint and use DefaultAzureCredential so you’re not hard-coding keys.

Step 1: A bare agent

Start with an agent that can only talk — no tools yet. This confirms your connection works:

using Microsoft.Agents.AI;
using Azure.AI.OpenAI;
using Azure.Identity;

var chatClient = new AzureOpenAIClient(
        new Uri("https://<your-resource>.openai.azure.com"),
        new DefaultAzureCredential())
    .GetChatClient("gpt-4o-mini");

AIAgent agent = chatClient.CreateAIAgent(
    name: "SupportBot",
    instructions: "You are a concise customer-support assistant.");

Console.WriteLine(await agent.RunAsync("Hi, can you help me track an order?"));

Run it and you’ll get a friendly but useless reply — it has no way to actually track anything. Let’s fix that.

Step 2: Define a tool

A tool is just a C# method. Annotate it and its parameters with [Description] so the model knows what it does and when to use it — the model reads these descriptions to decide whether to call it:

using System.ComponentModel;

[Description("Looks up the delivery status of a customer order by its ID.")]
static string GetOrderStatus(
    [Description("The order ID, e.g. 'ORD-4821'")] string orderId)
{
    // In a real app this would hit your database or an API.
    return orderId == "ORD-4821"
        ? "Order ORD-4821 shipped on Jul 12 and arrives Jul 15."
        : $"No order found with ID {orderId}.";
}

The description quality matters more than you’d expect: vague descriptions lead to the model calling the wrong tool or not calling it at all. Be specific about what it does and when to use it.

Two habits save most of the pain here. First, describe the trigger, not just the behaviour. “Looks up the delivery status of a customer order by its ID” tells the model both what happens and when it applies; “Order helper” tells it nothing it can route on. Second, let the parameter types do the work. A string status parameter invites the model to invent a value, whereas an enum forces the generated schema to list the only legal options and the model picks from that list. The same applies to dates, IDs and currency codes — the tighter the type, the fewer malformed calls you have to defend against downstream.

Expect malformed calls anyway. The model has seen ORD-4821 in the customer’s message, but it may hand you ORD 4821, 4821, or ord-4821. Normalise inside the tool rather than hoping the model behaves: trim it, upper-case it, strip the separators. And treat the argument as untrusted input, because it is — it originated in a sentence a stranger typed.

Step 3: Give the tool to the agent

Register the method as a tool when you create the agent. The framework turns your model’s tool-call requests into actual C# invocations and feeds the results back automatically — you don’t write the branching logic:

AIAgent agent = chatClient.CreateAIAgent(
    name: "SupportBot",
    instructions: "You help customers track orders. Use your tools to look up real data; never guess an order's status.",
    tools: [AIFunctionFactory.Create(GetOrderStatus)]);

var reply = await agent.RunAsync("Where is my order ORD-4821?");
Console.WriteLine(reply);

Now the agent recognizes the intent, calls GetOrderStatus("ORD-4821"), and answers with the real result. That instruction — “never guess” — is doing real work: it pushes the model toward the tool instead of hallucinating a status.

Step 4: Multiple tools and chaining

Real agents pick from several tools and sometimes chain them. Add a second tool and let the model coordinate:

[Description("Starts a return for a delivered order and returns a return label URL.")]
static string StartReturn(
    [Description("The order ID")] string orderId,
    [Description("Reason for the return")] string reason)
    => $"Return started for {orderId} ({reason}). Label: https://returns.example/{orderId}";

AIAgent agent = chatClient.CreateAIAgent(
    name: "SupportBot",
    instructions: "Help customers with orders and returns using your tools.",
    tools:
    [
        AIFunctionFactory.Create(GetOrderStatus),
        AIFunctionFactory.Create(StartReturn),
    ]);

// The model may call GetOrderStatus first to confirm delivery,
// then StartReturn — across a single RunAsync call.
var reply = await agent.RunAsync(
    "I want to return ORD-4821 because it's the wrong size.");
Console.WriteLine(reply);

The framework runs the tool-call loop for you: model requests a tool, framework executes it, result goes back to the model, repeat until the model produces a final answer.

Step 5: Remembering the conversation

By default each RunAsync is stateless. To hold a multi-turn conversation, create a thread and pass it on every call:

var thread = agent.GetNewThread();

Console.WriteLine(await agent.RunAsync("Track ORD-4821", thread));
// Follow-up — the agent remembers which order you meant:
Console.WriteLine(await agent.RunAsync("Actually, start a return for it — wrong size", thread));

The thread carries the history, so the second message resolves “it” to ORD-4821 without you repeating the ID.

That convenience has a bill attached. A thread is an append-only transcript, and the whole thing is resent on every turn — including every tool-call message and every tool result from earlier turns. A twenty-message support conversation with a handful of tool calls sends noticeably more input tokens on message twenty than on message one, at the same per-token rate. Left unmanaged, long-lived threads are the single most common reason an agent that felt cheap in testing becomes expensive in production.

The other half of the problem is that a thread is in-process state. It lives in memory, so it dies with the process, and it doesn’t follow the user to a second replica behind a load balancer. That is fine for a console demo and wrong for anything with two instances. Before you ship, decide where the transcript lives and when it gets trimmed or summarised — agent memory and state persistence covers the options.

What that loop actually costs

Hiding the loop is convenient and slightly dangerous, because it makes it easy to forget that every turn of it is a full model round-trip with the entire conversation resent as input.

Count the traffic in the return example above. Request one carries the system instructions, the user’s message, and the JSON schemas for both tools. The model replies asking for GetOrderStatus. The framework executes that locally in microseconds, then sends request two: everything from before, plus the assistant’s tool-call message, plus the tool’s result. The model replies asking for StartReturn. Request three repeats all of it again and finally gets prose back. One user sentence, three round-trips, and the input grows on every one because nothing is ever removed.

Two consequences are worth planning for. Latency is additive: if a model round-trip takes a second and a half, a two-tool chain costs four and a half seconds before the user sees a word, and your own tool execution is usually the smallest term in that sum. Cost grows faster than the call count: a turn that chains four tools does not cost four times a single call, it costs roughly the sum of a growing prefix, because the fourth request re-pays for the first three.

The levers are unglamorous but effective. Keep tool descriptions tight, because every word of them is input tokens on every request for the rest of the conversation. Return the smallest useful result — a tool that dumps a forty-field order object into the context makes every subsequent turn more expensive, while one that returns the four fields needed to answer the question does not. And prefer one coarse tool over three fine-grained ones when the coarse version matches what people actually ask: a single GetOrderSummary returning status, address and returns eligibility beats three tools the model has to discover and chain.

Cap the loop before it caps you

A model that misreads a tool result will sometimes ask for the same tool again, with the same arguments, and again after that. Without a limit that is an infinite loop billed per iteration.

Microsoft.Extensions.AI guards against this. The function-invoking chat client that drives the loop exposes a maximum number of iterations per request, defaulting to 10, where the count includes the initial request. Ten is generous for a support agent. If yours legitimately needs more than three or four tool calls to answer one question, the task is usually better expressed as a workflow with explicit steps than as one free-running agent turn.

Set the limit deliberately and log when you hit it, because hitting it is a diagnostic rather than just an error. It nearly always means a tool is returning something the model cannot interpret as an answer, so it keeps trying. The fix belongs in the tool’s return value, not in raising the ceiling.

Tools should fail like tools, not like exceptions

The tool above assumes the happy path. In production GetOrderStatus calls a database that times out, an API that returns 503, or a record the caller isn’t entitled to see.

Letting those exceptions escape the tool method is bad in two directions. The mild failure is that the whole RunAsync throws and your endpoint returns a 500 for what was, from the customer’s point of view, an entirely recoverable situation. The nastier failure is the exception message — connection strings, internal hostnames, stack frames — landing in the model’s context and then, paraphrased, in the reply.

Catch inside the tool and return something the model can act on:

var orders = new OrderRepository(connectionString);

// Not `static`, so the local function can close over `orders`.
[Description("Looks up the delivery status of a customer order by its ID.")]
async Task<string> GetOrderStatusAsync(
    [Description("The order ID, e.g. 'ORD-4821'")] string orderId,
    CancellationToken cancellationToken)
{
    try
    {
        var id = orderId.Trim().ToUpperInvariant();
        var order = await orders.FindAsync(id, cancellationToken);

        return order is null
            ? $"No order found with ID {id}. Ask the customer to confirm it."
            : $"Order {order.Id} is {order.Status}, expected {order.Eta:d MMM}.";
    }
    catch (TimeoutException)
    {
        return "The order system is not responding. Tell the customer to try again "
             + "shortly. Do not guess a status.";
    }
}

Four details carry weight there. It’s a non-static local function, so it can capture the repository from the enclosing scope — in a real app you’d inject the dependency rather than close over it, but the tool method itself is unchanged either way. It’s async and returns Task<string>; tool methods that await work exactly as synchronous ones do, which matters the moment a tool touches a database. The CancellationToken parameter is bound by the framework rather than by the model — AIFunctionFactory supplies the token from the invocation and omits that parameter from the generated JSON schema entirely, so the model never sees it and never tries to fill it in. And the failure strings are written at the model: “do not guess a status” is an instruction, and phrasing errors as instructions is the difference between an honest reply and a confident fabrication.

The tools you shouldn’t let the model call unsupervised

GetOrderStatus is safe: it reads, it’s idempotent, and calling it twice costs nothing but tokens. StartReturn is not. It creates a record, may trigger a refund, and the model decides when to call it based on a probabilistic reading of a sentence someone typed.

Two mitigations, and you want both. Enforce the preconditions in code rather than in the prompt — check inside StartReturn that the order exists, has actually been delivered, and falls inside the returns window, and refuse with an explanatory string if not. “Only start returns for delivered orders” in the instructions is a suggestion; a guard clause is a rule. Then put anything genuinely irreversible behind a human confirmation step, where the tool records an intent and a person approves it, instead of letting one ambiguous sentence move money.

The same reasoning covers prompt injection. Tool arguments are attacker-influenced strings arriving in your C# method, so a tool that concatenates one into SQL or hands it to a shell is an ordinary injection vector wearing a new hat. Parameterise queries, allow-list paths, and scope the tool’s database credentials to exactly what it needs. Our prompt-injection defence guide goes further, but the floor is: every argument is hostile until your code says otherwise.

Testing an agent that has tools

Split the problem, because the two halves fail differently and only one of them is deterministic.

The tools are ordinary C# methods, so test them with ordinary unit tests: unknown IDs, odd whitespace and casing, the timeout branch, the returns-window guard. Nothing about this is AI-shaped, and it is where most of your real bugs will be.

Tool selection is the non-deterministic half, and you test it by asserting on which tools were called rather than on the exact wording of the reply. Given “where’s my stuff”, does the agent call GetOrderStatus? Given “this arrived broken”, does it reach for StartReturn, and does it ask for an order ID first when it doesn’t have one? A dozen cases like that, run against the model you actually deploy, catch description regressions that no unit test will. Keep them out of your fast suite — they cost money, they take seconds each, and they’re allowed to be flaky in a way your unit tests are not. Testing and evaluating agents covers building that harness properly.

Note: the Agent Framework is young and exact method names (CreateAIAgent, AIFunctionFactory.Create, GetNewThread) can shift between releases. Treat this as the shape of the flow — define tools, register them, run, thread state — and check the official samples against the version you install.

When a tool-using agent is the wrong shape

If the sequence of calls is known in advance, you don’t need a model to choose them. “Look up the order, check the returns window, create the return” is a method. Writing it as an agent buys you three model round-trips, a few cents, several seconds of latency, and a non-zero chance the model skips the middle step — in exchange for flexibility you weren’t going to use.

The agent shape earns its cost when the path is genuinely unknown at design time: when people ask in open-ended language, when the right next tool depends on what the previous one returned, and when the tool set changes faster than you want to change routing code. Below that bar, a switch statement is faster, cheaper, and far easier to test.

There is a useful middle ground. If your tools are shared across several agents, or owned by a team that isn’t yours, exposing them over a standard protocol instead of compiling them into this process keeps the boundary clean — the tool-calling loop is identical, only the transport changes.

What you built

In a few dozen lines you have an agent that understands intent, calls real C# functions, chains multiple tools in one turn, and remembers context across turns — with none of the tool-dispatch plumbing written by hand. From here, the natural next steps are connecting standardized tools over MCP, and deploying the agent so it runs somewhere other than your laptop.


Have a correction or a topic you want covered? Email mani.bc72@gmail.com.