Type at least 2 characters to search...
Microsoft Agent Framework — Foundations dotnet maf agents microsoft-extensions-ai microsoft-agent-framework
Transform .NET Diagnostics into a Specialized AI Agent with Claude Agent SDK dotnet agents mcp

TL;DR

This is Part 2 of the Microsoft Agent Framework series. Part 1 covered the basics — creating agents, tools, multi-turn conversations, and memory. This post goes further: orchestrating agents into workflow graphs, exposing them as MCP servers, enabling agent-to-agent communication via A2A, and streaming to frontends with AG-UI. All samples run as single-file C# scripts with dotnet run.

Source code: https://github.com/NikiforovAll/maf-getting-started

Introduction — From agents to systems

Part 1 built individual agents with tools, sessions, and memory. But real systems need more: pipelines that chain agents together, protocols that expose agents to external clients, and standards that let agents discover each other.

MAF addresses this with three integration protocols:

ProtocolWho talksTransportUse case
MCP ServerClient → Your Agentstdio / HTTPExpose agents as tools
MCP ClientYour Agent → Remote Toolsstdio / HTTPConsume external tools
A2AAgent → AgentHTTP + JSON-RPCMulti-agent orchestration
AG-UIUser → AgentHTTP POST + SSEServe end users

MCP gives agents tools (both ways), A2A lets agents talk to agents, AG-UI connects agents to end users.

Workflows — Orchestrating agents as graphs

Workflows in MAF are directed graphs — nodes are executors (functions or agents), edges define data flow. Three patterns cover most use cases:

PatternUse CaseAPI
Function WorkflowPure data transformations, no LLMBindAsExecutor() + WorkflowBuilder
Agent WorkflowLLM-powered multi-agent pipelinesAgentWorkflowBuilder.BuildSequential()
Composed WorkflowMix functions + agents in one graphWorkflowBuilder + AddEdge()

The building blocks:

Building BlockAPIDescription
ExecutorFunc<T,R>.BindAsExecutor()A node in the workflow graph
Edgebuilder.AddEdge(A, B)Connects two executors (A → B)
Outputbuilder.WithOutputFrom(B)Designates the final output node
RunInProcessExecution.RunAsync()Executes the workflow
StreamingRunInProcessExecution.RunStreamingAsync()Executes with streaming events
EventsExecutorCompletedEvent, AgentResponseUpdateEventEmitted per completed node

Function workflow

Bind plain C# functions as workflow executors — no LLM involved, pure data transformations:

#:package Microsoft.Agents.AI.Workflows@1.0.0-rc2

using Microsoft.Agents.AI.Workflows;

Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");

Func<string, string> reverseFunc = s => string.Concat(s.Reverse());
var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor");

WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
var workflow = builder.Build();

await using Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!");
foreach (WorkflowEvent evt in run.NewEvents)
{
    if (evt is ExecutorCompletedEvent executorComplete)
    {
        Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}");
    }
}
graph LR Input["Hello, World!"] --> U[UppercaseExecutor] U --> R[ReverseTextExecutor] R --> Output["!DLROW ,OLLEH"]
StepExecutorOutput
Input"Hello, World!"
1UppercaseExecutor"HELLO, WORLD!"
2ReverseTextExecutor"!DLROW ,OLLEH"

BindAsExecutor() wraps any Func<TInput, TOutput> into a workflow node. The TOutput of one node must match the TInput of the next — the type system enforces the contract.

Agent workflow

For LLM-powered pipelines, AgentWorkflowBuilder.BuildSequential() chains agents together:

var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME");

var chatClient = new AzureOpenAIClient(new Uri(endpoint!), new DefaultAzureCredential())
    .GetChatClient(deploymentName)
    .AsIChatClient();

AIAgent writer = chatClient.AsAIAgent(
    instructions: "You write short creative stories in 2-3 sentences.", name: "Writer"
);

AIAgent critic = chatClient.AsAIAgent(
    instructions: "You review stories and give brief constructive feedback in 1-2 sentences.", name: "Critic"
);

var agentWorkflow = AgentWorkflowBuilder.BuildSequential("story-pipeline", [writer, critic]);

List<ChatMessage> input = [new(ChatRole.User, "Write a story about a robot learning to paint.")];

await using StreamingRun agentRun = await InProcessExecution.RunStreamingAsync(agentWorkflow,input);
await agentRun.TrySendMessageAsync(new TurnToken(emitEvents: true));

string lastExecutorId = string.Empty;
await foreach (WorkflowEvent evt in agentRun.WatchStreamAsync())
{
    if (evt is AgentResponseUpdateEvent e)
    {
        if (e.ExecutorId != lastExecutorId)
        {
            lastExecutorId = e.ExecutorId;
            Console.WriteLine();
            Console.WriteLine($"[{e.ExecutorId}]:");
        }

        Console.Write(e.Update.Text);
    }
    else if (evt is WorkflowOutputEvent output)
    {
        Console.WriteLine();
    }
}
graph LR U[User Prompt] --> W[Writer] W -->|story| C[Critic] C -->|feedback| O[Output]
[Writer]:
A small robot named Pixel discovered an abandoned art studio and began
mixing colors with its mechanical fingers...

[Critic]:
The story has a charming premise. Consider adding sensory details about
how the robot perceives color differently from humans...

Agent workflows use StreamingRun + AgentResponseUpdateEvent — not Run/NewEvents like function workflows. The TrySendMessageAsync(new TurnToken(emitEvents: true)) call kicks off the pipeline and enables event streaming.

Composed workflow

Real pipelines mix deterministic and intelligent steps. Consider PII redaction:

StepWhatWhy not just one?
Regex (function)Mask emails — fast, 100% reliableLLMs hallucinate, regex doesn't
LLM (agent)Rewrite text naturallyRegex can't rephrase prose
Regex (function)Validate no PII leakedTrust but verify — deterministic check

WorkflowBuilder lets you compose both in a single directed graph. First, define the three executors:

var client = new AzureOpenAIClient(new Uri(endpoint!), new DefaultAzureCredential());

// Step 1: Function executor — mask emails with regex
Func<string, string> maskEmails = text =>
    Regex.Replace(text, @"[\w.-]+@[\w.-]+\.\w+", "[EMAIL_REDACTED]");
var maskExecutor = maskEmails.BindAsExecutor("MaskEmails");

// Step 2: Agent executor — wrap LLM agent call as a function executor
AIAgent rewriter = client
    .GetChatClient(deploymentName)
    .AsAIAgent(
        instructions: "You receive text with [EMAIL_REDACTED] placeholders. "
            + "Rewrite the text to sound natural while keeping all redactions intact. "
            + "Do not invent or restore any redacted information. "
            + "Return only the rewritten text.",
        name: "Rewriter"
    );
Func<string, ValueTask<string>> rewriteFunc = async text =>
{
    var response = await rewriter.RunAsync(text);
    return response.ToString();
};
var rewriteExecutor = rewriteFunc.BindAsExecutor("Rewriter");

// Step 3: Function executor — validate no emails leaked
Func<string, string> validate = text =>
{
    var leaks = Regex.Matches(text, @"[\w.-]+@[\w.-]+\.\w+");
    return leaks.Count > 0
        ? $"VALIDATION FAILED - {leaks.Count} email(s) leaked"
        : $"CLEAN - no emails detected\n\n{text}";
};
var validateExecutor = validate.BindAsExecutor("ValidateNoLeaks");

Sync Func<T,R> is auto-wrapped to ValueTask by the framework, while async Func<T, ValueTask<R>> is used for agent calls that involve I/O. Both produce the same Executor — the graph doesn’t care.

Then wire the graph and execute:

// Build graph: mask → rewrite → validate
WorkflowBuilder builder = new(maskExecutor);
builder.AddEdge(maskExecutor, rewriteExecutor);
builder.AddEdge(rewriteExecutor, validateExecutor);
builder.WithOutputFrom(validateExecutor);
var workflow = builder.Build();

var input = """
    Hi team, please contact Alice at alice.smith@example.com for the Q3 report.
    Bob (bob.jones@corp.net) will handle the deployment.
    CC: support@acme.io for any issues.
    """;

await using Run run = await InProcessExecution.RunAsync(workflow, input);

foreach (WorkflowEvent evt in run.NewEvents)
{
    if (evt is ExecutorCompletedEvent executorComplete)
    {
        Console.WriteLine($"[{executorComplete.ExecutorId}]:");
        Console.WriteLine(executorComplete.Data);
        Console.WriteLine();
    }
}
graph LR I[Input text with emails] --> M[MaskEmails
Func — regex] M --> R[Rewriter
AIAgent — LLM] R --> V[ValidateNoLeaks
Func — regex] V --> O[Output]
[MaskEmails]:
Hi team, please contact Alice at [EMAIL_REDACTED] for the Q3 report.
Bob ([EMAIL_REDACTED]) will handle the deployment.
CC: [EMAIL_REDACTED] for any issues.

[Rewriter]:
Hi team, please reach out to Alice at [EMAIL_REDACTED] regarding the Q3 report.
Bob ([EMAIL_REDACTED]) will be managing the deployment.
For any issues, CC [EMAIL_REDACTED].

[ValidateNoLeaks]:
CLEAN - no emails detected

MCP Integration — Agents as servers and clients

Model Context Protocol is an open standard for connecting AI models to external tools and data. The integration is two-sided:

DirectionPatternAPI
Agent as MCP ServerExpose your agents as tools for external MCP clients (Claude Code, VS Code, etc.).AsAIFunction()McpServerTool.Create()
Agent as MCP ClientYour agent discovers and calls tools from remote MCP serversMcpClient.CreateAsync()ListToolsAsync()

MCP Server

Two calls turn any agent into an MCP tool: .AsAIFunction() then McpServerTool.Create():

var client = new AzureOpenAIClient(new Uri(endpoint!), new DefaultAzureCredential());

AIAgent joker = client
    .GetChatClient(deploymentName)
    .AsAIAgent(
        instructions: "You are good at telling jokes.",
        name: "Joker",
        description: "An agent that tells jokes on any topic."
    );

AIAgent weatherAgent = client
    .GetChatClient(deploymentName)
    .AsAIAgent(
        instructions: "You are a helpful weather assistant.",
        name: "WeatherAgent",
        description: "An agent that answers weather questions.",
        tools: [AIFunctionFactory.Create(GetWeather)]
    );

var jokerTool = McpServerTool.Create(joker.AsAIFunction());
var weatherTool = McpServerTool.Create(weatherAgent.AsAIFunction());

var builder = Host.CreateEmptyApplicationBuilder(settings: null);
builder.Services.AddMcpServer().WithStdioServerTransport().WithTools([jokerTool, weatherTool]);

await builder.Build().RunAsync();

[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location) =>
    $"The weather in {location} is cloudy with a high of 15°C.";
sequenceDiagram participant C as MCP Client
(Claude Code) participant S as MCP Server
(stdio) participant W as WeatherAgent participant T as GetWeather C->>S: call WeatherAgent tool S->>W: RunAsync(input) W->>T: GetWeather("Amsterdam") T-->>W: "cloudy, 15°C" W-->>S: response S-->>C: result

Drop a .mcp.json in your repo root and Claude Code / VS Code picks it up automatically:

{
  "mcpServers": {
    "maf-agents": {
      "command": "dotnet",
      "args": ["run", "src/06-agent-as-mcp.cs"],
      "env": {
        "AZURE_OPENAI_ENDPOINT": "https://your-resource.cognitiveservices.azure.com/",
        "AZURE_OPENAI_DEPLOYMENT_NAME": "gpt-4o-mini"
      }
    }
  }
}

dotnet run starts the MCP server as a child process. Communication happens over stdio — no ports, no networking.

MCP Client

Agents can also consume MCP tools. Here’s an agent that uses Microsoft Learn’s public MCP server:

var client = new AzureOpenAIClient(new Uri(endpoint!), new DefaultAzureCredential());

Console.WriteLine("Connecting to Microsoft Learn MCP...");
await using var mcpClient = await McpClient.CreateAsync(
    new HttpClientTransport(
        new()
        {
            Endpoint = new Uri("https://learn.microsoft.com/api/mcp"),
            Name = "Microsoft Learn MCP",
            TransportMode = HttpTransportMode.StreamableHttp,
        }
    ),
    loggerFactory: loggerFactory
);

Console.WriteLine("Listing tools...");
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Discovered {mcpTools.Count} tools:");
foreach (var tool in mcpTools)
    Console.WriteLine($"  - {tool.Name}");

AIAgent agent = client
    .GetChatClient(deploymentName)
    .AsIChatClient()
    .AsBuilder()
    .UseLogging(loggerFactory)
    .Build()
    .AsAIAgent(
        instructions: "You answer questions using Microsoft Learn documentation tools.",
        name: "DocsAgent",
        loggerFactory: loggerFactory,
        tools: [.. mcpTools.Cast<AITool>()]
    );

Console.WriteLine("Running agent...");
Console.WriteLine(await agent.RunAsync("What is Microsoft Agent Framework?"));

HttpClientTransport connects to remote MCP servers (Streamable HTTP), while StdioClientTransport works for local servers. The same ListToolsAsync() API discovers tools in both cases. Discovered McpClientTool instances are cast to AITool and passed directly as agent tools.

A2A — Agent-to-agent communication

Agent-to-Agent Protocol is an open standard for agents to discover and communicate with each other over HTTP.

MCPA2A
Who talksClient → ToolAgent → Agent
Transportstdio / HTTPHTTP + JSON-RPC
DiscoveryConfig file/.well-known/agent-card.json
Use caseExtend agent capabilitiesMulti-agent orchestration

A2A Server

Every A2A agent publishes an AgentCard that clients fetch for discovery:

var client = new AzureOpenAIClient(new Uri(endpoint!), new DefaultAzureCredential());

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(o =>
{
    o.SerializerOptions.TypeInfoResolverChain.Add(
        A2AJsonUtilities.DefaultOptions.TypeInfoResolver!
    );
    o.SerializerOptions.TypeInfoResolverChain.Add(
        A2AJsonUtilities.DefaultOptions.TypeInfoResolver!
    );
});

var app = builder.Build();

AIAgent agent = client
    .GetChatClient(deploymentName)
    .AsIChatClient()
    .AsAIAgent(
        instructions: "You are a helpful assistant.",
        name: "A2AAssistant",
        tools: [AIFunctionFactory.Create(GetWeather)]
    );

AgentCard agentCard = new()
{
    Name = "A2AAssistant",
    Description = "A helpful assistant exposed via A2A protocol.",
    Version = "1.0.0",
    DefaultInputModes = ["text"],
    DefaultOutputModes = ["text"],
    Capabilities = new() { Streaming = false },
    Skills =
    [
        new()
        {
            Id = "general",
            Name = "General Assistant",
            Description = "Answers general questions and checks weather.",
        },
    ],
};

app.MapA2A(
    agent,
    path: "/",
    agentCard: agentCard,
    taskManager => app.MapWellKnownAgentCard(taskManager, "/")
);

await app.RunAsync();

[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location) =>
    $"The weather in {location} is cloudy with a high of 15°C.";

MapA2A() exposes the agent over HTTP, and MapWellKnownAgentCard() serves the card at /.well-known/agent-card.json. Clients discover the agent by fetching the card — no config files, no registry needed.

A2A Client

var host = args.Length > 0 ? args[0] : "http://localhost:5000";

A2ACardResolver resolver = new(new Uri(host));

AIAgent agent = await resolver.GetAIAgentAsync();

Console.WriteLine(await agent.RunAsync("What is the weather in Amsterdam?"));

4 lines to discover a remote agent and call it. A2ACardResolver fetches the agent card, constructs an AIAgent proxy, and you use the same RunAsync() interface as local agents.

AG-UI — Exposing agents to web UIs

Agent User Interface Protocol connects agents to frontend UIs via HTTP POST + Server-Sent Events:

MCPA2AAG-UI
Who talksClient → ToolAgent → AgentUser → Agent
Transportstdio / HTTPHTTP + JSON-RPCHTTP POST + SSE
DiscoveryConfig fileAgent cardURL
Use caseExtend capabilitiesMulti-agent orchestrationServe end users

The client sends one HTTP POST, the server streams back typed SSE events:

PhaseWhat happensSSE Events
StartServer begins processingRUN_STARTED
Text responseTokens stream to UI in real-timeTEXT_MESSAGE_STARTTEXT_MESSAGE_CONTENT* → TEXT_MESSAGE_END
Tool callAgent invokes a functionTOOL_CALL_STARTTOOL_CALL_ARGSTOOL_CALL_END
State updateShared state syncs to clientSTATE_SNAPSHOT or STATE_DELTA
FinishRun completesRUN_FINISHED

AG-UI Server

var client = new AzureOpenAIClient(new Uri(endpoint!), new DefaultAzureCredential());

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
builder.Services.AddCors(o =>
    o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())
);
builder.Services.AddAGUI();

WebApplication app = builder.Build();
app.UseCors();

AIAgent agent = client
    .GetChatClient(deploymentName)
    .AsIChatClient()
    .AsAIAgent(
        instructions: "You are a helpful assistant.",
        name: "AGUIAssistant",
        description: "A helpful assistant that can answer questions and check weather.",
        tools: [AIFunctionFactory.Create(GetWeather)]
    );

app.MapAGUI("/", agent);

await app.RunAsync();

[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location) =>
    $"The weather in {location} is cloudy with a high of 15°C.";

AddAGUI() registers the AG-UI JSON serialization, MapAGUI("/", agent) exposes the agent as an HTTP+SSE endpoint. Two calls from agent to web-ready endpoint.

AG-UI Client

using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(60) };
AGUIChatClient chatClient = new(httpClient, "http://localhost:5000");

AIAgent agent = chatClient.AsAIAgent(name: "agui-client");

await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(
    [new ChatMessage(ChatRole.User, "What's the weather in Amsterdam?")]))
{
    foreach (AIContent content in update.Contents)
        if (content is TextContent text)
            Console.Write(text.Text);
}

AGUIChatClient wraps HttpClient and speaks the AG-UI protocol. From there it’s the familiar AsAIAgent() + RunStreamingAsync() pattern. The full interactive client adds a Spectre.Console chat loop with session management. For richer frontend experiences, check out CopilotKit or AG-UI Dojo.

Key takeaways

  1. BindAsExecutor() — turn any Func<T,R> into a workflow node
  2. AgentWorkflowBuilder.BuildSequential() — chain agents into pipelines with one call
  3. WorkflowBuilder + AddEdge() — compose function and agent executors in a single graph
  4. McpServerTool.Create(agent.AsAIFunction()) — expose agents as MCP tools in two lines
  5. MapA2A() + AgentCard — agents discover and call each other over HTTP
  6. AddAGUI() + MapAGUI() — expose agents to web UIs via HTTP+SSE in two lines

Presentation

References


Oleksii Nikiforov

Jibber-jabbering about programming and IT.