A single agent hits a ceiling. Give it a big task and it runs out of context window before it finishes, or drowns in a dozen tools it has to choose between on every turn, or simply cannot hold the whole problem in one chain of thought. The fix is not a bigger agent; it is more agents, each doing one focused thing.
The supervisor-worker pattern splits the work along that seam. A supervisor agent looks at the goal, breaks it into sub-tasks, and hands each one to a worker agent that handles exactly that piece. When the workers report back, the supervisor merges their results into a single answer. You scale past one agent's limits by orchestrating several, not by growing one.
flowchart LR G["goal"] --> SUP["supervisor"] SUP -->|"plan"| SUB["sub-task 1<br/>sub-task 2<br/>sub-task 3"] SUB --> W1["worker 1"] SUB --> W2["worker 2"] SUB --> W3["worker 3"] W1 --> MG["merge"] W2 --> MG W3 --> MG MG --> ANS["final answer"] style SUP fill:#8b5cf6,color:#fff style ANS fill:#166534,color:#fff
The five parts
The pattern has five named parts, and keeping them straight is what makes the code readable. The goal is the whole task. The supervisor is the agent that plans and merges but never executes a sub-task itself. The plan is the decomposition — a list of sub-tasks small enough for one worker each. The workers are the agents (or functions) that execute one sub-task and return a result. The merge is the function that combines worker results into the final answer.
Notice where the intelligence sits. Planning and merging are the supervisor's two real skills; everything in between is delegation. That separation is the whole point: a worker does not need to understand the goal, only its own sub-task, and the supervisor does not need to know how each worker does its job, only what it returns.
function plan(goal) {
return goal.pairs.map(([a, b]) => ({ a, b }));
}
function dispatch(workers, subtasks) {
return subtasks.map((st, i) => workers[i % workers.length](st));
}
function merge(results) {
return results.reduce((acc, n) => acc + n, 0);
}
function supervise(goal, workers) {
return merge(dispatch(workers, plan(goal)));
}
const goal = { pairs: [[2, 3], [4, 5], [6, 7]] };
const workers = [({ a, b }) => a + b];
console.log(supervise(goal, workers));
Plan, dispatch, merge
The supervisor runs in three phases. Plan turns the goal into a list of sub-tasks — here, a goal holding pairs of numbers becomes a list of { a, b } objects, one per pair. Dispatch runs each sub-task through a worker and collects the results into an array, preserving order. Merge folds that array down to one value — here, summing the worker outputs.
Each phase is a one-liner over the previous phase's output: merge(dispatch(workers, plan(goal))). That composition is the supervisor in a single expression. Swap the plan for a smarter decomposition, or the merge for a different combination, and you have changed what the system does without touching the workers at all.
flowchart LR G["goal"] --> P["plan<br/>decompose"] P --> D["dispatch<br/>run each worker"] D --> M["merge<br/>combine results"] M --> A["answer"] style P fill:#8b5cf6,color:#fff style D fill:#b45309,color:#fff style M fill:#8b5cf6,color:#fff
function dispatch(workers, subtasks) {
return subtasks.map((st, i) => workers[i % workers.length](st));
}
const workers = [({ a, b }) => a + b];
const subtasks = [{ a: 2, b: 3 }, { a: 4, b: 5 }];
console.log(dispatch(workers, subtasks));
Workers can specialise
Nothing says every worker has to be the same. In fact the payoff of the pattern is specialisation: a different worker for each kind of sub-task, each with its own tools and its own prompt. An adder worker knows only addition; a multiplier worker knows only multiplication. Dispatch routes each sub-task to the worker built for it.
The simplest routing is round-robin — sub-task i goes to workers[i % workers.length] — which hands sub-tasks out in turn. Real systems route by type, where a classify step picks the worker, but round-robin is enough to see the mechanism: the supervisor does not care what each worker does internally, only that each sub-task lands on exactly one. Each worker here is itself an agent — a full loop like the one you just built — we are merely stubbing it with a function so the orchestration is the only thing under test.
flowchart LR D["dispatch"] -->|"pair 0"| A["adder worker<br/>a + b"] D -->|"pair 1"| MUL["multiplier worker<br/>a * b"] D -->|"pair 2"| A style A fill:#166534,color:#fff style MUL fill:#1d4ed8,color:#fff
The supervisor's only tool is delegate
A useful way to see the boundary: from the supervisor's point of view, delegate to a worker is a tool, exactly like call add was in the dispatch loop. The supervisor's loop perceives the goal, decides which sub-task to run next, acts by handing it to a worker, and observes the result — the same state machine as any other agent. The workers are its tools.
That reframing keeps the pattern honest. If you catch your supervisor reaching into a worker's internals, or doing a sub-task itself instead of delegating, it has stopped being a supervisor and become a single bloated agent again — the very thing the pattern exists to avoid. Treat delegate as just another tool in the supervisor's belt, and the whole pattern reduces to a loop you already know how to build.
What does this print? Each sub-task is handed to the adder worker, then the results are summed.
const workers = [(st) => st.a + st.b];
const subtasks = [{ a: 2, b: 3 }, { a: 4, b: 5 }];
const results = subtasks.map((st) => workers[0](st));
console.log(results);
console.log(results.reduce((acc, n) => acc + n, 0));
The adder turns {2,3} into 5 and {4,5} into 9.
The results array is [5, 9], and 5 + 9 is 14.
Write dispatch(workers, subtasks) that runs each sub-task through a worker and returns an array of results, one per sub-task, in order. Route round-robin: sub-task i goes to workers[i % workers.length].
function dispatch(workers, subtasks) {
// map each subtask to workers[i % workers.length](subtask)
}
Use subtasks.map, and for each entry call workersi % workers.length.
With two workers, indices 0,1,2 route to worker 0, worker 1, worker 0.
function dispatch(workers, subtasks) {
return subtasks.map((st, i) => workers[i % workers.length](st));
}
Write supervise(goal, workers) that runs the full pipeline: decompose the goal with plan, run the workers with dispatch, and combine the results with merge. All three helpers are already defined for you.
function plan(goal) {
return goal.pairs.map(([a, b]) => ({ a, b }));
}
function merge(results) {
return results.reduce((acc, n) => acc + n, 0);
}
function dispatch(workers, subtasks) {
return subtasks.map((st, i) => workers[i % workers.length](st));
}
function supervise(goal, workers) {
// decompose with plan, run with dispatch, combine with merge
}
The body is one return: merge(dispatch(workers, plan(goal))).
Order matters: plan first, then dispatch, then merge.
function plan(goal) {
return goal.pairs.map(([a, b]) => ({ a, b }));
}
function merge(results) {
return results.reduce((acc, n) => acc + n, 0);
}
function dispatch(workers, subtasks) {
return subtasks.map((st, i) => workers[i % workers.length](st));
}
function supervise(goal, workers) {
return merge(dispatch(workers, plan(goal)));
}
This supervisor runs every sub-task through a worker but keeps only the last result, dropping the rest — so for two sub-tasks it returns 9 instead of the combined 14. Use the helpers to dispatch all sub-tasks and then merge them, so nothing is lost.
function plan(goal) { return goal.pairs.map(([a, b]) => ({ a, b })); }
function dispatch(workers, subtasks) { return subtasks.map((st, i) => workers[i % workers.length](st)); }
function merge(results) { return results.reduce((acc, n) => acc + n, 0); }
function supervise(goal, workers) {
const subtasks = plan(goal);
let last;
for (const st of subtasks) {
last = workers[0](st); // BUG: keeps only the last result, drops the rest
}
return last;
}
Replace the loop with dispatch(workers, subtasks) to get every result.
Then return merge(results) so all outputs are combined.
function plan(goal) { return goal.pairs.map(([a, b]) => ({ a, b })); }
function dispatch(workers, subtasks) { return subtasks.map((st, i) => workers[i % workers.length](st)); }
function merge(results) { return results.reduce((acc, n) => acc + n, 0); }
function supervise(goal, workers) {
const subtasks = plan(goal);
const results = dispatch(workers, subtasks);
return merge(results);
}
In a supervisor-worker system, what does the supervisor do that the workers never do?
Workers execute one sub-task each. The supervisor's unique jobs are at the edges: planning (breaking the goal apart) and merging (combining the results). It orchestrates; it does not do the sub-tasks itself.
Recap
- The supervisor-worker pattern scales past one agent: a supervisor decomposes a goal into sub-tasks, workers execute them, and the supervisor merges the results.
- The five parts: goal, supervisor (plans and merges), plan (the sub-task list), workers (execute one sub-task each), merge (combine results).
- The supervisor is
merge(dispatch(workers, plan(goal)))— three phases, each a function over the last. - Workers can specialise: dispatch routes each sub-task to the worker built for it, often round-robin.
- Dispatch without merge loses results — the merge is half the supervisor's job, not optional polish.
Next you will make these handoffs safe: passing context between agents without leaking it, so each worker gets exactly what it needs and nothing more.