P26-05-30">

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. (For the full chapter-to-code map, see the MAF reference architecture overview.)

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. (For the Go errgroup take on the concurrent pattern, see parallel agent dispatch with errgroup.)

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:

  1. 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.
  2. 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.
  3. Handoff is currently blocked on Ollama by an upstream bug in agent-framework-ollama 1.0.0b260521 that forwards allow_multiple_tool_calls= to a kwarg the ollama package 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:

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. For the two additional patterns MAF documents (Group Chat and Magentic), see five MAF orchestration shapes.