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.
- TL;DR
- Introduction — From agents to systems
- Workflows — Orchestrating agents as graphs
- MCP Integration — Agents as servers and clients
- A2A — Agent-to-agent communication
- AG-UI — Exposing agents to web UIs
- Key takeaways
- Presentation
- References
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:
| Protocol | Who talks | Transport | Use case |
|---|---|---|---|
| MCP Server | Client → Your Agent | stdio / HTTP | Expose agents as tools |
| MCP Client | Your Agent → Remote Tools | stdio / HTTP | Consume external tools |
| A2A | Agent → Agent | HTTP + JSON-RPC | Multi-agent orchestration |
| AG-UI | User → Agent | HTTP POST + SSE | Serve 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:
| Pattern | Use Case | API |
|---|---|---|
| Function Workflow | Pure data transformations, no LLM | BindAsExecutor() + WorkflowBuilder |
| Agent Workflow | LLM-powered multi-agent pipelines | AgentWorkflowBuilder.BuildSequential() |
| Composed Workflow | Mix functions + agents in one graph | WorkflowBuilder + AddEdge() |
The building blocks:
| Building Block | API | Description |
|---|---|---|
| Executor | Func<T,R>.BindAsExecutor() | A node in the workflow graph |
| Edge | builder.AddEdge(A, B) | Connects two executors (A → B) |
| Output | builder.WithOutputFrom(B) | Designates the final output node |
| Run | InProcessExecution.RunAsync() | Executes the workflow |
| StreamingRun | InProcessExecution.RunStreamingAsync() | Executes with streaming events |
| Events | ExecutorCompletedEvent, AgentResponseUpdateEvent | Emitted 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}");
}
}
| Step | Executor | Output |
|---|---|---|
| Input | — | "Hello, World!" |
| 1 | UppercaseExecutor | "HELLO, WORLD!" |
| 2 | ReverseTextExecutor | "!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();
}
}
[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:
| Step | What | Why not just one? |
|---|---|---|
| Regex (function) | Mask emails — fast, 100% reliable | LLMs hallucinate, regex doesn't |
| LLM (agent) | Rewrite text naturally | Regex can't rephrase prose |
| Regex (function) | Validate no PII leaked | Trust 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();
}
}
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:
| Direction | Pattern | API |
|---|---|---|
| Agent as MCP Server | Expose your agents as tools for external MCP clients (Claude Code, VS Code, etc.) | .AsAIFunction() → McpServerTool.Create() |
| Agent as MCP Client | Your agent discovers and calls tools from remote MCP servers | McpClient.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.";
(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.
| MCP | A2A | |
|---|---|---|
| Who talks | Client → Tool | Agent → Agent |
| Transport | stdio / HTTP | HTTP + JSON-RPC |
| Discovery | Config file | /.well-known/agent-card.json |
| Use case | Extend agent capabilities | Multi-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:
| MCP | A2A | AG-UI | |
|---|---|---|---|
| Who talks | Client → Tool | Agent → Agent | User → Agent |
| Transport | stdio / HTTP | HTTP + JSON-RPC | HTTP POST + SSE |
| Discovery | Config file | Agent card | URL |
| Use case | Extend capabilities | Multi-agent orchestration | Serve end users |
The client sends one HTTP POST, the server streams back typed SSE events:
| Phase | What happens | SSE Events |
|---|---|---|
| Start | Server begins processing | RUN_STARTED |
| Text response | Tokens stream to UI in real-time | TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT* → TEXT_MESSAGE_END |
| Tool call | Agent invokes a function | TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END |
| State update | Shared state syncs to client | STATE_SNAPSHOT or STATE_DELTA |
| Finish | Run completes | RUN_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
BindAsExecutor()— turn anyFunc<T,R>into a workflow nodeAgentWorkflowBuilder.BuildSequential()— chain agents into pipelines with one callWorkflowBuilder+AddEdge()— compose function and agent executors in a single graphMcpServerTool.Create(agent.AsAIFunction())— expose agents as MCP tools in two linesMapA2A()+AgentCard— agents discover and call each other over HTTPAddAGUI()+MapAGUI()— expose agents to web UIs via HTTP+SSE in two lines
Presentation
References
- Topics:
- dotnet (62) ·
- ai (17) ·
- dotnet (67) ·
- maf (2) ·
- agents (11) ·
- microsoft-extensions-ai (2) ·
- microsoft-agent-framework (2) ·
- mcp (10) ·
- agui (1) ·
- a2a (1)