This is the third post in a series on the .NET building blocks for AI. The first post covered Microsoft.Extensions.AI (MEAI), the unified interface for talking to language models. The second covered Microsoft.Extensions.VectorData, which handles storage and semantic search for RAG scenarios. This post moves to the Microsoft Agent Framework, which is where the pieces start acting instead of just answering.
MEAI gives you a common way to call a model. VectorData gives the model something to search. Neither one, on its own, gets you an AI system that can decide what to do next, call a function, check the result, and adjust course. That decision-making loop is what the Agent Framework adds, and it is worth understanding properly before you wire it into a real system.
What actually makes something an agent
A chatbot takes an input, sends it to a model, and returns whatever comes back. There is no loop and no autonomy. An agent is different: it can reason about a task, pick a tool from what is available to it, call that tool, look at the result, and decide whether it needs to do something else before responding. You are not hand-coding every branch of that decision tree.
A useful way to think about the difference: MEAI is like talking to a colleague directly. An agent is like handing that colleague a task and letting them figure out the steps, whether that means looking something up, running a calculation, or querying a database, using whatever tools you have given them access to.
The Agent Framework is Microsoft’s SDK for building these agents in .NET, and it also has a Python surface if you need it there. It reached its 1.0 release in April 2026 and covers everything from a single agent answering questions to multi-agent workflows connected as a graph. It is built directly on top of IChatClient, so if you already worked through MEAI in Part 1, most of this will feel familiar rather than new.
Creating your first agent
Start with a console app and add the package:
dotnet add package Microsoft.Agents.AI
With the package in place, turning a chat client into an agent takes one extension method call. Here is the minimum needed to get a working agent:
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME")
?? "gpt-5.4-mini";
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(
instructions: "You are good at telling jokes.",
name: "Joker");
Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate."));
The important call here is .AsAIAgent(). It plays the same role that .AsIChatClient() plays when bridging a provider SDK into MEAI, except it goes one step further and wraps the chat client into something that can manage sessions, tools, and memory on your behalf. It works the same way regardless of whether you are pointing at Azure OpenAI, OpenAI directly, GitHub Models, Microsoft Foundry, or a local model through Foundry Local or Ollama. Running this gives you a single response string back, printed to the console once the model finishes.
If you need output as it is generated rather than waiting for the full response, streaming is built in without any extra setup:
await foreach (var update in agent.RunStreamingAsync("Tell me a joke about a pirate."))
{
Console.Write(update);
}
This prints tokens as they arrive instead of buffering the whole response, which matters for anything with a UI where users expect to see text appear progressively. Under the hood it is the same agent and the same instructions, just a different method call.
Giving the agent tools to act with
An agent that only tells jokes is not doing anything a plain chat client could not do. Tools are what change that. A tool is just a regular method that the model can choose to call when it decides the request needs it. The Agent Framework reuses AIFunctionFactory from MEAI, so any tools you already defined for a chat client work here without modification.
Here is an agent wired up with a weather tool:
using System.ComponentModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
[Description("Get the weather for a given location.")]
static string GetWeather(
[Description("The location to get the weather for.")] string location)
=> $"The weather in {location} is cloudy with a high of 15\u00b0C.";
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(
instructions: "You are a helpful assistant",
tools: [AIFunctionFactory.Create(GetWeather)]);
Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?"));
When this runs, the model sees a weather question, notices it has a GetWeather tool available, calls it with “Amsterdam” as the location, and folds the returned string into its final answer. Nobody wrote an if statement checking whether the user asked about weather. That routing logic lives entirely in the model’s reasoning, guided by the tool description.
This is also where a common mistake shows up. The Description attributes on the method and its parameters are not documentation for your own team, they are the only information the model has about what the tool does and when to use it. A vague description like “gets data” gives the model almost nothing to reason with, and you will see it either avoid the tool entirely or call it at the wrong moment. Write these descriptions the way you would explain the tool to a new team member who has never seen your codebase.
Handling multi-turn conversations with sessions
A single question and answer is rarely how real usage looks. Users ask follow-up questions and expect the agent to remember what was just discussed. AgentSession handles this:
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine(
await agent.RunAsync("Tell me a joke about a pirate.", session));
Console.WriteLine(
await agent.RunAsync(
"Now add some emojis to the joke and tell it in the voice of a pirate's parrot.",
session));
Passing the same session into both calls means the second request, which only makes sense in context, resolves correctly. The agent knows exactly which joke to modify because the session is carrying the conversation history between calls rather than treating each RunAsync call as isolated.
The detail that matters for production use is that sessions can be serialized and restored:
// Save the session state
JsonElement sessionState = await agent.SerializeSessionAsync(session);
// Later, restore it
var restoredSession = await agent.DeserializeSessionAsync(sessionState);
Console.WriteLine(
await agent.RunAsync("What were we just talking about?", restoredSession));
This is what makes the pattern usable in a real web API, where you cannot assume the same process instance handles every request from a given user. Serialize the session to your database or cache after each turn, load it back on the next request, and the conversation continues as if nothing happened in between. Skipping this step is a common reason agent-based chat features work fine in a demo and then lose context in production the moment there is more than one server instance behind a load balancer.
Adding memory that survives across sessions
Sessions solve conversation history within one interaction. They do not solve the separate problem of an agent remembering something about a user across entirely different sessions, such as their name or a stated preference from last week. That is what AIContextProvider is for, and it is a more involved piece than the earlier examples.
internal sealed class UserInfoMemory : AIContextProvider
{
private readonly ProviderSessionState<UserInfo> _sessionState;
private readonly IChatClient _chatClient;
public UserInfoMemory(IChatClient chatClient)
{
_sessionState = new ProviderSessionState<UserInfo>(
_ => new UserInfo(),
GetType().Name);
_chatClient = chatClient;
}
protected override async ValueTask StoreAIContextAsync(
InvokedContext context,
CancellationToken cancellationToken = default)
{
var userInfo = _sessionState.GetOrInitializeState(context.Session);
if (userInfo.UserName is null
&& context.RequestMessages.Any(x => x.Role == ChatRole.User))
{
var result = await _chatClient.GetResponseAsync<UserInfo>(
context.RequestMessages,
new ChatOptions()
{
Instructions =
"Extract the user's name from the message if present."
},
cancellationToken: cancellationToken);
userInfo.UserName ??= result.Result.UserName;
}
_sessionState.SaveState(context.Session, userInfo);
}
protected override ValueTask<AIContext> ProvideAIContextAsync(
InvokingContext context,
CancellationToken cancellationToken = default)
{
var userInfo = _sessionState.GetOrInitializeState(context.Session);
var instructions = userInfo.UserName is null
? "Ask the user for their name."
: $"The user's name is {userInfo.UserName}.";
return new ValueTask<AIContext>(
new AIContext { Instructions = instructions });
}
}
There are two methods doing the actual work here, and it helps to keep them separate in your head. StoreAIContextAsync runs after each interaction and is where the provider learns something, in this case running a small extraction call to pull the user’s name out of the message if it was not already known. ProvideAIContextAsync runs before each interaction and injects whatever has been learned so far as extra instructions, either supplying the user’s name or telling the agent to ask for it.
Wiring the provider into an agent is a one-line addition to the options object:
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = "You are a friendly assistant. Always address the user by their name."
},
AIContextProviders = [new UserInfoMemory(chatClient.AsIChatClient())]
});
Once this is in place, the agent will greet a returning user by name without you writing any explicit lookup code in the conversation handler itself. The pattern is deliberately composable. You can register more than one AIContextProvider on the same agent, for example one tracking user preferences and another pulling relevant documents from a VectorData store, and each one contributes its own slice of context independently. Worth noting on the production side: that extraction call inside StoreAIContextAsync is an extra model call on every turn, so factor that latency and cost in before assuming memory is free.
Orchestrating multiple agents with workflows
A single agent covers a lot of ground, but plenty of real problems are cleaner when split across specialized agents. The Agent Framework’s workflow system models this as a graph: executors are the processing units, and edges define how data moves between them. Here is the simplest possible version, chaining two plain functions rather than agents, just to see the shape of it:
using Microsoft.Agents.AI.Workflows;
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
var reverse = new ReverseTextExecutor();
WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
var workflow = builder.Build();
await using Run run = await InProcessExecution.RunAsync(
workflow, "Hello, World!");
foreach (WorkflowEvent evt in run.NewEvents)
{
if (evt is ExecutorCompletedEvent executorComplete)
{
Console.WriteLine(
$"{executorComplete.ExecutorId}: {executorComplete.Data}");
}
}
Running this feeds “Hello, World!” into the uppercase executor, passes its output along the edge to the reverse executor, and prints each executor’s result as it completes. It is a text-processing toy on purpose, so the workflow mechanics are visible without an LLM call muddying the output. Swap the executors for agents and the same graph structure carries real orchestration logic between them.
The framework supports several orchestration shapes beyond a simple chain, and picking the right one matters for both cost and reliability:
- Sequential workflows, where each agent’s output feeds directly into the next agent in line.
- Concurrent workflows, which fan out to several agents in parallel and then fan the results back in.
- Conditional routing, sometimes called hand-off, which sends work to different agents depending on what a previous step produced.
- Feedback loops, such as a writer-critic pattern, where one agent produces work and another evaluates it, looping until the output meets a bar.
- Sub-workflows, which let you nest one workflow inside another so complex graphs stay composable rather than turning into one giant flowchart.
The writer-critic pattern in practice
Of these patterns, writer-critic is probably the one you will reach for most often once you move past single-agent scenarios. Picture one agent drafting marketing copy and a second agent reviewing it before it ships:
WorkflowBuilder builder = new(writerAgent);
builder
.AddEdge(writerAgent, criticAgent)
.AddEdge(criticAgent, writerAgent, condition: result => !result.IsApproved)
.WithOutputFrom(criticAgent, condition: result => result.IsApproved);
var workflow = builder.Build();
The writer produces a draft and hands it to the critic. If the critic rejects it, the edge back to the writer fires and a revised draft gets produced, repeating until the critic approves. Only an approved result reaches the workflow’s output. This is a genuinely useful pattern for anything where quality matters more than a single pass, such as generated content that needs a tone or compliance check before a human ever sees it.
The trade-off worth calling out, and one the original write-up flags too, is that nothing here stops the loop from running indefinitely if the critic never approves. In a demo that is a minor annoyance. In production it is a cost problem, since every iteration is another model call on both sides of the loop. Set a maximum iteration count and decide up front what happens when that limit is hit, whether that means escalating to a human, returning the best draft so far, or failing the request outright.
Human-in-the-loop for sensitive actions
Autonomy is useful, but there are actions you do not want an agent taking without a person signing off first, database writes, financial transactions, or sending a message to a customer being the obvious examples. The Agent Framework supports this through tool approval: the agent proposes a tool call and pauses instead of executing it, waiting for approval before it proceeds.
The mechanism runs on two content types, FunctionApprovalRequestContent and FunctionApprovalResponseContent, both part of the MEAI content model introduced in Part 1 of this series. When a tool call needs approval, the agent yields a request instead of just calling it. Your application code is responsible for surfacing that request to a human, in whatever interface makes sense, and the response that comes back determines whether the call goes ahead. Nothing here forces a specific UI, so the actual approval screen is entirely up to you, this is just the plumbing that pauses execution and resumes it correctly once a decision is made.
How the three building blocks fit together
Looking back across all three posts in the series, the pieces compose cleanly rather than competing with each other:
- MEAI, through IChatClient, is the universal interface for talking to any model provider.
- VectorData adds RAG patterns, letting agents search an organization’s own knowledge base and ground responses in real data instead of just model training.
- Agent Framework ties it together, since agents use IChatClient underneath, can pull in vector search through context providers, and coordinate through workflows when a single agent is not enough.
A concrete example of that composition: an AIContextProvider that queries a VectorData store before every agent invocation and feeds the retrieved documents back in as context. That is the RAG pattern from Part 2, except now it runs automatically as a standing part of every turn rather than something you wire up manually each time.
Wrapping up
The Agent Framework takes the foundation from Parts 1 and 2 and turns it into something that can act on its own, use tools, hold onto context across turns and sessions, and coordinate with other agents when the task calls for it. Across this post that meant creating agents with AsAIAgent() and RunAsync(), equipping them with tools through AIFunctionFactory, managing multi-turn state with AgentSession, building durable memory with AIContextProvider, orchestrating multiple agents through workflows including the writer-critic loop, and pausing for human approval before sensitive tool calls.
The next post in the series covers the Model Context Protocol, which standardizes how agents discover and use tools and resources outside their own codebase, making agents built with different frameworks interoperable with each other.
Leave a Reply