Creating an AI Agent in .NET with Microsoft Agent Framework
An AI agent in an enterprise .NET solution is not a chatbot bolted onto a controller. It is a model that can call your code. The agent receives a question, decides which C# method it needs, calls it, reads the result and writes the answer. The methods it may call are the ones you hand it, they go through your application layer like every other caller, and the model itself runs wherever you decide, including on your own machine. The framework part is small. The part that matters is where the agent sits in the solution and what you let it touch.
The Loop From HTTP request to a tool call and back
The Place What each project holds
DotnetLeet.Api project
Holds SupportController, it receives the message, hands it to ICustomerSupportAgent and returns the answer.
// DotnetLeet.Api/Controllers/SupportController.cs
[ApiController]
[Route("api/support")]
public sealed class SupportController(ICustomerSupportAgent agent) : ControllerBase
{
[HttpPost("ask")]
public async Task<IActionResult> Ask(string message, CancellationToken ct)
=> Ok(await agent.AskAsync(message, ct));
}
DotnetLeet.Application project
Holds ICustomerSupportAgent, one method, and the only AI type the rest of the solution sees.
// DotnetLeet.Application/AI/Agents/ICustomerSupportAgent.cs
public interface ICustomerSupportAgent
{
Task<string> AskAsync(string message, CancellationToken ct = default);
}
DotnetLeet.Infrastructure project
Holds everything that touches the framework and the model:
AgentFactorybuilds the agentCustomerSupportAgentimplementsICustomerSupportAgentOrderToolsholds the methods the model may callOrderRepositoryreads orders with EF Core
// DotnetLeet.Infrastructure/AI/Agents/CustomerSupportAgent.cs
internal sealed class CustomerSupportAgent(AgentFactory factory) : ICustomerSupportAgent
{
// AIAgent is the Microsoft Agent Framework type: it owns the instructions, the tools and the loop
// The factory builds it over an IChatClient (OllamaApiClient here). Swap the client, keep the agent
private readonly AIAgent _agent = factory.CreateCustomerSupportAgent();
public async Task<string> AskAsync(string message, CancellationToken ct = default)
{
var response = await _agent.RunAsync(message, cancellationToken: ct);
return response.Text;
}
}
Do not forget the two NuGet packages, they go into this project and nowhere else:
Microsoft.Agents.AIis the Microsoft Agent FrameworkOllamaSharpis the chat client for the local model
DotnetLeet.Domain project
Holds Order and IOrderRepository, no AI reference, no agent
// DotnetLeet.Domain/Orders
public sealed class Order
{
public int Id { get; private set; }
public DateTime OrderDate { get; private set; }
public DateTime? ShippedDate { get; private set; }
}
public interface IOrderRepository
{
Task<string> GetOrderStatusAsync(int orderId, CancellationToken ct = default);
Task<string> GetOrderDeliveryDateAsync(int orderId, CancellationToken ct = default);
}
The Agent Four parameters turn a chat client into an agent
OllamaApiClient is a chat client, an IChatClient from Microsoft.Extensions.AI: prompt in, text out.
AsAIAgent from the Microsoft Agent Framework wraps any IChatClient in an AIAgent, the type that owns the instructions, the tools and the loop.
Ollama only serves the model, the factory below is the whole setup.
// DotnetLeet.Infrastructure/AI/Agents/AgentFactory.cs
public sealed class AgentFactory(IOptions<OllamaOptions> options, OrderTools orderTools)
{
public AIAgent CreateCustomerSupportAgent()
{
// config params
var ollama = options.Value;
// IChatClient. Any provider works here: OpenAI, Azure OpenAI, Anthropic, Google Gemini, etc
var client = new OllamaApiClient(new Uri(ollama.Endpoint), ollama.Model);
// Microsoft Agent Framework: wraps the chat client in an AIAgent
return client.AsAIAgent(
name: "CustomerSupportAgent",
description: "Answers customer questions about their orders.",
instructions:
"""
You are a customer support agent for an online shop.
Use the available tools whenever you need information about an order.
Never invent order information. If a tool says the order was not found, say so.
""",
tools:
[
AIFunctionFactory.Create(orderTools.GetOrderStatusAsync),
AIFunctionFactory.Create(orderTools.GetEstimatedDeliveryDateAsync)
]);
}
}
What each parameter does:
- name identifies the agent in logs and traces.
- description says what the agent is for, with several agents, a model reads it to pick the right one.
- instructions is the system prompt: the role, the scope, and the rules, "Never invent order information" is the most important line.
- tools is the list of C# methods the model may ask for, without it, the agent is a chat client with a name.
In this example the agent is built over an Ollama chat client, the agent is the framework's, the model is Ollama's
The Tools A description is an API the model reads
A tool is a C# method the agent is allowed to run when the model asks for it.
AIFunctionFactory.Create reads its name, its parameters and the Description attributes and shows them to the model as a schema.
The body calls the repository like any other code.
// DotnetLeet.Infrastructure/AI/Tools/OrderTools.cs
public sealed class OrderTools(IOrderRepository orders)
{
[Description("Tells whether an order was found, is still pending, or has shipped and when.")]
public Task<string> GetOrderStatusAsync(
[Description("The numeric order id the customer is asking about.")] int orderId,
CancellationToken ct)
=> orders.GetOrderStatusAsync(orderId, ct);
[Description("Gets the estimated or actual delivery date of an order.")]
public Task<string> GetEstimatedDeliveryDateAsync(
[Description("The numeric order id the customer is asking about.")] int orderId,
CancellationToken ct)
=> orders.GetOrderDeliveryDateAsync(orderId, ct);
}
What the tools actually return
Behind the two tool methods is one repository. It reads a single column, decides what the row means, and returns a sentence the model can quote. No entity leaves this class.
// DotnetLeet.Infrastructure/Orders/OrderRepository.cs
public sealed class OrderRepository(IApplicationDbContext db) : IOrderRepository
{
private const int TransitDays = 3;
public async Task<string> GetOrderStatusAsync(int orderId, CancellationToken ct = default)
{
var order = await db.Orders
.Where(o => o.Id == orderId)
.Select(o => new { o.ShippedDate })
.FirstOrDefaultAsync(ct);
return order switch
{
null => $"Order {orderId} was not found.",
{ ShippedDate: null } => $"Order {orderId} is pending. It has not shipped yet.",
_ => $"Order {orderId} shipped on {order.ShippedDate:yyyy-MM-dd}."
};
}
public async Task<string> GetOrderDeliveryDateAsync(int orderId, CancellationToken ct = default)
{
var order = await db.Orders
.Where(o => o.Id == orderId)
.Select(o => new { o.ShippedDate })
.FirstOrDefaultAsync(ct);
if (order is null)
return $"Order {orderId} was not found.";
if (order.ShippedDate is null)
return $"Order {orderId} has no delivery date yet. It has not shipped.";
var delivery = order.ShippedDate.Value.AddDays(TransitDays);
return delivery < DateTime.UtcNow
? $"Order {orderId} was delivered on {delivery:yyyy-MM-dd}."
: $"Order {orderId} is expected on {delivery:yyyy-MM-dd}.";
}
}
Three rows in the orders table, three questions, and what the customer gets back. The middle column is what the tools return. The last column is what the agent says.
| Customer asks | Row in Orders | Tools return | Agent replies |
|---|---|---|---|
| "What about my order 100?" | Id 100, ShippedDate 2026-09-20 | Order 100 shipped on 2026-09-20. Order 100 was delivered on 2026-09-23. |
Good news: your order 100 shipped on 20 September and was delivered on 23 September. If it has not arrived, let me know and I will look into it. |
| "Is order 101 on its way?" | Id 101, ShippedDate null | Order 101 is pending. It has not shipped yet. Order 101 has no delivery date yet. It has not shipped. |
Not yet. Order 101 is still being prepared and has not shipped, so there is no delivery date at the moment. I can check again once it ships. |
| "Where is order 102?" | no row | Order 102 was not found. | I checked, but I could not find an order with the number 102 in our system. Could you double check the number on your confirmation email? |
Compare the last two columns. Every fact in the reply came from a tool. What the model added:
- The choice of tools. "What about my order 100" said nothing about delivery, the model called both tools because the description of the second one fit the question.
- One answer from two results, two sentences from two methods became one reply.
- Tone and the next step, "Good news", "not yet", the apology for the missing order, the suggestion to check the confirmation email, none of that is in the repository.
What the model did not add is a fact. No invented date, no guessed status, because the instructions say so and the tools give it nothing to guess with. That split is the whole design: the repository owns the truth, the model owns the conversation.
The Model Runs locally in Ollama
I run llama3.2 locally, served by Ollama in a container. No API key, no data leaving the network, and it works offline.
The agent does not care where the model runs: the framework works with any chat client, so the same factory can point at OpenAI, Azure OpenAI, Azure AI Foundry, Anthropic or Google Gemini by swapping the client on one line.
The endpoint and the model name are configuration, bound to one options record.
public sealed record OllamaOptions(string Endpoint, string Model);
The Wiring Four registrations
Everything above is registered in Infrastructure, next to the repository and the
DbContext.
// DotnetLeet.Infrastructure/DependencyInjection.cs
services.Configure<OllamaOptions>(config.GetSection("Ollama"));
services.AddScoped<OrderTools>();
services.AddScoped<AgentFactory>();
services.AddScoped<ICustomerSupportAgent, CustomerSupportAgent>();
Scoped, not singleton: the tools hold a repository, the repository holds a
DbContext, and a DbContext lives for one request. Building the
agent per request costs nothing next to a model call.
The Benefit What the agent replaced
Think about what "where is my order" cost before. Either a support person reading the mail and clicking through an admin screen, or a self-service feature: a panel of canned questions on the frontend, a form, an endpoint per question, a DTO per endpoint, validation, and a repository call at the end. Every new question the customers came up with was a new feature, end to end, from the button to the database.
| Before | With the agent | |
|---|---|---|
| Understanding what the customer wants | A form, a dropdown of questions, or text parsing rules | The model reads the sentence |
| One question that needs two answers | A third endpoint, or two round trips and glue code | Two tool calls, one reply |
| A new kind of question | Button, form, endpoint, DTO, handler, release | One C# method with a description |
| Tone, apology, next step, language | Hard-coded strings, per language | The model, from the tool result |
| The facts | The repository | The same repository |
The last row is the one that makes this safe. The agent did not replace the business layer. It replaced the interaction layer: the forms, the endpoints per question and the strings. The code that knows what an order is did not change, it only got a new caller. That is why the whole thing fits in one interface, one factory and one tools class, and why adding the next question is ten minutes of work and not half a sprint.