Mert Özen Further With Every Line
Artificial Intelligence AI with .NET — #11

Getting Started with Microsoft Agent Framework: From IChatClient to AIAgent

Mert Özen Nov 8, 2026 9 min 7 views
Getting Started with Microsoft Agent Framework: From IChatClient to AIAgent

AsAIAgent turns an IChatClient into a ChatClientAgent; the agent takes over instructions, function invocation and chat history through AgentSession, but it doesn't make the model smarter. We write the same Bookstore assistant first with a plain IChatClient and then as an agent, and compare RunAsync, RunStreamingAsync and AgentResponse with real outputs.

Version note: The code in this post was built and run with .NET 10, Microsoft.Agents.AI 1.28.0, Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2 and Ollama 0.34.4 (qwen3:4b).

Guess First

In this series the Bookstore assistant grew as an IChatClient pipeline: function invocation, telemetry, caching, rate limiting. Now we will write the same assistant as an "agent" with Microsoft Agent Framework (MAF). What do you think changes? Does the model get smarter? Does it discover tools on its own? Does it keep chat history permanently?

Write your guess down. We will ask the same two questions first with a plain IChatClient, then as an agent, and see the difference line by line. The answer is more modest than most people expect. That doesn't make the agent worthless. You need to know exactly what it solves.

What an Agent Is, and What It Isn't

The MAF documentation defines an agent as a component that uses an LLM (Large Language Model) to process input, call tools and generate responses. The core .NET type is AIAgent. The implementation you will use most is ChatClientAgent: a class that wraps any IChatClient and turns it into an agent. So MAF doesn't replace the Microsoft.Extensions.AI (MEAI) layer we built in this series; it sits on top of it.

The "agent or workflow?" table in the documentation also draws the boundary of this post. Use an agent when the task is open-ended or conversational, when the model should pick and plan tools on its own, and when a single LLM call (with tools) is enough. Use a workflow when the steps are known up front, when you want to control execution order yourself, or when several agents and functions must coordinate. Below the table there is a one-sentence rule: if you can write a function to handle the task, write the function instead of an agent. "What's the status of order 1042?" can also be answered by a form and a database query. The agent makes sense when users ask it in free language, with follow-up questions.

Agent patterns, multi-agent workflows and MCP (Model Context Protocol) belong to the upcoming AI (Artificial Intelligence) Agents and MCP series. This post focuses on the core .NET API (Application Programming Interface) of a single agent. There are provider-specific agent types too: for example, AsAIAgent overloads in Foundry that fetch an agent registered on the server side. But all of them implement the same AIAgent abstraction; the code you write in this post stays the same whatever service sits behind the agent.

Starting Point: A Plain IChatClient

First the familiar route. The tool class also counts its calls:

public sealed class OrderTools
{
    public int Calls { get; private set; }

    [Description("Returns the shipping status of a Bookstore order.")]
    public string GetOrderStatus([Description("The order number")] int orderId)
    {
        Calls++;
        return orderId == 1042 ? "Shipped on 2026-11-06, 2 items, tracking BKS-1042." : "Order not found.";
    }
}

Program.cs contains both modes; first the shared part and the chat mode:

using Bookstore.Assistant;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OllamaSharp;

const string Instructions =
    "You are the Bookstore assistant. Use tools for order questions. Answer in one short sentence.";

// On a shared local Ollama a response can take longer than 100 seconds (the HttpClient default)
IChatClient ollama = new OllamaApiClient(
    new HttpClient { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(10) },
    "qwen3:4b");
OrderTools tools = new();
AITool[] toolList = [AIFunctionFactory.Create(tools.GetOrderStatus)];

string mode = args.Length > 0 ? args[0] : "agent";

if (mode == "chat")
{
    // Plain IChatClient: history, system prompt and the tool loop are your responsibility
    IChatClient chat = ollama.AsBuilder().UseFunctionInvocation().Build();
    List<ChatMessage> history = [new(ChatRole.System, Instructions)];
    ChatOptions options = new() { Temperature = 0, Seed = 42, Tools = toolList };

    foreach (string question in new[] { "Where is my order 1042?", "How many items were in it?" })
    {
        history.Add(new(ChatRole.User, question));
        ChatResponse response = await chat.GetResponseAsync(history, options);
        history.AddMessages(response);
        Console.WriteLine($"> {question}\n{response.Text}");
    }
    Console.WriteLine($"history: {history.Count} messages, tool calls: {tools.Calls}");
}
dotnet run -- chat
> Where is my order 1042?
Your order 1042 is shipped on 2026-11-06 with 2 items and tracking number BKS-1042.
> How many items were in it?
Your order 1042 contained 2 items.
history: 7 messages, tool calls: 1

It works. The second question says "it" and the model knows that means 1042, because we carried the history. The list has 7 messages: system, first question, tool request, tool result, first answer, second question, second answer. Three jobs in this code are ours: putting the system prompt at the start of the list, adding function invocation to the pipeline and updating the history every turn. In this series' "Persisting Chat History" post we stored this list with Entity Framework Core (EF Core).

The Same Assistant as an AIAgent

The program's agent mode:

else
{
    AIAgent agent = ollama.AsAIAgent(
        new ChatClientAgentOptions
        {
            Name = "BookstoreAssistant",
            ChatOptions = new() { Instructions = Instructions, Temperature = 0, Seed = 42, Tools = toolList },
        });

    AgentSession session = await agent.CreateSessionAsync();

    AgentResponse first = await agent.RunAsync("Where is my order 1042?", session);
    Console.WriteLine($"> Where is my order 1042?\n{first.Text}");
    Console.WriteLine($"  messages in response: {first.Messages.Count}, tokens: {first.Usage?.InputTokenCount}/{first.Usage?.OutputTokenCount}");

    Console.WriteLine("> How many items were in it?");
    await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("How many items were in it?", session))
    {
        Console.Write(update.Text);
    }
    Console.WriteLine($"\ntool calls: {tools.Calls}");
}
dotnet run -- agent
> Where is my order 1042?
Your order 1042 is shipped on 2026-11-06 with 2 items and tracking number BKS-1042.
  messages in response: 3, tokens: 613/495
> How many items were in it?
Your order 1042 contained 2 items.
tool calls: 1

The answers are the same. AsAIAgent is an extension method on IChatClient and returns a ChatClientAgent. Here we used the overload that takes options because we also wanted to set temperature and seed. If you only needed instructions and tools, the ollama.AsAIAgent(instructions: ..., name: ..., tools: ...) overload would do. The instructions are no longer a system message but ChatOptions.Instructions. And we didn't add function invocation ourselves.

What ChatClientAgent Adds, and What It Doesn't

Putting the two versions side by side makes the agent's jobs clear:

  • Function invocation: the XML (Extensible Markup Language) documentation of ChatClientAgent says that if the given IChatClient doesn't already contain a FunctionInvokingChatClient, the agent inserts one itself. We passed ollama bare and the tool still ran.
  • History: there is no history list. AgentSession holds the conversation. We gave the second RunStreamingAsync call only the new question.
  • Instructions and identity: we don't add the system prompt every turn. The agent has a Name; telemetry and hosting use it.
  • A single response object: RunAsync returns an AgentResponse. It contains the 3 messages produced in this turn: tool request, tool result and the final answer. We read the text from Text and token counts from Usage. The streaming counterpart is AgentResponseUpdate.

What it doesn't add matters just as much. The model is the same model; qwen3:4b doesn't reason better because it's an agent. You provide the tools; the agent discovers nothing. There is no planning or multi-step goal tracking either: this is just a tool loop, the same loop as plain IChatClient + UseFunctionInvocation. And by default the session lives in memory; when the app stops, the conversation is gone. The answer to the opening questions "does it get smarter, does it keep history permanently?" is no. An agent is an abstraction layer. Its real value lies in what is built on top of it: middleware, context providers, session persistence, hosting and workflows. We cover these in the next two posts of the series.

AgentSession: Multi-Turn Conversations

It's no accident that CreateSessionAsync is a method of the agent. A session's content depends on the agent type. For ChatClientAgent the session holds the state of a chat history provider that keeps messages. With a stateful provider (for example a service that keeps conversations on the server side) it may carry only a conversation id. The calling code doesn't need to know the difference. In this series' "Persisting Chat History" post we handled the stateless/stateful split by hand with ConversationId; AgentSession abstracts the same split at the agent level.

Exporting a session as JSON (JavaScript Object Notation) (SerializeSessionAsync) and restoring it (DeserializeSessionAsync) are also methods on the agent. In a web application you load the session from the database on every request and write it back when the work is done. We'll do that in the next post, together with context providers.

RunStreamingAsync is the agent counterpart of the IAsyncEnumerable pattern from the LLM Fundamentals series post "Streaming LLM Responses". Each update can carry a text chunk, a tool call or usage information. update.Text joins only the text chunks.

Packages and Version Lines

We added just one package: Microsoft.Agents.AI 1.28.0. It depends on MEAI 10.11 and Microsoft.Agents.AI.Abstractions. MAF 1.0 became GA (General Availability) on April 3, 2026. Since then the core package has been released at a fast pace: five minor versions in six weeks, from 1.23 at the end of September to 1.28 on November 6. The packages around the core are at different maturity levels. The latest versions on NuGet (November 8, 2026):

Microsoft.Agents.AI                         1.28.0
Microsoft.Agents.AI.OpenAI                  1.28.0
Microsoft.Agents.AI.Harness                 1.28.0
Microsoft.Agents.AI.Foundry                 1.28.0-preview.261106.1  (latest stable: 1.5.0)
Microsoft.Agents.AI.Hosting                 1.28.0-preview.261106.1
Microsoft.Agents.AI.Hosting.OpenAI          1.28.0-alpha.261106.1

The Foundry provider's situation is especially misleading: the stable line is at 1.5.0 while the preview line has reached 1.28 together with the core. The first sample on MAF's overview page also installs this package with --prerelease. We didn't work with cloud providers in this post; the code ran against a local Ollama. But when you add Foundry or one of the hosting packages, make sure all MAF packages in your project are on the same version number.

In Production

An agent gains you nothing on its own, but it can lose you telemetry. Add the UseOpenTelemetry pipeline from this series' "Observing LLM Calls" post to the IChatClient and give that client to the agent. Check in the trace whether the function invocation layer the agent adds ends up outside telemetry.

The agent object is long-lived, the session is short-lived. Create the AIAgent once per application (a singleton in DI). Create the session per user and conversation, or load it from wherever you store it. If your tools need scoped services (a DbContext, for example), resolve the tool from DI as in this series' tool calling post.

Measure durations. The two-question agent run in this post took 4 minutes 19 seconds on a shared local Ollama, the chat mode with the same questions 2 minutes 26 seconds. The difference comes from the server's queue at the time, not from the agent. For the user the result is the same though: design timeouts, cancellation tokens and streaming accordingly.

Common Pitfalls

The first pitfall is creating a new session on every request. If a Web API endpoint calls CreateSessionAsync on every HTTP (Hypertext Transfer Protocol) request, the model starts with an empty conversation every time and "How many items were in it?" makes no sense. Either leave the session to the hosting layer's session store or serialize and store it yourself.

The second is mixing prerelease provider packages with the stable core. Put Microsoft.Agents.AI 1.28.0 and a 1.26 preview of a provider into the same project and the build may pass while you see unexpected behavior at runtime. Pin versions together with central package management (Directory.Packages.props).

The third is HttpClient's default 100-second timeout. In this post's first attempt the chat mode failed on the second question for exactly this reason:

Unhandled exception. System.Threading.Tasks.TaskCanceledException: The request was canceled due to the configured HttpClient.Timeout of 100 seconds elapsing.

A thinking model and a busy server together can easily fill 100 seconds. That's why the HttpClient in the code is created with a 10-minute timeout. In production, prefer streaming and cancellation tokens over long timeouts.

The last pitfall is making everything an agent. Remember the rule from the documentation: if a function can solve it, write a function. Every agent turn means one or more model calls, which means latency and token cost.

A Small Experiment

★☆☆ In agent mode, ask the second question with a new session (await agent.CreateSessionAsync()). What does the model take "it" to mean? Does it call the tool again?

★★☆ Print the messages in AgentResponse.Messages with their roles and content types. Which content types do the tool request and tool result use? Where in these messages is qwen3:4b's reasoning?

★★★ Add UseOpenTelemetry() to ollama, create the agent with that client and inspect the trace with the console exporter. Do the orchestrate_tools and execute_tool spans show up? How does the trace change when you set ChatClientAgentOptions.UseProvidedChatClientAsIs to true?

That's the bare agent. In the next post we add the parts that give an agent its real character: agent run and function invocation middleware, a context provider that remembers a user preference, and saving and restoring the session as JSON.