Back to homepage

Niels Kootstra

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

Microsoft.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

  1. AnthropicClient is Anthropic’s SDK. It holds the API key and makes the HTTP calls.
  2. AsIChatClient wraps it in IChatClient, the chat abstraction from Microsoft.Extensions.AI that 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 through ChatOptions.
  3. AsAIAgent wraps the chat client in a ChatClientAgent, the framework’s general-purpose agent, which the code holds as the abstract AIAgent.

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:

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:

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:

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:

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, so response.Text came 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.