
Build a Multi-Agent System: Planner, Worker, and Reviewer
A single agent grading its own work shares its own blind spots. Splitting planning, execution, and review into three passes is what actually catches mistakes.
Ask a single agent to write a script, then ask that same agent, in the same turn, to check its own script for bugs, and you've asked one context to be both the author and the auditor. It usually says the code looks fine. Of course it does: the same reasoning that produced the bug is the reasoning being asked to catch it. If a model missed a division-by-zero case while writing a function, there's no strong reason it will suddenly notice that gap two sentences later just because you asked it to "double-check."
This is the actual case for a three-role multi-agent system: not that three agents are smarter than one, but that a Planner, a Worker, and a Reviewer each see the task from a genuinely different vantage point, with the Reviewer specifically built to never share the Worker's blind spot. This is a hands-on build of that exact pattern, plan a task, execute it, review it against the plan, retry on rejection, all with real code.
The one sentence to remember
The value of a separate Reviewer isn't a second opinion for its own sake, it's a differently-prompted pass, in a clean context, checking the work against criteria the Worker never got to grade itself on. Self-review inside one context tends to rubber-stamp its own reasoning; an independent review pass doesn't have that reasoning to protect.
This is the hands-on companion to Multi-Agent AI Systems: When One Agent Is Not Enough. That article covers the general supervisor-worker architecture and, just as importantly, when not to reach for it. This one builds a specific, narrower shape of that pattern: a Planner that decomposes a goal, a Worker that executes one step at a time, and a Reviewer that grades each result before the next step is allowed to start.
The Architecture
Three roles, three separate contexts, on purpose
The Planner never executes anything. The Worker never grades its own output against the original acceptance criteria, it only sees the step it was asked to do. The Reviewer never writes code or performs the task, it only judges a result against a spec it didn't write and didn't execute. Collapsing any two of these into one context is exactly what reintroduces the self-review blind spot this whole architecture exists to avoid.
Why Split Into Three Roles Instead of One
Multi-Agent AI Systems names three conditions where multiple agents actually earn their cost: context pollution, genuine parallelism, and real specialization. This pattern is a direct instance of specialization, and it's worth being precise about which specialization is actually happening.
| Role | What it's specialized for | What it deliberately doesn't do |
|---|---|---|
| Planner | Decomposing a goal into ordered, checkable steps | Never executes a step or judges a result |
| Worker | Producing one concrete result per step | Never decides whether its own result is good enough |
| Reviewer | Judging a result against criteria it didn't write | Never fixes the problem it finds |
The Reviewer's value comes from what it doesn't know, not what it knows
The Reviewer is deliberately handed a clean context: the step's acceptance criteria and the Worker's result, nothing else. It never sees the Worker's reasoning, its false starts, or its internal justification for why a shortcut was fine. That absence is the entire point, a reasoning trail is exactly what makes self-review too forgiving.
The Contract Between the Three Roles
Before writing a single prompt, the roles need a shared, structured contract, the same principle covered under Agent Communication in Multi-Agent AI Systems, made concrete here as the actual data each role passes to the next.
{
"step": {
"id": "step-2",
"description": "Write a Pester test for Get-LowDiskDevices covering the zero-free-space edge case",
"acceptance_criteria": [
"Test file uses Pester v5 syntax (Describe/It/Should)",
"Covers a device reporting 0 bytes free without throwing",
"Covers a device above the 10% threshold, expected to be excluded from results"
]
},
"result": {
"step_id": "step-2",
"artifact": "<the actual code or text the Worker produced>",
"notes": "Assumptions the Worker made while executing this step"
},
"verdict": {
"step_id": "step-2",
"approved": false,
"feedback": "The zero-free-space case isn't covered. Add an It block asserting no exception is thrown when FreeSpace is 0."
}
}Acceptance criteria belong to the Planner, not the Worker
If the Worker is allowed to write its own acceptance criteria, it will, unsurprisingly, write criteria its own output already satisfies. The criteria have to be fixed at planning time, before the Worker has produced anything to grade, or the Reviewer ends up checking the Worker's work against the Worker's own definition of done.
The Planner
The Planner's only job is decomposition: turn a goal into an ordered list of steps, each with acceptance criteria specific enough that a Reviewer with no other context could actually judge a result against them.
import json
import anthropic
client = anthropic.Anthropic()
PLANNER_SYSTEM_PROMPT = """You are a planning agent. Break the user's goal into an \
ordered list of concrete steps. Each step needs acceptance criteria specific enough \
that someone with no other context could judge a finished result against them. \
Respond with JSON only, matching this shape:
{"steps": [{"id": "step-1", "description": "...", "acceptance_criteria": ["...", "..."]}]}
"""
def create_plan(goal: str) -> dict:
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
system=PLANNER_SYSTEM_PROMPT,
messages=[{"role": "user", "content": goal}],
)
return json.loads(response.content[0].text)Vague criteria produce a Reviewer that can't actually reject anything
"Write good tests" isn't acceptance criteria, it's a wish. A Reviewer handed that has nothing concrete to check a result against and will end up approving almost anything. Push the specificity into the Planner: "covers the zero-free-space edge case without throwing" is something a Reviewer can actually verify against a result. This is the same discipline as writing a real evaluation rubric, covered in full in LLM Evaluation, just applied per-step instead of per-release.
The Worker
The Worker executes exactly one step at a time. It never sees the full plan, only the current step and, on a retry, the Reviewer's feedback from the previous attempt.
WORKER_SYSTEM_PROMPT = """You are an execution agent. You will be given one step \
and its acceptance criteria. Produce the artifact that satisfies them. If feedback \
from a previous attempt is included, address it directly. Respond with JSON only:
{"artifact": "...", "notes": "..."}
"""
def execute_step(step: dict, feedback: str | None = None) -> dict:
prompt = f"Step: {step['description']}\nAcceptance criteria:\n"
prompt += "\n".join(f"- {c}" for c in step["acceptance_criteria"])
if feedback:
prompt += f"\n\nYour previous attempt was rejected. Feedback: {feedback}"
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
system=WORKER_SYSTEM_PROMPT,
messages=[{"role": "user", "content": prompt}],
)
return json.loads(response.content[0].text)Scope the Worker's tool access to exactly this step
If a step involves reading a file, calling an API, or touching a real system, give the Worker only the tools that specific step needs, not the full toolset every other step in the plan might use. This is the same least-privilege reasoning from AI Agent Identity: a Worker mid-way through "write a test file" has no legitimate reason to hold a tool that can execute PowerShell against a production device. When a step genuinely is "run a real command," the layered architecture for doing that safely, an allowlisted action menu, scoped credentials, and audit logging, is covered in full in Build an AI Agent That Can Safely Execute PowerShell Commands.
The Reviewer
The Reviewer is the piece that makes this whole architecture worth the extra API calls. It gets the step's acceptance criteria and the Worker's result, in a fresh context that never saw how the Worker arrived at that result.
REVIEWER_SYSTEM_PROMPT = """You are a strict reviewer. You will be given a step's \
acceptance criteria and a result. Check the result against each criterion \
individually. Approve only if every criterion is clearly met. Be specific about \
what's missing when you reject. Respond with JSON only:
{"approved": true, "feedback": "..."}
"""
def review_step(step: dict, result: dict) -> dict:
prompt = (
f"Acceptance criteria:\n"
+ "\n".join(f"- {c}" for c in step["acceptance_criteria"])
+ f"\n\nResult to review:\n{result['artifact']}"
)
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=REVIEWER_SYSTEM_PROMPT,
messages=[{"role": "user", "content": prompt}],
)
return json.loads(response.content[0].text)This is an LLM-as-judge, so it inherits every LLM-as-judge failure mode
A Reviewer built this way can be too lenient, too harsh, or inconsistent between runs, the exact grading problems covered in LLM Evaluation. Two things keep it honest here: criteria specific enough to check individually rather than judge holistically, and, where a criterion can be checked deterministically, a plain code check beats a model's opinion every time. If "the function doesn't throw on zero free space" can be verified by actually running the test, run the test. Reach for the Reviewer's judgment only for the parts a script genuinely can't verify, whether an explanation is clear, whether an edge case was reasoned about correctly, not for anything a simple try/except could answer more reliably.
The Orchestrator Loop
The orchestrator is plain code, not another agent, it doesn't need judgment, it needs to reliably run the same sequence every time.
Ask the Planner for a plan, once, up front
plan = create_plan(goal)The plan is fixed for the rest of the run. If a step turns out to be impossible given what the Worker discovers while executing it, that's a signal to stop and escalate, not a license for the Worker to quietly redefine the step.
For each step, run the Worker, then the Reviewer
def run_plan(goal: str, max_retries: int = 2) -> list[dict]:
plan = create_plan(goal)
completed = []
for step in plan["steps"]:
feedback = None
for attempt in range(1, max_retries + 2):
result = execute_step(step, feedback)
verdict = review_step(step, result)
if verdict["approved"]:
completed.append({"step": step, "result": result})
break
feedback = verdict["feedback"]
if attempt == max_retries + 1:
return escalate(step, result, verdict, completed)
return completedFeed a rejection's feedback back into the next attempt, don't start over blind
The Worker's second attempt gets the Reviewer's specific feedback, not just a bare "try again." A Worker retrying without knowing what actually failed tends to either repeat the same mistake or overcorrect into a different one.
Bound the retries, and give up loudly rather than silently
escalate() is a function you write, not a fallback that quietly ships an unapproved result. At minimum it should log the step, the last rejected result, the Reviewer's final feedback, and everything completed so far, then hand the whole thing to a human. This is the same retryable-versus-not distinction from Failure Handling in Multi-Agent AI Systems: a step that fails review twice in a row isn't a transient glitch worth a third automatic try, it's a sign the step itself, or the criteria written for it, needs a person to look at it.
A Full Trace
Tracing one goal through the whole loop, "write a PowerShell function that flags devices under 10% free disk space, and prove it handles the zero-free-space edge case":
| Step | What happened |
|---|---|
| Planner | Produces three steps: write Get-LowDiskDevices, write a Pester test for it, update the changelog. Step 2's criteria explicitly require covering zero free space without throwing. |
| Worker, step 1, attempt 1 | Writes the function. It divides FreeSpace by TotalSize to get a percentage, with no check for TotalSize being zero. |
| Reviewer, step 1, attempt 1 | Rejects. Feedback: "No handling for a device reporting zero total size, this would throw a divide-by-zero." |
| Worker, step 1, attempt 2 | Adds a guard clause, treats zero total size as unmeasurable rather than dividing. |
| Reviewer, step 1, attempt 2 | Approves. |
| Worker, step 2, attempt 1 | Writes a Pester test covering the normal case and the above-threshold case, but not zero free space specifically. |
| Reviewer, step 2, attempt 1 | Rejects. Feedback matches the earlier JSON example: the zero-free-space case isn't covered. |
| Worker, step 2, attempt 2 | Adds the missing It block. |
| Reviewer, step 2, attempt 2 | Approves. |
| Worker, step 3 | Updates the changelog. Approved on the first attempt. |
Notice where the value actually showed up
Both rejections caught a real gap, one in the code, one in the test meant to catch problems in that code, and neither gap was something the Worker flagged about its own output. A single agent asked to "write the function and test it yourself" had already written the version with the missing guard clause once. Nothing about asking it to review its own work in the same breath was likely to surface that gap; a separate pass, with nothing invested in the first version being right, is what actually did.
Guarding Against Runaway Loops
A retry loop with no ceiling is a cost incident waiting to happen
Every rejected attempt is a full Worker call plus a full Reviewer call. A step that's genuinely impossible, or acceptance criteria that quietly contradict each other, will happily consume retries forever if nothing stops it. max_retries in the orchestrator above isn't a style choice, it's the difference between a bounded, budgeted run and one that keeps calling the API until someone notices the bill.
Three limits are worth setting explicitly, not left as implicit defaults:
| Limit | What it prevents |
|---|---|
| Retries per step | One step's rejection loop from consuming the entire run's budget |
| Total steps per plan | A Planner that decomposes a simple goal into an unreasonably long plan |
| Wall-clock or token budget for the whole run | A slow accumulation across many approved-but-expensive steps, not just an obvious runaway loop |
The full discipline for tracing exactly where a run's time and cost went, not just that it went somewhere, is covered in AI Observability. At minimum, log every Worker attempt and every Reviewer verdict against the run, so a slow or expensive plan is diagnosable after the fact instead of a mystery.
Self-Review Versus an Independent Reviewer
Test this claim yourself before trusting it
Don't take "self-review misses things an independent pass catches" on faith. Run the same goal two ways: once as a single agent asked to write the code and then check its own work in the same conversation, once through the Planner, Worker, Reviewer loop above. Compare what each version actually catches. The gap is the entire argument for this architecture, and it's more convincing measured on your own task than read in an article.
| Single agent, self-review | Planner, Worker, Reviewer | |
|---|---|---|
| Context the check runs in | Same conversation that produced the work | Fresh context, no visibility into the Worker's reasoning |
| What "acceptance criteria" means | Whatever the agent decides after the fact | Fixed by the Planner before the Worker starts |
| Cost per task | One API call | At minimum three, more with retries |
| Most likely failure mode | Approves its own reasoning, misses shared blind spots | Costs more, and a badly specified criterion still slips through |
Neither column is free of failure modes. The honest case for the three-role version isn't that it's failure-proof, it's that its likely failures are different from a single agent's, and specifically don't include "agreeing with itself."
When This Pattern Is Overkill
Apply the same test from the architectural article
A one-step task doesn't need a Planner, there's nothing to decompose. A task where correctness is trivially self-evident, formatting a date string, doesn't need an independent Reviewer either, a plain assertion would catch the same problem for a fraction of the cost. Reach for this shape when a wrong result would be genuinely hard to catch in the same breath that produced it, not by default because three agents sounds more rigorous than one.
| If your task is... | The right shape is probably... |
|---|---|
| A single, simple action with an obviously checkable result | One agent call, or even a plain function, no review loop needed |
| Correctness that a deterministic check can verify | A single agent plus a code-based assertion, skip the LLM Reviewer entirely |
| Multi-step, with results that are genuinely hard to self-assess in the same context that produced them | The full Planner, Worker, Reviewer loop |
| Steps that are independent of each other and could run concurrently | The same loop, with independent steps parallelized, the genuine-parallelism condition from Multi-Agent AI Systems |
The Bottom Line
A Planner, a Worker, and a Reviewer aren't three ways of asking the same question and hoping for a better average answer. They're three different jobs, decompose, execute, judge, deliberately kept in separate contexts so that the pass meant to catch mistakes never inherits the reasoning that produced them. The retry loop, the bounded attempts, and the escalation path aren't optional scaffolding around that idea, they're what turns "an independent review sounds good" into a system that actually stops before it burns an unbounded budget on a step that was never going to pass.
The exercise worth running before this touches anything real
Take one task you'd currently hand to a single agent with a "check your work" instruction bolted on. Run it through this loop instead, and read the Reviewer's first rejection, if there is one, closely. If it caught something specific and real, that's the pattern earning its cost on your own work, not just in a worked example.
One agent grading itself shares its own blind spots by construction. A Reviewer that never saw the reasoning has nothing to protect, and that absence is exactly what makes it useful.
Written by
Chetan Yamger
Cloud Engineer · AI Automation Architect · Modern Workplace Consultant
Cloud Engineer, AI Automation Architect, and Modern Workplace Consultant based in Amsterdam, Netherlands. Specializing in scalable, secure enterprise solutions with Microsoft Azure, Intune, PowerShell, and AI-driven automation using ChatGPT, Gemini, and modern LLM technologies.
Stay in the loop.
New articles, straight to you.
Deep-dive technical articles on Intune, PowerShell, and AI — no noise, no spam.
Discussion
Share your thoughts — your email stays private
Leave a comment