# Microsoft Agent Framework: your first C# agent

> Build a console AI agent on Claude with Microsoft Agent Framework, then switch to OpenAI or Ollama in one statement.

Published: 7 Oct 2026
Series: Microsoft Agent Framework in C#, part 1
URL: https://kootstra.io/writing/microsoft-agent-framework-first-csharp-agent/

**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](https://github.com/nkootstra/samples/tree/main/agent-framework/01-first-agent), a runnable version of this post with its tests.

## Install the agent package and the Anthropic connector

```bash
dotnet new console -n FirstAgent
cd FirstAgent
dotnet add package Microsoft.Agents.AI --version 1.24.0
dotnet add package Microsoft.Agents.AI.Anthropic --version 1.24.0
```

`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

```csharp
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);
```

```bash
export ANTHROPIC_API_KEY=<your key>
dotnet run
```

```text
Fried 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.
```

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`:

```csharp
Console.WriteLine(
    $"[{response.Usage?.InputTokenCount} in, "
    + $"{response.Usage?.OutputTokenCount} out, "
    + $"finish: {response.FinishReason}]");

if (response.FinishReason == ChatFinishReason.Length) // [!code highlight]
{
    Console.WriteLine("The answer stopped at the output token limit.");
}
```

```text
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 `AgentResponseUpdate`s as the model produces output:

```csharp
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]");
```

```text
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:

```bash
dotnet add package Microsoft.Agents.AI.OpenAI --version 1.24.0
```

```csharp
using Anthropic; // [!code --]
using Anthropic.Core; // [!code --]
using System.ClientModel; // [!code ++]
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI; // [!code ++]

var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") // [!code --]
    ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY."); // [!code --]
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") // [!code ++]
    ?? throw new InvalidOperationException("Set OPENAI_API_KEY."); // [!code ++]
var endpoint = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") // [!code ++]
    ?? throw new InvalidOperationException("Set OPENAI_BASE_URL."); // [!code ++]
var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") // [!code ++]
    ?? throw new InvalidOperationException("Set OPENAI_MODEL."); // [!code ++]

IChatClient chatClient =
    new AnthropicClient(new ClientOptions { ApiKey = apiKey }) // [!code --]
        .AsIChatClient("claude-haiku-5-5", 1024); // [!code --]
    new OpenAIClient( // [!code ++]
            new ApiKeyCredential(apiKey), // [!code ++]
            new OpenAIClientOptions { Endpoint = new Uri(endpoint) }) // [!code ++]
        .GetChatClient(model) // [!code ++]
        .AsIChatClient(); // [!code ++]

// 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:

```bash
dotnet add package OllamaSharp --version 5.5.0
ollama pull llama3.2
```

```csharp
using Anthropic; // [!code --]
using Anthropic.Core; // [!code --]
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OllamaSharp; // [!code ++]

var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") // [!code --]
    ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY."); // [!code --]
 // [!code --]
IChatClient chatClient =
    new AnthropicClient(new ClientOptions { ApiKey = apiKey }) // [!code --]
        .AsIChatClient("claude-haiku-5-5", 1024); // [!code --]
    new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2"); // [!code ++]

// 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, 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.
