Microsoft Agent Framework: your first C# agent
Summarize with AI
The assistants open a new chat that asks for a short summary of this post.
The idea: an agent puts one layer between your code and the model provider, so changing provider means changing the statement that creates the chat client, and the code after it stays the same.
Calling a language model from C# is easy with a provider’s SDK. The trouble starts when the call needs instructions, a conversation and tools, and all of it is written against that one SDK. This post builds a kitchen assistant on Anthropic’s Claude Haiku 5.5: a console app that checks why each answer stopped, streams the text, and then moves to another provider.
Who this is for: C# developers who are new to agents.
Versions: Microsoft Agent Framework 1.24.0, .NET 10.
Code: samples/agent-framework/01-first-agent, a runnable version of this post with its tests.
Install the agent package and the Anthropic connector
dotnet new console -n FirstAgentcd FirstAgentdotnet add package Microsoft.Agents.AI --version 1.24.0dotnet add package Microsoft.Agents.AI.Anthropic --version 1.24.0Microsoft.Agents.AI holds the agent types. Microsoft.Agents.AI.Anthropic brings in Anthropic’s own .NET SDK (the Anthropic package), and the AsIChatClient the code below uses comes from that SDK, which puts it in the Microsoft.Extensions.AI namespace. The connector itself adds a one-call AsAIAgent on the Anthropic client that takes the model, instructions and name together and defaults to 4096 output tokens. Both are stable releases.
Pick the provider in one statement
using Anthropic;using Anthropic.Core;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");
IChatClient chatClient = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsIChatClient("claude-haiku-5-5", 1024);
AIAgent agent = chatClient.AsAIAgent( name: "kitchen-assistant", instructions: """ You help one household decide what to cook. Answer in at most three sentences. """);
AgentResponse response = await agent.RunAsync( "What can I cook tonight with rice, eggs and an onion?");
Console.WriteLine(response.Text);export ANTHROPIC_API_KEY=<your key>dotnet runFried rice is the easiest pick: sauté the diced onion, scramble in the eggs, then stir in the cooked rice and season with salt, pepper, or soy sauce if you have it. For a simpler option, make an onion and egg fried rice or an onion frittata served alongside plain rice.AnthropicClientis Anthropic’s SDK. It holds the API key and makes the HTTP calls.AsIChatClientwraps it inIChatClient, the chat abstraction fromMicrosoft.Extensions.AIthat the wider .NET ecosystem builds on. The first argument is the default model ID, the second the default maximum number of output tokens. A single request can override either throughChatOptions.AsAIAgentwraps the chat client in aChatClientAgent, the framework’s general-purpose agent, which the code holds as the abstractAIAgent.
Only the chatClient statement knows about Anthropic. The instructions go out as the system prompt on every request. The model behind them is the same one anyone else calls; what makes it a kitchen assistant that answers in three sentences is those two lines.
Beyond the instructions, the agent adds two things the bare IChatClient lacks. It can keep a conversation in a session, and it runs the tool-calling loop: unless the chat client already has one, ChatClientAgent adds a FunctionInvokingChatClient, which runs the tools the model asks for and sends their results back.
Name the agent from the first line
Leave name out and the agent’s only identifier is its Id, a GUID generated when the agent is created. That GUID is what logs and traces then show, and it changes on every start. With a name, traces record kitchen-assistant as gen_ai.agent.name.
Check the finish reason, not just the text
RunAsync returns an AgentResponse. Its ToString() returns the text, so Console.WriteLine(await agent.RunAsync(...)) works, but the rest of the object is what belongs in logs:
| Property | What it holds |
|---|---|
Text |
The text of the response messages, joined |
Messages |
Every message the run produced; once there are tools, the calls and results too |
Usage |
Token counts: InputTokenCount, OutputTokenCount, TotalTokenCount |
FinishReason |
Why the model stopped |
ResponseId, AgentId, CreatedAt |
Identifiers and timing for correlating logs |
Add a usage line and a finish-reason check after the Console.WriteLine:
Console.WriteLine( $"[{response.Usage?.InputTokenCount} in, " + $"{response.Usage?.OutputTokenCount} out, " + $"finish: {response.FinishReason}]");
if (response.FinishReason == ChatFinishReason.Length){ Console.WriteLine("The answer stopped at the output token limit.");}Make egg fried rice: sauté the chopped onion in a little oil, add cold cooked rice and stir-fry until heated through, then push it aside, scramble the beaten eggs in the pan, and mix everything together. Season with salt, pepper, or soy sauce if you have it.[52 in, 94 out, finish: stop]The token counts are what the request cost. The input count includes the instructions, which are sent again on every call.
FinishReason is the one to check from the first agent on. The Anthropic client translates the API’s stop_reason into the provider-neutral ChatFinishReason:
Anthropic stop_reason |
ChatFinishReason |
|---|---|
end_turn, stop_sequence |
Stop |
max_tokens, model_context_window_exceeded |
Length |
tool_use |
ToolCalls |
refusal |
ContentFilter |
Any other value, such as pause_turn, comes through unchanged in FinishReason.Value. With a limit of 1,024 output tokens, a long answer stops mid-sentence and reports Length. response.Text still contains whatever text was produced before the limit, and it looks like any other string. Code that never compares the finish reason with ChatFinishReason.Length shows a truncated recipe to the user as if it were complete. A person reading it sees an answer that just ends, with no sign that anything is missing, so the app has to decide what to show instead.
Stream the answer as it arrives
A complete answer can take a few seconds, which a person watching a chat window notices. RunStreamingAsync yields AgentResponseUpdates as the model produces output:
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( "Suggest one dessert that uses eggs.")){ Console.Write(update.Text); updates.Add(update);}Console.WriteLine();
AgentResponse streamed = updates.ToAgentResponse();Console.WriteLine($"[{streamed.Usage?.OutputTokenCount} out]");Try a classic crème brûlée: whisk egg yolks with cream, sugar, and vanilla, bake gently in a water bath until just set, then chill and torch a layer of sugar on top for a crisp, caramelized crust.[77 out]In this run the sentence arrived in at least 15 fragments over about a third of a second, split mid-word: Try a classic cr, then ème brûlée: wh, then isk egg yolks with, and so on.
Each update carries a fragment, so it is printed with Console.Write. ToAgentResponse() combines the kept updates into one AgentResponse, with the messages rebuilt and the usage the stream reported, which is what logging needs. When nothing has to appear on screen, ToAgentResponseAsync() consumes the stream directly and returns the same combined response.
Swap the provider by changing one statement
The agent depends on an IChatClient, not on Anthropic, so only the code that creates chatClient changes: that statement, its using lines and its settings. An endpoint that speaks OpenAI’s Chat Completions API can be reached with the OpenAI SDK, which Microsoft.Agents.AI.OpenAI brings in:
dotnet add package Microsoft.Agents.AI.OpenAI --version 1.24.0using Anthropic;using Anthropic.Core;using System.ClientModel;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;using OpenAI;
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("Set OPENAI_API_KEY.");var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? throw new InvalidOperationException("Set OPENAI_BASE_URL.");var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? throw new InvalidOperationException("Set OPENAI_MODEL.");
IChatClient chatClient = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsIChatClient("claude-haiku-5-5", 1024); new OpenAIClient( new ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = new Uri(endpoint) }) .GetChatClient(model) .AsIChatClient();
// The AsAIAgent call and everything after it is unchanged.For OpenAI itself, leave Endpoint out and the client uses its default. For a model on your own machine, the community .NET client for Ollama, OllamaSharp, implements IChatClient itself:
dotnet add package OllamaSharp --version 5.5.0ollama pull llama3.2using Anthropic;using Anthropic.Core;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;using OllamaSharp;
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");
IChatClient chatClient = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsIChatClient("claude-haiku-5-5", 1024); new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2");
// The AsAIAgent call and everything after it is unchanged.Every version needs using Microsoft.Extensions.AI;, because the AsAIAgent extension for any IChatClient lives in that namespace.
Some things do not swap. Provider-specific values pass through as they are: Anthropic’s pause_turn arrives unchanged in FinishReason.Value, so code that checks for it has to change with the provider. The agent and its sessions are Agent Framework types; the tools are Microsoft.Extensions.AI types the agent passes through. The model can be swapped, but the code around it is tied to the framework.
A local setup can also leave the machine. Once the local server is signed in, Ollama serves models whose tag ends in cloud (gemma4:cloud, gpt-oss:120b-cloud) from its cloud service, so a request that looks local is sent over the internet.
Try it
- Lower the output limit from 1024 to 50 tokens and ask for a full recipe. The bracketed line reads
finish: length. In this run the model spent all 50 tokens on reasoning, soresponse.Textcame back empty. - Run the same question through the OpenAI-compatible or Ollama version and compare the answers. Only the code that creates the chat client differs.