From Semantic Kernel plugins and hand-rolled session management to one-line agent wiring. What SK still left on the table, and what I'd do differently now.
A few months ago I needed a conversational assistant for a task management app — something that could answer natural-language questions about a user's task list, create tasks from a description, and summarise overdue items. Think: "What's due this week?" answered with actual data, not a canned response.
The app was a standard ASP.NET Core API backed by a SQL database. I reached for
Semantic Kernel — it had plugin support, the [KernelFunction]
attribute was clean, and auto-invoke meant I didn't have to write tool dispatch by hand.
That part worked well. What Semantic Kernel didn't give me was the stuff around the agent loop.
"I didn't need AGI. I needed a thing that could call two functions, remember context between turns, and not hallucinate task IDs."
The plugin definition was the easy part. Everything else — session persistence, context injection, streaming, and wiring the kernel into ASP.NET Core's DI properly — was still mine to figure out.
Here's roughly what the SK setup looked like. The plugin itself was nice:
public class TaskPlugin { private readonly TaskRepository _repo; public TaskPlugin(TaskRepository repo) => _repo = repo; [KernelFunction("get_tasks")] [Description("Returns the user's open tasks")] public async Task<List<TaskItem>> GetTasksAsync( [Description("Filter: 'all', 'overdue', or 'thisWeek'")] string filter = "all") => await _repo.GetAsync(filter); [KernelFunction("create_task")] [Description("Creates a new task from a description")] public async Task<TaskItem> CreateTaskAsync(string title, DateOnly? dueDate = null) => await _repo.CreateAsync(title, dueDate); }
Clean. No string switches. Arguments deserialised automatically. Auto-invoke handled the loop. But then came the endpoint — and that's where the seams showed:
// Rebuild ChatHistory from cache on every request var history = _cache.Get<ChatHistory>(sessionId) ?? new ChatHistory(); history.AddUserMessage(userMessage); // Inject user context — no first-class mechanism, so: KernelArguments var args = new KernelArguments( new OpenAIPromptExecutionSettings { ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions } ) { ["userId"] = currentUserId }; // Invoke and collect full response (no streaming here — separate setup) var result = await _kernel.InvokePromptAsync(userMessage, args); // Persist history back manually history.AddAssistantMessage(result.ToString()); _cache.Set(sessionId, history, TimeSpan.FromMinutes(30));
The plugin layer was solved. But the surrounding code was still mine: cache the history, rebuild it each
turn, inject context via KernelArguments
(which the plugin has to know to look for), handle expiry, and set up streaming as a completely separate path.
Each of these was a separate decision, a separate bug surface.
The code wasn't wrong. But it was infrastructure, not product.
In April 2026, Microsoft shipped Microsoft.Agents.AI 1.0. The short version: it's the unified successor
to Semantic Kernel and AutoGen for .NET, built natively on top of
Microsoft.Extensions.AI.
The longer version: those two libraries grew independently and solved overlapping problems with different abstractions. Semantic Kernel had a plugin model and planners. AutoGen had a multi-agent conversation model. Both had their own way of managing chat history, tools, and agent lifecycle. Microsoft.Agents.AI unifies the concepts — one abstraction for single agents, multi-agent workflows, tool registration, memory, and sessions.
| What you needed before | What ships in the box now |
|---|---|
| SK plugin loop (auto-invoke, but no session) | Full loop built into RunAsync() / RunStreamingAsync() |
[KernelFunction] attributes on a plugin class | [AIFunction] + AIFunctionFactory — same idea, native to M.E.AI |
| ChatHistory in IDistributedCache, managed by hand | AgentSession — per-session, pluggable storage, built-in |
| Context via KernelArguments (stringly-typed) | AIContextProvider + [FromAIContext] — type-safe injection |
Separate streaming API (GetStreamingChatMessageContentsAsync) | One API: RunStreamingAsync() handles text + tool calls |
| Singleton Kernel, scoped service workarounds | Tool classes resolved from DI per request — constructor injection just works |
The key design decision is that it sits on top of IChatClient
— the abstraction from Microsoft.Extensions.AI. That means it works with OpenAI, Azure OpenAI, Ollama, and anything else that implements the interface.
You don't rewrite your DI setup.
The entry point is a single extension method: .AsAIAgent().
It wraps your existing IChatClient and gives it the full agent loop.
// Register IChatClient as usual builder.Services.AddOpenAIClient() .AddChatClient("gpt-4o-mini") .AsAIAgent() // <— that's it. IChatClient is now an AIAgent. .WithSession<InMemoryAgentSession>() .WithContextProvider<TaskContextProvider>(); builder.Services.AddAIFunction<TaskAgentTools>();
.AsAIAgent() installs the agentic loop:
it calls the model, detects tool-call finish reasons, executes the registered tools, feeds results back,
and loops until the model stops. You don't write that loop. You don't maintain that loop. It just happens.
The before/after for the endpoint itself is stark:
// ~25 lines to wire per endpoint var history = _cache .Get<ChatHistory>(sessionId) ?? new ChatHistory(); history.AddUserMessage(msg); var result = await _kernel.InvokePromptAsync( msg, new KernelArguments(opts) { ["userId"] = userId }); history.AddAssistantMessage( result.ToString()); _cache.Set(sessionId, history);
// ~4 lines in the endpoint var answer = await _agent.RunAsync( userMessage, sessionId); return Ok(answer.Text);
The session ID is all the framework needs to load and persist history automatically — using whichever
IAgentSession implementation you registered.
In-memory for dev, Redis for production, same API.
RunStreamingAsync()
returns an IAsyncEnumerable<AgentUpdate>.
You await foreach over it and write chunks to the response stream. Tool calls happen in the background;
the stream only surfaces text tokens to the client.
This is where the biggest quality-of-life jump happens. Gone is the string-switch dispatch.
You define tools as plain C# methods and let AIFunctionFactory.Create()
handle schema generation, argument deserialization, and result serialization.
public class TaskAgentTools(TaskRepository repo) { [AIFunction("get_tasks", Description = "Returns the user's open tasks")] public async Task<List<TaskItem>> GetTasksAsync( [AIParameter(Description = "Filter: 'all', 'overdue', or 'thisWeek'")] string filter = "all") { return await repo.GetAsync(filter); } [AIFunction("create_task", Description = "Creates a new task from a description")] public async Task<TaskItem> CreateTaskAsync( string title, DateOnly? dueDate = null) { return await repo.CreateAsync(title, dueDate); } }
That's all. The framework inspects the method signatures, generates JSON schemas for the model, deserialises the model's arguments into the correct .NET types, and calls the methods. The model never sees a raw JSON blob it might misparse. You never write a deserialiser by hand.
Null safety also improves: optional parameters map cleanly to nullable types,
so if the model omits dueDate,
C# gets null, not a runtime parse exception.
Tool classes registered via AddAIFunction<T>()
are resolved from the DI container per request, so constructor injection of scoped services (like a DbContext) works exactly as you'd expect.
No service locator pattern. No static state.
AgentSession is the abstraction
for per-conversation state — history, metadata, whatever you attach to it. The built-in implementations are
InMemoryAgentSession for local dev
and a Redis-backed one for production. You can also implement
IAgentSession directly if you need custom storage.
More interesting is AIContextProvider:
it's the clean way to inject request-scoped context into every tool call without cramming it into the system prompt or passing it around as a parameter.
public class TaskContextProvider(IHttpContextAccessor http) : AIContextProvider { public override AgentContext BuildContext() { var userId = http.HttpContext?.User.GetUserId(); return new AgentContext { ["UserId"] = userId, ["TimeZone"] = http.HttpContext? .Request.Headers["X-Timezone"].ToString() }; } }
Inside any tool method you can now accept the context as a parameter — the framework injects it:
[AIFunction("get_tasks")] public async Task<List<TaskItem>> GetTasksAsync( string filter, [FromAIContext] string userId, // injected, not from model [FromAIContext] string? timeZone) { return await _repo.GetAsync(userId, filter, timeZone); }
This is the pattern I most wish I'd had earlier. The user ID and tenant context never touched the model prompt (models shouldn't make access-control decisions), and the tool implementation is clean — no service locator, no hidden state, no ambient context bag being passed through ten layers of call stack.
Looking back at the manual implementation, most of the complexity wasn't product complexity — it was framework gap-filling. The actual business logic (which tasks to show, how to format them, when to create vs update) took maybe a quarter of the total code. The rest was infrastructure I reinvented.
"The best infrastructure code is the code you don't write because someone already got it right."
If I started today I'd structure it like this:
The framework doesn't take away the hard parts of building an AI feature — deciding what information the model should have, how to scope tool permissions, how to test agent behaviour. Those remain your problem, and they should. What it removes is the mechanical cost of running the loop itself.
For most .NET applications adding an AI assistant, the manual loop is now the wrong starting point.
Start with AsAIAgent(),
get something working, and only drop down to the raw IChatClient
loop when you need control the framework can't give you. In my experience building the task assistant,
I never hit that point.
NuGet: Microsoft.Agents.AI —
requires .NET 8+ and Microsoft.Extensions.AI 9.x.
The GitHub repo has a working TaskBot sample that covers everything in this article end-to-end.