AIAgent and ChatClientAgent: Core Abstractions in Microsoft Agent Framework
The heart of the microsoft agent framework aiagent chatclientagent design is a clean two-layer abstraction: a minimal interface that defines what an agent does, and a concrete implementation that handles the heavy lifting. Understanding this separation -- the AIAgent and ChatClientAgent split -- is the key to using the Microsoft Agent Framework in C# effectively -- and to knowing when to reach past the agent layer and work directly with the underlying IChatClient. This article unpacks both IAIAgent and ChatClientAgent in depth, covers what AsAIAgent() actually does, explains system prompt and instruction management, walks through tool attachment, and compares the agent abstractions to raw IChatClient usage.
MAF is in public preview at version 1.0.0-rc1. The abstractions described here reflect the current API shape and may evolve before GA.
The IAIAgent Interface
IAIAgent is the core contract in Microsoft Agent Framework. The Microsoft Agent Framework's central interface is intentionally minimal. It exposes two method families:
RunAsync-- sends a prompt and returns anAgentResponsecontaining the full text responseRunStreamingAsync-- sends a prompt and returnsIAsyncEnumerable<StreamingAgentResponse>for incremental output
Both methods accept an optional AgentSession for multi-turn memory and a CancellationToken for cancellation support. That's the entire public surface of the interface from the consumer's perspective.
The minimalism is a deliberate design choice. IAIAgent defines what an agent does -- process a prompt and return a response -- without dictating how. This makes it easy to mock in tests, swap implementations, and extend with decorators. In the Microsoft Agent Framework, the decorator pattern fits naturally here: you can wrap any IAIAgent to add logging, rate limiting, retry, or caching behavior without touching the underlying implementation.
What IAIAgent Exposes
The IAIAgent and ChatClientAgent pairing follows a clear consumer pattern: code against the interface, never against the implementation:
using Microsoft.Agents.AI;
// Consume an agent through the interface -- works with any implementation
IAIAgent agent = GetAgent(); // injected or resolved from DI
// Single-turn call
AgentResponse response = await agent.RunAsync("Summarize this text: ...");
Console.WriteLine(response.Text);
// Streaming call
await foreach (StreamingAgentResponse chunk in agent.RunStreamingAsync("Explain async/await in C#"))
{
Console.Write(chunk.Text);
}
Notice that the consumer code references only IAIAgent. It doesn't know or care whether the implementation is ChatClientAgent wrapping GPT-4o, a mock agent for testing, or a specialized routing agent that dispatches to sub-agents. This is the correct way to consume agents in a production codebase -- depend on abstractions, not concrete types.
AgentResponse and StreamingAgentResponse
AgentResponse is the result of a completed RunAsync call. Its key property is Message, which exposes the Text of the LLM's reply. It may also carry metadata about the completion (token usage, finish reason) depending on the provider.
StreamingAgentResponse is the chunk type yielded by RunStreamingAsync. Each chunk has a Text property with the incremental content for that token or token group. Accumulating all chunks gives you the same content as a non-streaming AgentResponse.
ChatClientAgent: The Concrete Implementation
ChatClientAgent is the standard IAIAgent implementation that ships with Microsoft.Agents.AI. The relationship between IAIAgent and ChatClientAgent is deliberate: the interface defines the contract, the concrete class handles execution. You typically never reference the concrete type directly -- you get a ChatClientAgent (typed as IAIAgent) by calling AsAIAgent() on any IChatClient.
ChatClientAgent does three things that raw IChatClient does not:
- System prompt management -- it prepends your agent instructions as a system message on every call
- Tool invocation loop -- it handles the automatic function-calling cycle: detect tool calls in the response, execute the tools, append results, re-invoke the model
- Session wiring -- it integrates with
AgentSessionto append and retrieve conversation history automatically
The Tool Invocation Loop
The tool loop is where ChatClientAgent earns its value over raw IChatClient. When the LLM returns a response that includes tool call requests, ChatClientAgent:
- Detects the tool call requests in the completion
- Finds the matching
AIFunctionby name - Executes the function with the LLM-provided arguments
- Appends the tool result as a tool message to the conversation
- Re-invokes the model with the updated message history
- Repeats until the model returns a final text response (no tool calls)
You get all of this automatically. Without ChatClientAgent, you'd write this loop yourself every time -- which is tedious and error-prone.
The AsAIAgent() Extension Method
AsAIAgent() is how you create agents -- and how IAIAgent and ChatClientAgent get connected. It's an extension method on IChatClient that constructs a ChatClientAgent and returns it as IAIAgent:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
IChatClient chatClient = new OpenAIClient(new ApiKeyCredential(apiKey))
.GetChatClient("gpt-4o-mini")
.AsIChatClient();
// Minimal agent -- instructions only
IAIAgent minimalAgent = chatClient.AsAIAgent(
instructions: "You are a helpful assistant.");
// Agent with tools
static double CalculateTax(double amount, double rate) => amount * rate;
var tools = new[] { AIFunctionFactory.Create(CalculateTax) };
IAIAgent taxAgent = chatClient.AsAIAgent(
instructions: "You are a tax calculation assistant. Use tools to compute tax.",
tools: tools);
The method signature accepts:
instructions(required) -- the system prompt for this agenttools(optional) -- an array ofAIFunctioninstances to register with the agent
Under the hood, AsAIAgent() creates a ChatClientAgent with the provided IChatClient, instructions, and tools. The agent holds a reference to the IChatClient and uses it for every call.
System Prompt and Instruction Management
Working with AIAgent and ChatClientAgent together means you manage instructions at the agent level, not the call level. The instructions parameter in AsAIAgent() becomes the system prompt prepended to every conversation this agent handles. It is sent as a system role message at the beginning of the message list on every RunAsync or RunStreamingAsync call.
Instructions are set at agent creation time. They are not mutable after construction in the current API. This means:
- Agent instances are stateless with respect to instructions -- you can safely share a single
IAIAgentinstance across threads and concurrent calls - Instructions are global to the agent -- they apply to every prompt processed by that agent
- Different instruction sets require different agent instances -- if you need a customer support agent and a coding assistant in the same application, create two separate
IAIAgentinstances
The instructions string is your primary tool for shaping agent behavior. Write precise, specific instructions. Include guidance on tone, format, constraints, and tool usage. Vague instructions produce inconsistent responses.
// Good: specific, structured instructions
IAIAgent preciseAgent = chatClient.AsAIAgent(
instructions: """
You are a .NET code review assistant.
Rules:
- Always point out nullable reference type issues
- Flag any use of blocking calls (like .Result or .Wait()) in async methods
- Suggest LINQ alternatives to manual loops when appropriate
- Keep responses under 300 words unless a detailed explanation is requested
""");
// Less effective: vague instructions
IAIAgent vagueAgent = chatClient.AsAIAgent(
instructions: "Help with code.");
Attaching Tools at Creation Time vs. Runtime
The AIAgent and ChatClientAgent design attaches tools at agent creation time via the tools parameter of AsAIAgent(). Once created, the agent's tool set is fixed.
For the majority of use cases this is fine -- you know upfront which tools your agent needs. But if you need a dynamic tool set that varies per request or per user, you have a few options:
- Create multiple specialized agents -- a read-only agent with query tools and a write agent with mutation tools, both registered in DI, resolved via keyed services
- Use conditional tools -- register tools that check context internally and return appropriate errors or no-ops when the action isn't permitted
- Extend MAF -- since
IAIAgentis an interface, you can build a customIAIAgentimplementation that dynamically selects tools before delegating to an innerChatClientAgent
The abstract factory pattern is a natural fit for agent creation when tool sets vary by context -- a factory that produces IAIAgent instances with context-appropriate tools registered.
Comparing IAIAgent to Raw IChatClient
Understanding when to use IAIAgent versus raw IChatClient directly is an important judgment call. The IAIAgent and ChatClientAgent pair exist specifically to raise the abstraction level above what raw IChatClient offers.
Raw IChatClient
// Raw IChatClient -- full control, full responsibility
IChatClient chatClient = /* ... */;
var messages = new List<ChatMessage>
{
new(ChatRole.System, "You are a helpful assistant."),
new(ChatRole.User, "What is the capital of France?")
};
ChatResponse response = await chatClient.GetResponseAsync(messages);
Console.WriteLine(response.Text);
With raw IChatClient:
- You own the message list -- you construct it, append to it, manage it
- You own the tool loop -- if the model returns tool calls, you handle them
- You own session state -- if you want history, you track it yourself
- Maximum control, maximum boilerplate
IAIAgent
// IAIAgent -- structured orchestration
IAIAgent agent = chatClient.AsAIAgent(instructions: "You are a helpful assistant.");
AgentResponse response = await agent.RunAsync("What is the capital of France?");
Console.WriteLine(response.Text);
With IAIAgent:
- System prompt is managed for you
- Tool invocation loop is managed for you
- Session state is managed for you (when you pass an
AgentSession) - Provider-agnostic -- your code doesn't reference any provider type
- Mockable -- write tests against
IAIAgentwithout hitting the LLM
When to Use Each
| Scenario | Recommendation |
|---|---|
| Simple single-turn Q&A, no tools | Either works; IAIAgent is cleaner |
| Multi-turn conversation | IAIAgent with AgentSession |
| Tool/function calling | IAIAgent (handles the loop automatically) |
| Custom message list construction | Raw IChatClient |
| Building a custom agent framework | Raw IChatClient |
| Production application with tests | IAIAgent (mockable interface) |
| Middleware pipeline development | Raw IChatClient |
The dependency injection best practices that apply to any interface apply here too: inject IAIAgent, not ChatClientAgent. Keep your application code independent of the concrete implementation.
Decorating IAIAgent for Cross-Cutting Concerns
Because IAIAgent is an interface, you can wrap it with decorator implementations that add cross-cutting behavior transparently. This is one of the practical advantages that IAIAgent and ChatClientAgent give you over coding directly against IChatClient:
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
public sealed class LoggingAgent : IAIAgent
{
private readonly IAIAgent _inner;
private readonly ILogger<LoggingAgent> _logger;
public LoggingAgent(IAIAgent inner, ILogger<LoggingAgent> logger)
{
_inner = inner;
_logger = logger;
}
public async Task<AgentResponse> RunAsync(
string prompt,
AgentSession? session = null,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Agent prompt: {Prompt}", prompt);
var response = await _inner.RunAsync(prompt, session, cancellationToken);
_logger.LogInformation("Agent response length: {Length}", response.Text?.Length ?? 0);
return response;
}
public async IAsyncEnumerable<StreamingAgentResponse> RunStreamingAsync(
string prompt,
AgentSession? session = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
_logger.LogInformation("Agent streaming prompt: {Prompt}", prompt);
await foreach (var chunk in _inner.RunStreamingAsync(prompt, session, cancellationToken))
{
yield return chunk;
}
}
}
This pattern integrates naturally with decorator-based DI registration. Register the inner ChatClientAgent and wrap it with your decorator before registering the final IAIAgent in the service container.
Practical Example: Multiple Specialized Agents
A real application typically needs more than one agent. The IAIAgent and ChatClientAgent model supports this cleanly -- each agent has its own instructions and tools, and they are independently injectable via DI. Here's a pattern for registering two specialized agents with different instructions and tools:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
IChatClient chatClient = /* build from config */;
// Coding assistant -- no tools, focused instructions
IAIAgent codingAgent = chatClient.AsAIAgent(
instructions: """
You are an expert C# code reviewer.
Focus on correctness, performance, and adherence to SOLID principles.
Provide specific, actionable feedback with code examples.
""");
// Data assistant -- with tools for data retrieval
var dataTools = new[]
{
AIFunctionFactory.Create((string query) => QueryDatabase(query), "QueryDatabase",
"Execute a read-only SQL query and return results as JSON"),
};
IAIAgent dataAgent = chatClient.AsAIAgent(
instructions: "You are a data analysis assistant. Use the QueryDatabase tool to retrieve data.",
tools: dataTools);
// Register both with keyed services
services.AddKeyedSingleton<IAIAgent>("coding", codingAgent);
services.AddKeyedSingleton<IAIAgent>("data", dataAgent);
static string QueryDatabase(string query) => "[]"; // placeholder
With keyed services, you can resolve the right agent by key anywhere in your application without coupling to the concrete type or construction logic.
FAQ
What is IAIAgent in Microsoft Agent Framework?
IAIAgent is the core interface in MAF. It defines RunAsync (blocking, returns AgentResponse) and RunStreamingAsync (streaming, returns IAsyncEnumerable<StreamingAgentResponse>). It is the abstraction you code against in your application -- inject it via DI, mock it in tests, and swap implementations without changing consumer code.
What does ChatClientAgent do that IChatClient does not?
ChatClientAgent adds three things on top of IChatClient: automatic system prompt prepending, an automatic tool invocation loop (handles function calling cycles without manual code), and session-based conversation history management. Raw IChatClient handles none of these -- you'd write that plumbing yourself.
What does AsAIAgent() do internally?
AsAIAgent() is an extension method on IChatClient that constructs a ChatClientAgent instance configured with your instructions and tools, and returns it typed as IAIAgent. It is the factory entry point for MAF agents. You provide the IChatClient, instructions, and optional tools -- MAF handles the rest.
Can I mock IAIAgent in unit tests?
Yes. Because IAIAgent is an interface, you can create a mock implementation using Moq, NSubstitute, or a simple manual stub. Set up RunAsync to return a predetermined AgentResponse for specific inputs and your tests never hit the LLM. This is one of the key reasons to always inject IAIAgent rather than ChatClientAgent directly.
Can I change agent instructions after creation?
Not in the current 1.0.0-rc1 API. Instructions are set at construction time via AsAIAgent(). If you need different instructions, create a different agent instance. Agent instances are thread-safe and can be shared across requests, so creating a few specialized instances at startup is the recommended approach.
How do I add cross-cutting behavior like logging to an agent?
Implement IAIAgent in a decorator class that wraps an inner IAIAgent and delegates to it after performing the cross-cutting work. Register the decorator in DI wrapping the concrete ChatClientAgent. This is the same decorator pattern used throughout the .NET ecosystem for adding logging, retry, and caching to services.
Is ChatClientAgent thread-safe?
Yes. A ChatClientAgent instance is stateless with respect to individual calls -- it does not hold mutable per-call state. The AgentSession carries conversation state and is created per conversation, so concurrent calls with different sessions are safe. You can register a single IAIAgent as a singleton and use it across thousands of concurrent requests.
