Series note — Part of MAF v1: Python and .NET. Builds on Chapter 10 — Workflow Events and Builder by putting real LLM calls on the workflow’s event stream.
Repo — Full runnable code for this chapter is at https://github.com/nitin27may/e-commerce-agents/tree/main/tutorials/11-agents-in-workflows. Clone the repo,
cd tutorials/11-agents-in-workflows, and follow the README.
Why this chapter#
Chapters 09–10 taught raw executors that transform data. Chapter 11 replaces the interesting executors with agents: LLM-powered nodes that take a message in and produce another. Now one workflow can have deterministic steps (validation, enrichment, routing) and LLM steps (translation, summarization, classification) composed in the same graph — same scheduler, same event stream, same checkpoint boundaries.
The canonical example in the MAF docs is English → French → Spanish translation. Each arrow is a real LLM call; the workflow passes one agent’s output straight into the next agent’s input. Two round-trips to Azure OpenAI / OpenAI, one Pregel-style schedule.
What this chapter teaches. Two patterns for running an AIAgent inside a Workflow, with complete working code in both Python and .NET:
- Manual adapter executors — you wrap each agent in a
[MessageHandler]-decoratedExecutor(.NET) / subclassExecutorand write@handlermethods (Python). You get to emit custom events, manage sessions, mix agents with raw executors, short-circuit on errors. Verbose, maximum control. - Convenience builders —
AgentWorkflowBuilder.BuildSequential(agents)(.NET) / feedingAIAgents straight intoWorkflowBuilder(Python). One line; the framework wires the adapters for you. Terse, zero control knobs.
The rest of this series (Ch12 Sequential, Ch13 Concurrent, Ch14 Handoff, Ch16 Magentic) assumes you can reach for either.
Prerequisites#
- Completed Chapter 10 — Workflow Events and Builder
- Python 3.12+ via
uv; .NET 10 SDK - A working
.envat the repo root with eitherOPENAI_API_KEY(OpenAI) orAZURE_OPENAI_ENDPOINT+AZURE_OPENAI_DEPLOYMENT+AZURE_OPENAI_KEY(Azure). The examples cost two LLM calls per run (one French, one Spanish).
The concept#
What happens on the wire#
An agent inside a workflow is still an AIAgent — same .RunAsync() surface you’ve used since Ch01. The difference is that the workflow wants typed messages on edges, not raw agent I/O. MAF bridges this with two primitives:
AgentExecutorRequest— carries a list ofChatMessages and aShouldRespondflag. When a workflow dispatches to an agent-backed executor, it packs the inbound message into this request.ShouldRespond=falsemeans “append to history, don’t call the LLM” — useful for pre-seeding context before another agent takes over.AgentExecutorResponse— carries theAgentResponsethe LLM returned plus the full running conversation so downstream executors can see the complete history.
AgentExecutor (the built-in wrapper MAF provides) takes AgentExecutorRequest in and sends AgentExecutorResponse out. Your workflow input is a plain str (or a ChatMessage, or whatever your domain types are). Somewhere at the boundary you need to coerce. That’s the adapter.
Why adapters exist#
A plain string is not a conversation. The agent wants a ChatMessage with a role, optional tool results, maybe metadata. Likewise, the consumer on the other side of the workflow wants the translated string, not a wrapped response with a conversation history attached. Adapter executors sit at the edges of the workflow to marshal between the domain type and the agent-protocol type.
In Python the adapters are explicit — you write them. In .NET the Workflows SDK accepts AIAgent anywhere it accepts an executor, and packs/unpacks the request for you. You still write adapters when you need custom behaviour; you skip them when you don’t.
The DAG we’re building#
Two agents in a chain, with adapters on the ends:
str]) inAdapter[InputAdapter
Executor] enFr[en-to-fr
AgentExecutor] frEs[fr-to-es
AgentExecutor] outAdapter[OutputAdapter
Executor] llm1[(Azure OpenAI
French turn)] llm2[(Azure OpenAI
Spanish turn)] output([Spanish output
str]) input --> inAdapter inAdapter -- "AgentExecutorRequest" --> enFr enFr -- "LLM call" --> llm1 llm1 -- "French text" --> enFr enFr -- "AgentExecutorResponse" --> frEs frEs -- "LLM call" --> llm2 llm2 -- "Spanish text" --> frEs frEs -- "AgentExecutorResponse" --> outAdapter outAdapter --> output class inAdapter,enFr,frEs,outAdapter core class llm1,llm2 external class input,output infra
Adapters sit at the workflow’s boundaries; agent-executors in the middle speak AgentExecutorRequest / AgentExecutorResponse. Each agent-executor triggers one round-trip to the LLM. The convenience builder (AgentWorkflowBuilder.BuildSequential in .NET, WorkflowBuilder accepting AIAgents directly in Python) emits exactly this graph — the adapters are still there, just not in your source file.
Jargon#
AgentExecutor— the built-in MAFExecutorthat wraps anAIAgent. ConsumesAgentExecutorRequest, emitsAgentExecutorResponse, runs one LLM turn per invocation.AgentExecutorRequest— typed envelope on the inbound edge of an agent-executor. CarriesMessages(a list ofChatMessage) andShouldRespond(bool). The agent only calls the LLM whenShouldRespondis true; otherwise it just appends to the conversation.AgentExecutorResponse— typed envelope on the outbound edge. Carries theAgentResponsethe LLM produced plus the full running conversation.ShouldRespond— the knob that says “actually invoke the LLM” vs “just record this turn”. Lets you pre-seed context from multiple upstreams before the LLM fires.- Adapter executor — a plain
Executorat the workflow’s boundary whose only job is converting between the domain type (say,str) and the agent-protocol type (AgentExecutorRequest/AgentExecutorResponse). Hidden by the convenience builders; explicit when you need them. TurnToken(.NET) — a sentinel message you send into the workflow after the real input, which signals “agents, you may now fire”. .NET’s agent-wrapping executor caches inbound messages and waits for this token so fan-in patterns work. Python doesn’t need it — handlers fire on message arrival.- Session sharing — passing the same
AgentSession(.NET) /sessionkwarg (Python) to two agent-executors makes both agents see the same conversation. Skip it and each gets a fresh session per run.
Code walkthrough#
Source: dotnet/Program.cs. The .NET file teaches both patterns — --sequential for the convenience builder, --manual for the explicit custom executors.
Pattern 1 — AgentWorkflowBuilder.BuildSequential (convenience)#
The one-liner. BuildSequential(agents) accepts a params array of AIAgent and wraps each one as an AgentExecutor behind the scenes — adapters, edges, and all. The workflow input becomes a List<ChatMessage>; the terminal output is a List<ChatMessage> containing the full conversation.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
// Build two translation agents from one IChatClient.
AIAgent enToFr = chatClient.AsAIAgent(
instructions: "Translate the user's message to French. Output ONLY the translation.",
name: "en-to-fr");
AIAgent frToEs = chatClient.AsAIAgent(
instructions: "Translate the user's message to Spanish. Output ONLY the translation.",
name: "fr-to-es");
// One call. The framework wraps each agent as an AgentExecutor and wires
// the adapter plumbing internally.
Workflow workflow = AgentWorkflowBuilder.BuildSequential(enToFr, frToEs);
var messages = new List<ChatMessage> { new(ChatRole.User, "Hello, how are you?") };
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, messages);
// Kick the pipeline. Agent-executors cache inbound messages and only fire
// when a TurnToken arrives — this matters for fan-in patterns where you
// want N upstreams to drop their messages before the agent runs.
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is AgentResponseUpdateEvent update && update.Data is AgentResponseUpdate chunk)
{
Console.Write(chunk.Text); // streaming per-chunk text
}
else if (evt is WorkflowOutputEvent output && output.Data is List<ChatMessage> conversation)
{
var last = conversation.LastOrDefault(m => m.Role == ChatRole.Assistant);
Console.WriteLine(last?.Text); // "Hola, ¿cómo estás?"
}
}Four things to know:
- Pass
AIAgent— notAgentExecutor.BuildSequentialtakes raw agents. It constructs theAgentExecutors internally. TurnTokenis mandatory. TheAgentExecutorwrapper implements a caching protocol: inbound messages accumulate until aTurnTokenarrives, then the LLM fires with the full batch. Forgetting to send one leaves the workflow hanging forever.- Workflow input is
List<ChatMessage>, notstring. That’s what the adapter MAF inserts expects. The terminal output is also aList<ChatMessage>— you read the last assistant message to get the final translation. - Events are
AgentResponseUpdateEventduring streaming, plus one finalWorkflowOutputEvent. NoAgentResponseEventin the stream for this shape — that event is used by the declarative / custom-executor paths, not the auto-wrappedBuildSequentialpath.
Pattern 2 — Manual adapter executors#
The moment you need custom sessions, per-agent event payloads, error handling inside the executor, or mixing agents with raw executors, drop back to the [MessageHandler] pattern from Ch09 and call agent.RunAsync yourself. Each custom executor is an adapter: it marshals the message it received into an agent call, then forwards the agent’s text downstream as a plain string.
[SendsMessage(typeof(string))]
internal sealed partial class TranslationAgentExecutor : Executor
{
private readonly AIAgent _agent;
public TranslationAgentExecutor(string id, IChatClient chatClient, string targetLanguage)
: base(id)
{
_agent = chatClient.AsAIAgent(
instructions: $"Translate to {targetLanguage}. Output ONLY the translation.",
name: id);
}
[MessageHandler]
public async ValueTask HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// One LLM turn. We own the session story, the event story,
// and the error story here.
AgentResponse response = await _agent.RunAsync(message, cancellationToken: cancellationToken);
await context.AddEventAsync(new TranslationCompletedEvent(Id, response.Text), cancellationToken);
await context.SendMessageAsync(response.Text, cancellationToken);
}
}Wire it with the usual WorkflowBuilder. Both adapters (InputAdapter, OutputAdapter) are one-line [MessageHandler] executors that forward their message unchanged — that’s the shape you’d extend into real validation, DTO coercion, or enrichment code:
var inputAdapter = new InputAdapter();
var frenchExecutor = new TranslationAgentExecutor("en-to-fr", chatClient, "French");
var spanishExecutor = new TranslationAgentExecutor("fr-to-es", chatClient, "Spanish");
var outputAdapter = new OutputAdapter();
Workflow workflow = new WorkflowBuilder(inputAdapter)
.AddEdge(inputAdapter, frenchExecutor)
.AddEdge(frenchExecutor, spanishExecutor)
.AddEdge(spanishExecutor, outputAdapter)
.WithOutputFrom(outputAdapter)
.Build();
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "Hello, how are you?");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is TranslationCompletedEvent t)
Console.WriteLine($" [{t.ExecutorId}] {t.Text}");
else if (evt is WorkflowOutputEvent o && o.Data is string final)
Console.WriteLine($"output: {final}");
}Three things to know:
- No
TurnToken. The manual pattern uses plain[MessageHandler]executors, andHandleAsyncfires on arrival.TurnTokenis specific to the auto-wrappedAgentExecutor. - Your executor is the adapter.
TranslationAgentExecutoris exactly the PythonInputAdapter→AgentExecutor→OutputAdaptersandwich, collapsed into a single node. It marshalsstring→agent.RunAsync(string)→stringand emits a customTranslationCompletedEventso downstream consumers can render per-turn progress. - You still need
[SendsMessage]/[YieldsOutput]. These are the Ch09 attributes. The source generator uses them to wire the protocol override at build time. Omit them and you’ll get the usualCS0534cryptic error.
Build and run#
cd tutorials/11-agents-in-workflows/dotnet
dotnet build
# Convenience — one-liner builder.
dotnet run -- --sequential "Hello, how are you?"
# mode: Sequential
# input: 'Hello, how are you?'
# [en-to-fr] Bonjour, comment ça va ?
# [fr-to-es] Hola, ¿cómo estás?
# output: 'Hola, ¿cómo estás?'
# Manual — custom [MessageHandler] executors.
dotnet run -- --manual "Good night"
# mode: Manual
# input: 'Good night'
# [en-to-fr] Bonne nuit
# [fr-to-es] Buenas noches
# output: 'Buenas noches'
# Tests (5 xUnit tests — deterministic, no LLM).
cd tests && dotnet testThe project wires Microsoft.Agents.AI.Workflows 1.1.0 and the matching Microsoft.Agents.AI.Workflows.Generators 1.1.0 source generator (same pair as Ch09/Ch10) plus Microsoft.Agents.AI 1.1.0 and Microsoft.Agents.AI.OpenAI 1.1.0 for the chat client. The .env loader at the top of Program.cs walks up from AppContext.BaseDirectory to find the repo-root .env, matching the convention from Ch01.
Source: python/main.py. This is the explicit-adapters version — one InputAdapter, two AgentExecutor nodes, one OutputAdapter, four add_edge calls.
from agent_framework import Agent, Message
from agent_framework._workflows._agent_executor import (
AgentExecutor, AgentExecutorRequest, AgentExecutorResponse,
)
from agent_framework._workflows._executor import Executor, handler
from agent_framework._workflows._workflow_builder import WorkflowBuilder
from agent_framework._workflows._workflow_context import WorkflowContext
def translator(target_language: str, name: str) -> Agent:
return Agent(
_default_client(),
instructions=(
f"You are a translator. Translate to {target_language}. "
"Output ONLY the translation — no preamble."
),
name=name,
)
class InputAdapter(Executor):
def __init__(self) -> None:
super().__init__(id="input-adapter")
@handler
async def run(self, message: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
await ctx.send_message(AgentExecutorRequest(
messages=[Message(role="user", contents=[message])],
should_respond=True, # actually call the LLM (vs. pre-seed history)
))
class OutputAdapter(Executor):
def __init__(self) -> None:
super().__init__(id="output-adapter")
@handler
async def run(self, response: AgentExecutorResponse, ctx: WorkflowContext[None, str]) -> None:
await ctx.yield_output(response.agent_response.text)
def build_workflow():
input_adapter = InputAdapter()
en_to_fr = AgentExecutor(translator("French", name="en-to-fr"), id="en-to-fr")
fr_to_es = AgentExecutor(translator("Spanish", name="fr-to-es"), id="fr-to-es")
output_adapter = OutputAdapter()
return (
WorkflowBuilder(start_executor=input_adapter)
.add_edge(input_adapter, en_to_fr)
.add_edge(en_to_fr, fr_to_es)
.add_edge(fr_to_es, output_adapter)
.build()
)Running it:
uv run python tutorials/11-agents-in-workflows/python/main.py "Hello, how are you?"
# English input: Hello, how are you?
# Spanish output: Hola, ¿cómo estás?Two real LLM calls happen under the hood. The InputAdapter wraps the plain-string input in an AgentExecutorRequest; en-to-fr calls Azure OpenAI with the French instructions and emits an AgentExecutorResponse; fr-to-es receives that response, extracts the French text, translates to Spanish, emits another response; OutputAdapter pulls the Spanish string out and yields it as the workflow’s terminal output.
Want the shorter version? Python accepts an AIAgent as the start executor of a workflow too — WorkflowBuilder(start_executor=agent).add_edge(agent, next_agent).build(). That’s the “convenience” path in Python; the adapters are auto-inserted around the agents. Reach for it when the pipeline is pure agent-to-agent and your input is a ChatMessage or str that can be coerced automatically.
Which pattern when#
| You need… | Reach for |
|---|---|
| A straight agent-to-agent chain | AgentWorkflowBuilder.BuildSequential (.NET) / WorkflowBuilder(start_executor=agent).add_edge(agent, next) (Python) |
| Per-agent custom events on the stream | Manual adapter executors |
| Agents mixed with raw data executors (validate, enrich, transform) | Manual adapter executors |
| Fan-out to several agents with a custom aggregator | AgentWorkflowBuilder.BuildConcurrent + a reduce executor, or fully manual |
| Human-in-the-loop between two agents | Manual (you need a RequestInfo/ResponseHandler pair between the agents) |
| Session sharing across agents (all see the same conversation) | Either pattern — pass one AgentSession to each agent-backed executor |
| Tool approval gates between agents | Manual (gate in function middleware on the inner AIAgent) |
The default is convenience. The production capstone ends up with manual adapters everywhere because domain events are load-bearing (see “How this shows up in the capstone” below).
Side-by-side differences#
| Aspect | Python | .NET |
|---|---|---|
| Wrap an agent (manual) | AgentExecutor(agent, id="...") | [MessageHandler] executor that calls agent.RunAsync(...) |
| Wrap an agent (auto) | WorkflowBuilder(start_executor=agent).add_edge(agent, next) | AgentWorkflowBuilder.BuildSequential(agentA, agentB) |
| Request envelope | AgentExecutorRequest(messages=[...], should_respond=True) | MAF-internal AgentExecutorRequest (produced by the auto-adapter) |
| Response envelope | AgentExecutorResponse(executor_id, agent_response, full_conversation) | MAF-internal AgentExecutorResponse |
| Workflow input (auto pattern) | str or ChatMessage | List<ChatMessage> |
| Kicking the pipeline | Just call workflow.run(input, stream=True) | Send the input, then run.TrySendMessageAsync(new TurnToken(emitEvents: true)) |
| Streaming LLM output | event.type == "data" with AgentResponseUpdate payload | AgentResponseUpdateEvent.Data is AgentResponseUpdate |
| Session sharing | session= kwarg on each AgentExecutor | ChatClientAgentOptions.ChatOptions.ConversationId or an explicit AgentSession |
The mental model is identical. Python’s adapters are explicit where .NET’s are hidden behind BuildSequential; both hop on the same runtime and emit the same event shapes. Python skips TurnToken because @handler fires on message arrival; .NET needs it because the auto-generated AgentExecutor wrapper intentionally batches inbound messages until the token drops.
Gotchas#
- Don’t send raw strings between agent-executors. When you use
AgentExecutordirectly (Python’s explicit form), it expectsAgentExecutorRequeston the inbound edge and emitsAgentExecutorResponseon the outbound edge. Piping astrinto it silently routes nothing. If you’re mixing raw and agent nodes, either adapt at each boundary or use the convenience builder that handles it for you. ShouldRespond=Falseskips the LLM but appends to history. Useful for pre-seeding context from two upstreams into a third agent — the first two emitAgentExecutorRequest(..., should_respond=False), the third agent sees both turns in its history before firing. Misuse and the agent never calls the LLM; it stays silent while the workflow waits for an output that never comes.- TurnToken is easy to forget.
AgentWorkflowBuilder.BuildSequentialand direct-agentAddEdgeboth produce a workflow whose first event is a no-op untilTurnTokenarrives. Symptom:RunStreamingAsyncreturns,WatchStreamAsyncyields theWorkflowStartedEvent, and then nothing happens. Fix:await run.TrySendMessageAsync(new TurnToken(emitEvents: true))right afterRunStreamingAsync. - Unique executor ids are still required. Two
AgentExecutors on the same agent can’t share an id.BuildSequentialgenerates ids likeen_to_fr_<guid>to avoid the problem — if you want readable ids, use the manual pattern. List<ChatMessage>vsstringfor workflow input.BuildSequential’s adapters expectList<ChatMessage>; the manual pattern from this chapter usesstring. Wire a mismatched input type and the workflow will throw at runtime with “no handler for type X” because the adapter’s[MessageHandler]signature is specific.- Session sharing crosses conversation boundaries. Two agent-executors sharing the same
AgentSessionwill both see every message — including the other’s system prompt turn history. That’s usually what you want for classify-then-answer patterns; it’s usually not what you want for parallel specialists that should each start with a fresh context. - Don’t mix
[SendsMessage(typeof(AgentExecutorResponse))]with convenience builders. The convenience path’s auto-generatedAgentExecutordoesn’t expose itsAgentExecutorResponseon outbound edges — that shape exists only between internal adapters. If you want to inspect the response, switch to the manual pattern or subscribe toAgentResponseEventon the event stream.
Tests#
# Python — 1 wiring test + 3 real-LLM integration tests (skipped if no creds).
source agents/.venv/bin/activate
python -m pytest tutorials/11-agents-in-workflows/python/tests/ -v
# 4 passed (3 hit real Azure OpenAI / OpenAI)
# .NET — 5 xUnit tests, all deterministic (fake IChatClient, no network).
cd tutorials/11-agents-in-workflows/dotnet/tests
dotnet test --nologo
# Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5, Duration: <100msThe fake IChatClient in the .NET test file is the pattern to reuse for every workflow-chapter test from here on. Real LLM tests go in smoke/nightly suites; the deterministic fake catches the wiring bugs that would otherwise fire on every PR.
How this shows up in the capstone#
- Phase 7 —
plans/refactor/08-pre-purchase-concurrent.md. The pre-purchase workflow wraps each specialist (ProductDiscovery,PricingPromotions,InventoryFulfillment) as a manual agent-adapter executor so the executor can emitOrderProgressEvent/ReviewSummaryEventpayloads on the event stream. Those payloads travel verbatim to the frontend as SSE chunks. The refactor usesAgentWorkflowBuilder.BuildConcurrentfor the fan-out layer and manual adapters for the conditionalEstimateShippingstep. - Phase 8 —
plans/refactor/10-orchestrator-to-handoff.md. The top-level orchestrator moves from a single-agent call-router to aHandoffBuildermesh. Each mesh node is anAIAgentthe builder wraps as anAgentExecutorunder the hood — the same primitive from this chapter, stitched into a graph with handoff tool-call edges. - .NET port —
plans/dotnet-port/02-orchestrator.md. The .NET orchestrator port uses the manual pattern for every node becauseTreatWarningsAsErrors=trueand the custom event subclasses ride through theWorkflowEventtype hierarchy cleanly.
In other words: the convenience path is great for learning and for straight chains; production work in both languages gravitates to manual adapters because domain events and custom sessions matter.
Further reading#
- MAF docs — Agents in Workflows
- MAF docs — Executors
- Sample —
03_AgentWorkflowPatterns(Sequential / Concurrent / Handoff / GroupChat viaAgentWorkflowBuilder) - Sample —
CustomAgentExecutors(manual adapters with structured output)
What’s next#
- Next chapter: Chapter 12 — Sequential Orchestration takes the sequential pattern seriously — turn limits, content filters, handoff conditions. Builds straight on
AgentWorkflowBuilder.BuildSequentialfrom this chapter. - Full source: tutorials/11-agents-in-workflows

