Agent Workflow Patterns: Prompt Chaining, Routing, Parallelization, Orchestrator-Workers and Evaluator-Optimizer
We run five workflow patterns on Bookstore: prompt chaining and orchestrator-workers in plain C#, routing, fan-out/fan-in and a writer-critic loop with MAF WorkflowBuilder. Real runs where a small model approves "must read" and the orchestrator blows the budget, code gates, and exposing a workflow as an agent with AsAIAgent.
Version note: The code in this post was built with .NET 10, Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2 and Microsoft.Agents.AI.Workflows 1.28.0 and run against the gemma3:4b and qwen3:4b models on a local Ollama 0.34.4.
Guess First
Bookstore's marketing team wants two checks on a book blurb: the author must be named, and there must be no exaggerated phrases such as "best book ever" or "must read". You have two options. One: hand the text to a model and ask "is there exaggeration, is the author named?". Two: check the author with string.Contains and leave only the genuinely subjective part, the tone, to the model.
Your guess: will a 4-billion-parameter model approve a text containing "the best book ever written" and "a must read" as "not exaggerated"? In this post we run five workflow patterns on Bookstore, and the answer to that question shows up in two separate places.
Five Patterns at a Glance
In the first post we used the distinction from Anthropic's "Building effective agents": in a workflow, code draws the path. The same article lists the five workflow patterns they see most often in production. In this post each one is matched with a Bookstore job:

Prompt chaining splits a job into sequential steps and puts a code gate between them. Routing classifies the input and sends it down a specialised path. Parallelization runs several independent pieces at once (sectioning) or runs the same piece several times and votes (voting). In orchestrator-workers an LLM (Large Language Model) breaks the job into pieces at run time and hands them to workers. In evaluator-optimizer one model produces, another evaluates, and the loop runs until the output is accepted.
We write some of these in plain C# and some with Microsoft Agent Framework's (MAF) WorkflowBuilder. The split is deliberate: if you don't need a graph, events or checkpoints, moving the code into a framework only adds code.
Prompt Chaining: Plain C# and a Gate
For "Winter in Kyoto" we first extract three selling points and then write a one-sentence ad from them. Code puts a gate between the two steps:
// Prompt chaining with a gate between the steps
public static async Task<string> ChainAsync(IChatClient small, Book book, string synopsis)
{
ChatResponse<SellingPoints> step1 = await small.GetResponseAsync<SellingPoints>(
$"List three selling points of this book. Synopsis: {synopsis}", new ChatOptions { Temperature = 0, Seed = 42 });
string[] points = step1.Result.Points;
// Gate: code decides whether the chain may continue
if (points.Length != 3 || points.Any(p => p.Contains('$')))
{
return $"stopped at gate: {points.Length} points";
}
ChatResponse step2 = await small.GetResponseAsync(
$"Write one sentence ad for '{book.Title}' by {book.Author} using: {string.Join("; ", points)}",
new ChatOptions { Temperature = 0, Seed = 42 });
return $"points: {string.Join(" | ", points)}{Environment.NewLine}ad: {step2.Text.Trim()}";
}
Real output with gemma3:4b (selling points shortened):
points: **Exploration of Family Relationships:** The core of the story revolves around a strained sibling bond ... | **Atmospheric Setting & Cultural Immersion:** Set in the beautiful and serene city of Kyoto ... | **Gentle & Reflective Pace:** The synopsis suggests a slow-burn narrative ...
ad: Lose yourself in “Winter in Kyoto” by Ken Sato – a poignant exploration of family relationships set against the breathtaking beauty of a snowy Kyoto, inviting you into a gentle, reflective story of forgiveness and cultural immersion through the quiet rhythm of letter writing.
small: model calls=2, input tokens=208, output tokens=196, time=8,6s
Two calls, 8.6 seconds. The gate passed, but despite asking for "short selling points" every item is a long paragraph with a markdown heading. Catching exactly this is what the gate is for: a per-item word limit is one line of code. This pattern needs no framework; two awaits and an if.
Routing: WorkflowBuilder, a Switch and a Cheap Model
We split support messages into three paths. The fast gemma3:4b, which doesn't reason, does the classification; the recommendation job, which needs a tool, goes to qwen3:4b; order status and complaints never reach a model:
// Cheap model only labels the question
ExecutorBinding classify = BindAsync<string, RoutedQuestion>("classify", async (question, ct) =>
{
ChatResponse<QuestionKind> label = await small.GetResponseAsync<QuestionKind>(
$"Label this Bookstore customer message: {question}",
new ChatOptions { Temperature = 0, Seed = 42 }, cancellationToken: ct);
return new RoutedQuestion(label.Result, question);
});
// Order status needs no model at all
ExecutorBinding orderStatus = Bind<RoutedQuestion, string>("order-status", q =>
{
Match m = Regex.Match(q.Text, @"\d{4}");
Order? order = data.Orders.FirstOrDefault(o => m.Success && o.Id == int.Parse(m.Value) && o.CustomerId == 7);
return order is null ? "I could not find that order." : $"Order {order.Id} is {order.Status}.";
});
// Open-ended request goes to the larger model with tools
ExecutorBinding recommend = BindAsync<RoutedQuestion, string>("recommend", async (q, ct) =>
{
ChatResponse r = await large.GetResponseAsync(q.Text, new ChatOptions
{
Temperature = 0,
Seed = 42,
Instructions = "Recommend one book from the Bookstore catalog in two sentences. Use search_books.",
Tools = [tools.ReadTools()[0]],
}, ct);
return r.Text.Trim();
});
// Complaints go to people, not to a model
ExecutorBinding complaint = Bind<RoutedQuestion, string>("complaint",
q => $"Ticket T-{Interlocked.Increment(ref ticketNo)} opened for the support team.");
return builder
.AddSwitch(classify, sw => sw
.AddCase<RoutedQuestion>(q => q!.Kind == QuestionKind.OrderStatus, orderStatus)
.AddCase<RoutedQuestion>(q => q!.Kind == QuestionKind.Recommendation, recommend)
.WithDefault(complaint))
.WithOutputFrom(orderStatus, recommend, complaint)
.WithName("BookstoreRouting")
.Build();
Bind and BindAsync are two-line helpers that turn a lambda into an executor with BindAsExecutor(id). An executor is a node in the workflow graph: it takes one type and returns another. Edges connect the nodes; AddSwitch goes to the first matching case, or to WithDefault if none match. Build() validates the graph before anything runs (type compatibility between connected executors, reachability from the start executor). WorkflowVisualizer.ToMermaidString(workflow) returns the graph as Mermaid; this is the real output of the path the code draws:
flowchart TD
classify["classify (Start)"];
order_status["order-status"];
recommend["recommend"];
complaint["complaint"];
classify --> order_status;
classify --> recommend;
classify --> complaint;
The run for three messages:
[classify] completed
[order-status] completed
output: Order 1043 is Shipped.
small: 1 call(s), large: 0 call(s), 0,9s
[classify] completed
[recommend] completed
output: The Quiet Orchard by Mira Holt is a thoughtful novel that matches your preference for quiet storytelling. It's currently priced at $16.50.
small: 1 call(s), large: 2 call(s), 437,9s
[classify] completed
[complaint] completed
output: Ticket T-1001 opened for the support team.
small: 1 call(s), large: 0 call(s), 0,8s
Two of the three messages finished in under a second. The 437 seconds on the recommendation path are qwen3:4b waiting in the queue of a shared Ollama plus its reasoning time; on the same machine gemma3:4b classified in 0.9 seconds. That is the real gain of routing: the expensive path only runs for the messages that need it. The price is classifier error; a complaint labelled as a recommendation gets a book suggestion. That is why complaints sit on WithDefault: anything uncertain goes to a person.
Parallelization: Fan-out and Fan-in
We run a blurb through three independent checks: facts (is the author named, is a price written), length and tone. The first two are code, the third is gemma3:4b:
ExecutorBinding tone = ((Func<string, CancellationToken, ValueTask<CheckResult>>)(async (text, ct) =>
{
ChatResponse<StyleVerdict> v = await small.GetResponseAsync<StyleVerdict>(
$"Approve this book blurb only if it makes no exaggerated claims such as 'best ever' or 'must read': {text}",
new ChatOptions { Temperature = 0, Seed = 42 }, cancellationToken: ct);
return new CheckResult("tone", v.Result.Approved, v.Result.Approved ? "ok" : v.Result.Feedback);
})).BindAsExecutor("tone");
ExecutorBinding report = new ReportExecutor(expected: 3);
return new WorkflowBuilder(start)
.AddFanOutEdge(start, [facts, length, tone])
.AddFanInBarrierEdge([facts, length, tone], report)
.WithOutputFrom(report)
.Build();
AddFanOutEdge sends the same message to three executors; AddFanInBarrierEdge waits until all three finish and delivers the results to ReportExecutor. MAF workflows run in supersteps using a BSP (Bulk Synchronous Parallel) model derived from Pregel: executors that receive messages in the same superstep run concurrently, the messages they emit are delivered in the next superstep, and there is a synchronization barrier between supersteps. The test text is "A Short History of Maps by Lena Ford is the best book ever written about maps, a must read for every traveller." Result:
PASS length: 22 words
PASS facts: author present, no price
PASS tone: ok
small: model calls=1, input tokens=58, output tokens=43, time=2,5s
Here is the first answer to the guess: the model approved a text containing "best book ever written" and "must read", with an instruction that used exactly those two phrases as examples. Running in parallel improved speed, not quality. If a rule can be checked in code (a banned-phrase list is a Regex), don't leave it to the model; use the model only for what is genuinely open to interpretation, and measure that part with an evaluation set.
Orchestrator-Workers: The Plan Is Made at Run Time
For "a gift bundle for a friend who loves maps and quiet novels, total under 60 dollars" the number of searches isn't known in advance. The orchestrator (qwen3:4b) plans the searches, the workers (gemma3:4b) run in parallel, code combines the result. We wrote this one in plain C# too; Task.WhenAll is enough for the fan-out:
ChatResponse<GiftPlan> plan = await large.GetResponseAsync<GiftPlan>(
$"Plan catalog searches for this request. Catalog genres: software, novel, history, science. Request: {request}",
new ChatOptions { Temperature = 0, Seed = 42 });
Console.WriteLine($" plan: {string.Join(" | ", plan.Result.Searches)}");
Pick[] picks = await Task.WhenAll(plan.Result.Searches.Select(async search =>
{
object hits = tools.SearchBooks(search, maxResults: 3);
ChatResponse<Pick> pick = await small.GetResponseAsync<Pick>(
$"Pick the single best book for '{search}' from: {System.Text.Json.JsonSerializer.Serialize(hits)}",
new ChatOptions { Temperature = 0, Seed = 42 });
return pick.Result;
}));
List<Book> chosen = picks.Select(p => data.Books.First(b => b.Id == p.BookId)).Distinct().ToList();
decimal total = chosen.Sum(b => b.Price);
Real output:
plan: history catalog: map atlases (e.g., 'world atlas', 'ancient maps', 'thematic maps') - price range: $15-$25 | novel catalog: quiet novels (e.g., 'literary fiction', 'historical fiction', 'slow-paced novels', 'poetry') - price range: $10-$20 | science catalog: geographic maps (e.g., 'topographic maps', 'geological maps', 'natural history maps') - price range: $5-$15 (for map-focused items) | software catalog: skip (irrelevant for physical map/novel gifts under $60)
A Short History of Maps (history) 27,50
The Quiet Orchard (novel) 16,50
Coffee and Compilers (software) 35,00
total=79,00, within budget=False
large: model calls=1, input tokens=50, output tokens=1751, time=60,1s
small: model calls=4, input tokens=545, output tokens=238, time=29,4s
This run failed, and it is instructive. The fourth item of the plan says "software catalog: skip", but our code treated every item as a search and handed it to a worker; the worker duly picked a software book. The total is 79 dollars and the budget gate says within budget=False. This is exactly where orchestrator-workers is weak: when the plan is free text, everything that reaches the workers depends on the model's interpretation. The fix is to type the plan more strictly (for example a record with a Genre enum and a Skip field), cap the number of workers and check hard rules such as the budget in code at the end. Without the final gate the customer would have been offered a 79-dollar bundle as "under 60 dollars".
Evaluator-Optimizer: A Writer and Critic Loop
The last pattern is a loop. The writer drafts a blurb, the critic evaluates it; if rejected, the revise executor turns the feedback into a new request and sends it back to the writer. The loop ends at the third attempt even without approval:
ExecutorBinding critic = ((Func<Draft, CancellationToken, ValueTask<Review>>)(async (draft, ct) =>
{
int words = draft.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
if (words > 35)
{
return new Review(draft, false, $"It has {words} words; the limit is 35.");
}
// Rules that code can check never go to the model
if (!draft.Text.Contains(draft.Book.Author))
{
return new Review(draft, false, $"Name the author, {draft.Book.Author}.");
}
ChatResponse<StyleVerdict> v = await small.GetResponseAsync<StyleVerdict>(
"You review book blurbs. Reject exaggerated claims ('best', 'must read', 'masterpiece') "
+ $"and blurbs that do not name the author. Blurb: {draft.Text}",
new ChatOptions { Temperature = 0, Seed = 42 }, cancellationToken: ct);
return new Review(draft, v.Result.Approved, v.Result.Feedback);
})).BindAsExecutor("critic");
return new WorkflowBuilder(writer)
.AddEdge(writer, critic)
.AddSwitch(critic, sw => sw
.AddCase<Review>(r => r!.Approved || r.Draft.Attempt >= 3, publish)
.WithDefault(revise))
.AddEdge(revise, writer)
.WithOutputFrom(publish)
.Build();
We ran three versions of this code. In the first, the author check lived only in the critic model's instruction. The writer's first draft didn't mention "Ken Sato" and the gemma3:4b critic approved it on the first attempt: the second answer to the guess. We moved the author check into code (the Contains above) and on the second run code rejected the draft. But because we only told the writer "Name the author, Ken Sato.", it never saw its previous draft and this time returned a 97-word answer with three options; the loop ended with "gave up" on the third attempt. In the third version we gave the writer its previous draft together with the feedback and asked for the text only:
[writer #1] Amidst the snow-dusted temples of Kyoto, a disillusioned American artist seeks solace and connection. But a haunting past and a mysterious local woman threaten to unravel his fragile peace.
[critic] rejected: Name the author, Ken Sato.
[writer #2] Ken Sato’s *Winter in Kyoto* follows a disillusioned American artist searching for peace amidst Kyoto’s snow-covered temples. A haunting past and a mysterious woman challenge his fragile tranquility.
approved after 2 attempt(s): Ken Sato’s *Winter in Kyoto* follows a disillusioned American artist searching for peace amidst Kyoto’s snow-covered temples. A haunting past and a mysterious woman challenge his fragile tranquility.
small: model calls=3, input tokens=222, output tokens=150, time=6,6s
The loop works: code rejected, the writer fixed it, the critic approved. Still, read the text carefully: "disillusioned American artist" and "mysterious woman" are not in the book's plot. We never gave the writer the synopsis, so the model invented a story, and the critic had no way of knowing. An evaluator-optimizer is only as good as what the critic can measure; for factual accuracy the writer needs a source (the synopsis) and the critic should check against the same source.
Exposing a Workflow as an Agent
In MAF a workflow can take on the AIAgent interface with AsAIAgent(); callers simply call RunAsync without knowing it's a workflow. When we wrapped our routing workflow directly, this was the real result:
Unhandled exception. System.InvalidOperationException: Workflow does not support ChatProtocol: At least List<ChatMessage> and TurnToken must be supported as input.
The agent interface talks in message lists; the workflow's start executor expected a string. The fix is to put an executor that speaks the chat protocol at the entrance. This class, derived from ChatProtocolExecutor, accumulates messages and, when a TurnToken arrives, sends the last user message as a string to the next executor:
// Accepts the chat protocol (messages + TurnToken) so the workflow can run as an AIAgent
public sealed class ChatEntryExecutor() : ChatProtocolExecutor("chat-in")
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder).SendsMessage<string>();
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) =>
context.SendMessageAsync(messages.Last(m => m.Role == ChatRole.User).Text, cancellationToken: cancellationToken);
}
The ConfigureProtocol override is required: on the first try we only added a [SendsMessage(typeof(string))] attribute and the executor failed with "cannot send messages of type 'System.String'". After the fix:
Workflow workflow = RoutingWorkflow.Build(small, large, data, tools, chatEntry: args.Length > 1);
Microsoft.Agents.AI.AIAgent agent = workflow.AsAIAgent(id: "routing-agent", name: "BookstoreRouter");
Microsoft.Agents.AI.AgentResponse response = await agent.RunAsync("Where is my order 1044?");
1 message(s): Order 1044 is Processing.
The practical value is large: you can hand the workflow, as if it were an agent, to another workflow, to a multi-agent orchestration, or to the hosting endpoints in post 13 of the AI (Artificial Intelligence) with .NET series. From the outside, it doesn't matter how many model calls or code steps are inside.
Production Notes
Account for the superstep barrier. According to the execution model description on Microsoft Learn, the next superstep doesn't start until every executor in the current one has finished. In a fan-out where one branch is a single long model call and the other a chain of short steps, the chain waits for the long call at every step. Combine sequential steps that need to progress independently into a single executor.
Observe the events. ExecutorInvokedEvent, ExecutorCompletedEvent, ExecutorFailedEvent and WorkflowOutputEvent tell you when each node started and finished. Write them to logs or OpenTelemetry and the trace of a workflow run becomes far more readable than an agent loop's, because node names and order are known up front.
When an executor fails, the workflow stops. Decide what happens on ExecutorFailedEvent (retry, a message to the user, handing over to a person) while you design the workflow. For long-running workflows you can checkpoint at superstep boundaries; we use that in the human-in-the-loop post.
Know when a pattern is overkill. Plain C# was enough for prompt chaining and orchestrator-workers. Reach for WorkflowBuilder when you want to visualize the graph, observe it through events, checkpoint it or expose it as an agent. "Let's use a framework" is not a reason on its own.
Common Pitfalls
Leaving a rule that code can check to the model. We saw it twice in this post: the tone check missed "must read", and the critic missed the missing author. Length, banned phrases, required fields and budgets are checked in code.
Losing context in a feedback loop. Telling the writer only "fix this" without showing the previous draft made the model start from scratch, often with something unrelated. Draft, feedback and source go into the loop together.
Trusting the orchestrator's plan blindly. A free-text plan can send even "skip" instructions to workers as searches. Type the plan, validate it and cap it.
No upper bound on the loop. Without the Attempt >= 3 case in evaluator-optimizer, a draft the critic never approves would loop forever.
Picking the wrong default in routing. The classifier will make mistakes; make sure uncertain input goes to the safest path (usually a person).
A Small Experiment
★☆☆ Add a fourth code check to the parallelization example: FAIL if any phrase from ["best", "must read", "masterpiece"] appears. Run it with the same text; what does the report look like when the model and code checks disagree?
★★☆ Turn GiftPlan into a list of records with a Genre enum and a MaxPrice field, and have the worker search only that genre below that price. Does the budget gate pass this time?
★★★ Give the writer the book's synopsis as well and add a second model check to the critic asking "is there an event or character that is not in the synopsis?". How many attempts until approval, and do the invented characters disappear?
Every pattern in this post had a single model or a few independent model calls. In the next post we move to agents that talk to each other: handoff, group chat, MAF's built-in orchestrations, and handing work to an agent running in another service with the A2A (Agent2Agent) protocol.