·Microsoft Agent Framework·5 min read·Mid-level developers

A Three-Agent Content Pipeline with Microsoft Agent Framework

One ASP.NET endpoint, three small agents handing work down the line, zero hand-rolled prompt chaining. A complete walkthrough of the sample repo.

If you've ever wired three LLM calls together with string.Format, a try/catch, and a quiet prayer — this is the post that gets you off that road. I built a small companion repo to go with it: ludmal/microsoft-agent-framework-sample. A single ASP.NET Core endpoint, three agents, one sequential workflow. The whole orchestration is one file.

What is the Microsoft Agent Framework

The Microsoft Agent Framework is Microsoft's official .NET library for building agents and multi-agent workflows. It lives in the Microsoft.Agents.AI and Microsoft.Agents.AI.Workflows packages. Think of it as the grown-up sibling of "I'll just chain a few OpenAI calls in Program.cs."

Three things matter:

  • ChatClientAgent wraps any IChatClient (OpenAI, Azure OpenAI, Anthropic via adapter, GitHub Models, etc.) and gives it a name and a system prompt. That's the unit of work.
  • AgentWorkflowBuilder composes agents into pipelines — BuildSequential (chain them) or BuildConcurrent (fan out the same input to many agents).
  • InProcessExecution runs a workflow and gives you a stream of events you can observe — turn tokens, tool calls, intermediate messages, final output.

Why use it instead of Semantic Kernel? SK is the everything-store; the Agent Framework is the focused "I just want agents that talk to each other without me writing a message bus" library. Less surface area, fewer foot-guns, and the orchestration primitives are first-class instead of a side-quest.

What the sample does

POST /api/content   { "topic": "..." }
        │
        ▼
   ┌────────────┐    ┌──────────┐    ┌────────┐
   │ Researcher │ →  │  Writer  │ →  │ Editor │ → polished article
   └────────────┘    └──────────┘    └────────┘

You POST a topic, three agents pass the work down the line, you get a polished article back — plus the intermediate output of every agent for auditability.

Step 1 — the packages

The whole project file is five package references:

<PackageReference Include="Microsoft.Agents.AI" Version="1.6.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.6.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />

Microsoft.Extensions.AI is the abstraction layer (IChatClient and friends). The Microsoft.Agents.AI.* packages are the agent + workflow layer that sits on top.

Step 2 — one shared IChatClient

The agents don't know or care which provider is behind the chat. They just want an IChatClient:

builder.Services.AddSingleton<IChatClient>(_ =>
    new OpenAIClient(new ApiKeyCredential(apiKey))
        .GetChatClient(model)
        .AsIChatClient());

That's the seam where you swap OpenAI for Azure OpenAI, GitHub Models, or anything else with an adapter. Three agents below — one client. In a real app you might give each agent its own model (e.g. a cheaper one for the Researcher, a stronger one for the Editor), but for the sample one is enough.

Step 3 — three agents, three system prompts

var researcher = new ChatClientAgent(chat,
    instructions:
        "You are a researcher. Given a topic, list 5 concise, factual bullet points " +
        "covering the most important angles. No fluff, no marketing language.",
    name: "Researcher");

var writer = new ChatClientAgent(chat,
    instructions:
        "You are a writer. Turn the researcher's bullet points into a short, " +
        "engaging article of about 200 words. Keep it accessible.",
    name: "Writer");

var editor = new ChatClientAgent(chat,
    instructions:
        "You are an editor. Tighten the writer's draft: fix grammar, remove " +
        "redundancy, and make the opening line more compelling. Return only the " +
        "final polished article.",
    name: "Editor");

Each agent has one job. The name matters — it shows up on every message the agent emits, which is what lets you reconstruct the pipeline output at the end.

This is the part most teams overthink. You don't need a base class or a strategy pattern. Three constructor calls, three system prompts.

Step 4 — compose them into a workflow

Workflow pipeline = AgentWorkflowBuilder.BuildSequential(researcher, writer, editor);

That's the orchestration. One line. The framework wires the output of each agent into the input of the next. Want a FactChecker between Writer and Editor? Add it to the list. Want them to run in parallel instead? Swap BuildSequential for BuildConcurrent. No queues, no message bus, no _ = Task.WhenAll(...).

Step 5 — run it on a request

app.MapPost("/api/content", async (ContentRequest request, Workflow pipeline) =>
{
    var messages = new List<ChatMessage> { new(ChatRole.User, request.Topic) };
    var steps = new List<AgentStep>();

    await using var run = await InProcessExecution.RunStreamingAsync(pipeline, messages);
    await run.TrySendMessageAsync(new TurnToken(emitEvents: true));

    List<ChatMessage> finalMessages = [];
    await foreach (var evt in run.WatchStreamAsync())
    {
        if (evt is WorkflowOutputEvent output)
        {
            finalMessages = output.As<List<ChatMessage>>() ?? [];
            break;
        }
    }

    foreach (var msg in finalMessages.Where(m => m.Role == ChatRole.Assistant))
        steps.Add(new AgentStep(msg.AuthorName ?? "unknown", msg.Text ?? string.Empty));

    var finalArticle = steps.LastOrDefault()?.Output ?? string.Empty;
    return Results.Ok(new ContentResponse(request.Topic, finalArticle, steps));
});

Two things to notice:

  • RunStreamingAsync + TurnToken(emitEvents: true) is what gives you the live event stream. Each agent's reply, each tool call, each turn — they all flow through WatchStreamAsync(). For this endpoint we only care about the final WorkflowOutputEvent, but the same plumbing is what you'd use to stream tokens to a browser via SSE.
  • The AuthorName on each ChatMessage is how you know which agent produced what. That's why we named the agents — without names, the steps array would be a mystery.

What you get back

{
  "topic": "Why .NET is a great choice for AI apps",
  "article": "Final polished article here...",
  "steps": [
    { "agent": "Researcher", "output": "- Point 1\n- Point 2 ..." },
    { "agent": "Writer",     "output": "Draft article ..." },
    { "agent": "Editor",     "output": "Final polished article ..." }
  ]
}

The steps array is the part you'll keep coming back to. When the final article comes out flat, you can see exactly which stage went wrong instead of guessing. That's the real win of multi-agent over one mega-prompt: each step is debuggable in isolation.

What I'd add next, in this order

  1. Per-agent models. Cheap model for the Researcher, stronger one for the Editor. Same code shape, two more IChatClient registrations.
  2. A tool on the Researcher. Give it a web-search or RAG function and it'll start pulling real facts instead of inventing them. ChatClientAgent accepts function tools directly.
  3. Stream to the client. You're already inside a streaming workflow — surface those intermediate AgentRunUpdate events as SSE so the UI can show "Researcher thinking…" → "Writer drafting…" instead of one long spinner.

Clone it, set OPENAI_API_KEY, dotnet run. The whole thing fits in your head in 15 minutes — and once it does, replacing your next "three LLM calls in a row" tangle with a real workflow becomes the obvious move.