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:

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}");
    }
}
graph LR Input["Hello, World!"] --> U[UppercaseExecutor] U --> R[ReverseTextExecutor] R --> Output["!DLROW ,OLLEH"]
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();
    }
}
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:

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();
    }
}
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:

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.";
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.

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_STARTTEXT_MESSAGE_CONTENT* → TEXT_MESSAGE_END
Tool call Agent invokes a function TOOL_CALL_STARTTOOL_CALL_ARGSTOOL_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

  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

Pragmatic AI-assisted engineering, with care for the craft.