Four orchestration patterns in MAF — and when to pick each
Sequential, Concurrent, Handoff, and Custom WorkflowBuilder. Four shapes the Microsoft Agent Framework ships out of the box. Each one is the right answer to a different question.
The Microsoft Agent Framework ships four orchestration topologies as first-class builders. The names are easy. The harder thing is knowing which one your problem actually wants. This post is the side-by-side I wish I had when I started.
from agent_framework.orchestrations import (
SequentialBuilder,
ConcurrentBuilder,
HandoffBuilder,
)
from agent_framework import WorkflowBuilder
Four shapes. Same Agent type going in. Four very different runtime sequences coming out.
1. Sequential — refinement pipeline
Use when: each step's output is the input the next step needs. Refining a draft, summarising a researched brief, classifying then enriching.
from agent_framework.orchestrations import SequentialBuilder
workflow = SequentialBuilder(
participants=[planner, researcher, writer, critic],
output_from="all",
).build()
result = await workflow.run(prompt)
sequenceDiagram
autonumber
User->>Sequential: prompt
Sequential->>Planner: full conversation
Planner-->>Sequential: + planner msg
Sequential->>Researcher: full conversation
Researcher-->>Sequential: + researcher msg
Sequential->>Writer: full conversation
Writer-->>Sequential: + writer msg
Sequential->>Critic: full conversation
Critic-->>Sequential: + critic msg
Sequential-->>User: full conversation
Each agent sees the entire growing conversation. Live run against granite4.1:3b on "Define eventual consistency in one short sentence":
01 [user] Define eventual consistency in one short sentence.
02 [planner] Eventual consistency refers to a data model updated periodically...
03 [researcher] **Sources:** [1] Definition from AI literature on Eventual Consistency
04 [writer] Eventual consistency is a consistency model that ensures all nodes...
05 [critic] STRENGTHS: ... ISSUES: ... LGTM — no revisions needed.
Four agents, four LLM calls, sequential.
2. Concurrent — fan-out / fan-in
Use when: the same prompt benefits from N independent perspectives. Brainstorming, multi-judge review, risk analysis across roles.
from agent_framework.orchestrations import ConcurrentBuilder
workflow = ConcurrentBuilder(
participants=[researcher, marketer, legal]
).build()
sequenceDiagram
autonumber
User->>Concurrent: prompt
par fan-out
Concurrent->>Researcher: prompt
and
Concurrent->>Marketer: prompt
and
Concurrent->>Legal: prompt
end
par fan-in
Researcher-->>Concurrent: result
Marketer-->>Concurrent: result
Legal-->>Concurrent: result
end
Concurrent-->>User: merged
The gotcha I hit: ConcurrentBuilder yields either AgentResponse (per participant) or a pre-merged list[Message] depending on the aggregator. Don't assume one shape:
merged: list[Message] = []
for output in events.get_outputs() or []:
if isinstance(output, AgentResponse):
merged.extend(output.messages)
elif isinstance(output, Message):
merged.append(output)
elif isinstance(output, list):
merged.extend(output)
That defensive code in workflows/concurrent.py only got written after a TypeError: 'AgentResponse' object is not iterable crashed a live run.
3. Handoff — triage + specialist mesh + HITL
Use when: the user's intent decides which specialist responds, and the conversation may continue across turns. Customer support, on-call triage, a one-to-many router.
from agent_framework.orchestrations import HandoffBuilder
workflow = (
HandoffBuilder(
name="customer_support_handoff",
participants=[triage, refund_agent, order_agent, return_agent],
termination_condition=lambda conv: (
len(conv) > 0 and "welcome" in conv[-1].text.lower()
),
)
.with_start_agent(triage)
.build()
)
sequenceDiagram
autonumber
User->>Handoff: prompt
Handoff->>Triage: route
Triage-->>Handoff: handoff: refund_agent
Handoff->>RefundAgent: subtask
RefundAgent-->>Handoff: result
Handoff->>User: request_info (HITL)
User-->>Handoff: response
Handoff->>RefundAgent: continued
RefundAgent-->>Handoff: result
alt termination
Handoff-->>User: closing
else
Handoff->>User: request_info again
end
Three production gotchas worth knowing:
- Every participant needs
require_per_service_call_history_persistence=True. Not just the triage. The error is helpfully explicit if you forget:ValueError: Handoff workflows require all participant agents to have 'require_per_service_call_history_persistence=True'. The following agents are missing this setting: researcher, writer, critic. - The termination condition is your job. Without it the workflow keeps prompting forever. I use
"welcome" in conversation[-1].text.lower()to recognise the assistant's farewell. - Handoff is currently blocked on Ollama by an upstream bug in
agent-framework-ollama 1.0.0b260521that forwardsallow_multiple_tool_calls=to a kwarg theollamapackage never accepted. Handoff works fine on OpenAI and Foundry.
4. Custom — WorkflowBuilder + Executor
Use when: none of the three above fit. Conditional branching, loops, sub-workflows, anything with non-linear edges.
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, executor, handler
from typing_extensions import Never
class Classify(Executor):
@handler
async def classify(self, text: str, ctx: WorkflowContext[str]) -> None:
marker = "SHORT::" if len(text) <= 80 else "LONG::"
await ctx.send_message(marker + text)
@executor(id="finalize")
async def finalize(text: str, ctx: WorkflowContext[Never, str]) -> None:
if text:
await ctx.yield_output(text)
workflow = (
WorkflowBuilder(start_executor=normalize, output_from=[finalize])
.add_edge(normalize, classify)
.add_edge(classify, short_path)
.add_edge(classify, long_path)
.add_edge(short_path, finalize)
.add_edge(long_path, finalize)
.build()
)
flowchart TB
Start([prompt]) --> Normalize
Normalize --> Classify{Classify by length}
Classify -->|≤ 80| ShortPath
Classify -->|> 80| LongPath
ShortPath --> Finalize
LongPath --> Finalize
Finalize --> Out([yield_output])
Two API details that took me a beat:
output_fromis a kwarg onWorkflowBuilder.__init__, not on.build(). The error if you put it on.build()isTypeError: WorkflowBuilder.build() got an unexpected keyword argument 'output_from'. Forward-compatible code passes it on the constructor.- A
WorkflowContext[T_Out]sends messages to downstream nodes withctx.send_message(T_Out). AWorkflowContext[T_Out, T_W_Out]also yields workflow outputs withctx.yield_output(T_W_Out). UseNeverfor either type-parameter when you mean "this node doesn't do that."
A cheat-sheet
| Pattern | Latency | Cost | Best for |
|---|---|---|---|
| Sequential | n × LLM, serial | n × LLM | Refinement pipelines, drafts |
| Concurrent | 1 × LLM (parallel) | n × LLM | Independent perspectives |
| Handoff | varies (HITL) | varies | Routing + multi-turn user interaction |
| Custom graph | depends on graph | depends | Loops, conditional fan-out, sub-workflows |
The default I reach for first is sequential, because most pipelines need refinement, not parallelism. Concurrent is what I reach for second when I want explicit diversity (three judges, three risk perspectives). Handoff is the right shape for support-desk flows and almost nothing else. The custom builder is what you use when one of the previous three "almost works but" — you don't fight the high-level builder, you drop a level.
Running every shape against Ollama
The repo ships a Makefile target per workflow so you can poke at all four:
make sequential PROMPT="In one sentence: what is eventual consistency?"
make concurrent PROMPT="Three perspectives on feature flags."
make custom PROMPT="hello there" # no LLM needed
# make handoff is OpenAI/Foundry only until the Ollama bug is patched
The next post in this series is on the Agent Registry — why MAF doesn't ship one and what to build instead.