Building AI Agents with .NET 10 and Microsoft.Extensions.AI
The .NET ecosystem has been steadily building its AI story, and with .NET 10 preview and the Microsoft.Extensions.AI library, we finally have a cohesive set of abstractions for integrating AI into .NET applications. Rather than locking you into a single provider, Microsoft.Extensions.AI defines interfaces like IChatClient and IEmbeddingGenerator that work across OpenAI, Azure OpenAI, Ollama, and other providers through a unified API.
For developers building AI agents in C#, this means you can write agent logic once and swap providers without rewriting your orchestration code. I will walk through building an AI agent system using these new abstractions, covering the middleware pipeline, tool calling, and patterns for production agent architectures.
The Microsoft.Extensions.AI abstraction layer
The library centers on a few key interfaces that mirror the patterns .NET developers already know from HttpClient and the middleware pipeline in ASP.NET Core.
// The core abstraction - works with any AI provider
using Microsoft.Extensions.AI;
// Register with dependency injection
var builder = Host.CreateApplicationBuilder(args);
// Use OpenAI
builder.Services.AddChatClient(
new OpenAIChatClient("gpt-4o", apiKey: "your-api-key"));
// Or use Azure OpenAI
// builder.Services.AddChatClient(
// new AzureOpenAIChatClient(endpoint, "gpt-4o", credential));
// Or use Ollama for local development
// builder.Services.AddChatClient(
// new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.1"));
var app = builder.Build();
What makes this work is that IChatClient is a simple interface with a CompleteAsync method. Your agent logic depends on the interface, not the implementation, making it trivial to swap providers between development, testing, and production.
Building a basic agent
Let us start with a simple agent that uses IChatClient to process requests:
using Microsoft.Extensions.AI;
public class ResearchAgent
{
private readonly IChatClient _chatClient;
private readonly List<ChatMessage> _conversationHistory = [];
public ResearchAgent(IChatClient chatClient)
{
_chatClient = chatClient;
_conversationHistory.Add(new ChatMessage(
ChatRole.System,
"""
You are a senior research analyst. Analyze topics thoroughly,
provide structured findings, and cite your reasoning.
When you need more information, ask clarifying questions.
"""
));
}
public async Task<string> AnalyzeAsync(string topic)
{
_conversationHistory.Add(new ChatMessage(ChatRole.User, topic));
var response = await _chatClient.CompleteAsync(_conversationHistory);
var assistantMessage = response.Message;
_conversationHistory.Add(assistantMessage);
return assistantMessage.Text ?? "No response generated.";
}
}
This is clean and idiomatic C#. The agent maintains conversation history, which gives the LLM context across multiple interactions. The IChatClient abstraction means this agent works identically whether backed by OpenAI, Azure, or a local model.
The middleware pipeline
Microsoft.Extensions.AI includes a middleware pipeline directly inspired by ASP.NET Core middleware. You can wrap the chat client with logging, caching, rate limiting, and custom behaviors, and they all compose together.
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Caching.Distributed;
var builder = Host.CreateApplicationBuilder(args);
// Build a middleware pipeline around the chat client
builder.Services.AddDistributedMemoryCache();
builder.Services.AddChatClient(pipeline => pipeline
.UseOpenTelemetry() // Automatic tracing and metrics
.UseFunctionInvocation() // Handle tool/function calls
.UseDistributedCache() // Cache identical requests
.UseLogging() // Log all requests and responses
.Use(new RateLimitingChatClient(
maxRequestsPerMinute: 60
))
.Use(new OpenAIChatClient("gpt-4o", apiKey: "your-api-key"))
);
var app = builder.Build();
Each middleware in the pipeline wraps the next, forming a chain of responsibility. Requests flow down through the middlewares to the actual provider, and responses flow back up. This is the same pattern .NET developers use for HTTP middleware, applied to AI interactions.
Here is a custom middleware example that adds retry logic with exponential backoff:
using Microsoft.Extensions.AI;
public class RetryChatClient : DelegatingChatClient
{
private readonly int _maxRetries;
private readonly TimeSpan _baseDelay;
public RetryChatClient(
IChatClient innerClient,
int maxRetries = 3,
int baseDelayMs = 1000)
: base(innerClient)
{
_maxRetries = maxRetries;
_baseDelay = TimeSpan.FromMilliseconds(baseDelayMs);
}
public override async Task<ChatCompletion> CompleteAsync(
IList<ChatMessage> chatMessages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
for (int attempt = 0; attempt <= _maxRetries; attempt++)
{
try
{
return await base.CompleteAsync(
chatMessages, options, cancellationToken);
}
catch (Exception ex) when (attempt < _maxRetries)
{
var delay = _baseDelay * Math.Pow(2, attempt);
Console.WriteLine(
quot;Attempt {attempt + 1} failed: {ex.Message}. "
+ quot;Retrying in {delay.TotalSeconds}s...");
await Task.Delay(delay, cancellationToken);
}
}
throw new InvalidOperationException("All retry attempts exhausted.");
}
}
Tool calling
Tool calling lets agents interact with external systems. Microsoft.Extensions.AI provides the AIFunction abstraction for defining tools that the LLM can invoke.
using Microsoft.Extensions.AI;
using System.ComponentModel;
public class AgentTools
{
[Description("Search for articles on a given topic and return summaries")]
public static async Task<string> SearchArticles(
[Description("The topic to search for")] string query,
[Description("Maximum number of results")] int maxResults = 5)
{
// In production, this would call a real search API
using var client = new HttpClient();
var response = await client.GetStringAsync(
quot;https://api.example.com/search?q={Uri.EscapeDataString(query)}"
+ quot;&limit={maxResults}");
return response;
}
[Description("Get the current weather for a location")]
public static async Task<string> GetWeather(
[Description("City name")] string city)
{
using var client = new HttpClient();
var response = await client.GetStringAsync(
quot;https://api.weatherapi.com/v1/current.json?q={city}");
return response;
}
[Description("Execute a SQL query against the analytics database")]
public static async Task<string> QueryDatabase(
[Description("SQL query to execute")] string sql)
{
// Validate and execute the query safely
if (sql.Contains("DROP", StringComparison.OrdinalIgnoreCase) ||
sql.Contains("DELETE", StringComparison.OrdinalIgnoreCase))
{
return "Error: Destructive queries are not allowed.";
}
// Execute against read-only connection
return quot;Query executed: {sql}\nResults: [sample data]";
}
}
Wire the tools into the agent through the chat client options:
using Microsoft.Extensions.AI;
var chatClient = new OpenAIChatClient("gpt-4o", apiKey: "your-api-key");
// Create AI functions from the static methods
var tools = new[]
{
AIFunctionFactory.Create(AgentTools.SearchArticles),
AIFunctionFactory.Create(AgentTools.GetWeather),
AIFunctionFactory.Create(AgentTools.QueryDatabase)
};
var options = new ChatOptions
{
Tools = [.. tools]
};
// Use the function invocation middleware to handle tool calls automatically
var agent = chatClient
.AsBuilder()
.UseFunctionInvocation()
.Build();
var messages = new List<ChatMessage>
{
new(ChatRole.System, "You are a helpful assistant with access to search, "
+ "weather, and database tools. Use them when appropriate."),
new(ChatRole.User, "What's the weather like in London and find me "
+ "recent articles about .NET AI development?")
};
var response = await agent.CompleteAsync(messages, options);
Console.WriteLine(response.Message.Text);
The UseFunctionInvocation middleware handles the entire tool-calling loop automatically. When the LLM decides to call a function, the middleware executes it, feeds the result back to the model, and continues until the LLM produces a final text response.
Multi-agent pattern with dependency injection
For more complex scenarios, you can build a multi-agent system using .NET's dependency injection:
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
public interface IAgent
{
string Name { get; }
Task<string> ProcessAsync(string input);
}
public class TriageAgent : IAgent
{
private readonly IChatClient _chatClient;
private readonly IEnumerable<IAgent> _specialists;
public string Name => "Triage";
public TriageAgent(
IChatClient chatClient,
[FromKeyedServices("specialists")] IEnumerable<IAgent> specialists)
{
_chatClient = chatClient;
_specialists = specialists;
}
public async Task<string> ProcessAsync(string input)
{
var availableAgents = string.Join(", ",
_specialists.Select(a => a.Name));
var messages = new List<ChatMessage>
{
new(ChatRole.System,
quot;Route the user's request to the appropriate specialist. "
+ quot;Available: {availableAgents}. "
+ "Respond with only the agent name."),
new(ChatRole.User, input)
};
var response = await _chatClient.CompleteAsync(messages);
var targetName = response.Message.Text?.Trim();
var specialist = _specialists
.FirstOrDefault(a => a.Name.Equals(
targetName, StringComparison.OrdinalIgnoreCase));
if (specialist is null)
{
return quot;No specialist found for: {targetName}";
}
return await specialist.ProcessAsync(input);
}
}
// Registration
builder.Services.AddKeyedSingleton<IAgent, CodeReviewAgent>("specialists");
builder.Services.AddKeyedSingleton<IAgent, DatabaseAgent>("specialists");
builder.Services.AddSingleton<IAgent, TriageAgent>();
This pattern uses .NET's keyed services (introduced in .NET 8) to inject a collection of specialist agents into the triage agent. Each specialist is a standalone IAgent implementation with its own system prompt and tools.
Streaming for real-time UX
For user-facing applications, streaming responses provide a much better experience:
using Microsoft.Extensions.AI;
public async Task StreamResponseAsync(
IChatClient chatClient, string userMessage)
{
var messages = new List<ChatMessage>
{
new(ChatRole.System, "You are a helpful technical assistant."),
new(ChatRole.User, userMessage)
};
await foreach (var update in
chatClient.CompleteStreamingAsync(messages))
{
Console.Write(update.Text);
}
Console.WriteLine();
}
The CompleteStreamingAsync method returns an IAsyncEnumerable<StreamingChatCompletionUpdate>, which integrates naturally with C#'s async streaming patterns.
Looking ahead
Microsoft.Extensions.AI takes a solid approach to AI integration in .NET. It follows the same patterns that made ASP.NET Core middleware, HttpClient, and dependency injection work well, so the library feels familiar to .NET developers right away. You are applying patterns you already know to a new domain.
Provider-agnostic interfaces, composable middleware, and first-class tool calling cover what you need for production AI agents in C#. As .NET 10 moves toward release, expect tighter integration with Aspire for cloud-native deployment, SignalR for real-time streaming, and Blazor for agent-powered UIs.