Understanding the Orchestrator-Worker Pattern
Continuing the agentic AI patterns series. This time we’re pulling apart a design that sits at the heart of most scalable agentic systems: the orchestrator-worker pattern.
Where reflection loops and prompt chaining gave us self-improving and linear workflows, this one tackles a different problem entirely: coordination and specialisation.
What Is the Orchestrator-Worker Pattern?
At a high level, this pattern splits responsibilities in a multi-step task between two types of agents:
- Orchestrator: A high-level coordinator that understands the overarching task, breaks it into subtasks, assigns those to appropriate workers, and stitches their results together.
- Workers: Specialised agents (or sub-agents) that execute individual tasks as assigned by the orchestrator. These can range from simple function calls to independent LLM-based agents with their own internal logic.
The design echoes patterns found elsewhere in computing, particularly map-reduce and microservice architectures. But in an agentic context, it offers a clean way of balancing central control with decentralised intelligence.
Why Use This Pattern?
The orchestrator-worker pattern becomes especially useful when dealing with:
- Complex tasks that can be decomposed into subtasks
- Tasks requiring different types of expertise
Structural Overview
Here’s a stripped-back breakdown of how the pattern works:
User Request
↓
[ Orchestrator ]
↓
┌─────────────┬─────────────┬─────────────┐
│ Worker A │ Worker B │ Worker C │
│ (Sentiment) │ (Keywords) │ (Trends) │
└─────────────┴─────────────┴─────────────┘
↓
[ Aggregation & Final Response ]
Each worker is responsible for a narrowly-defined role. The orchestrator doesn’t need to understand how each worker completes their job, only what input they require and what output they produce.
That separation of concerns is what makes the pattern so powerful.
A Practical Example: Trip Planning with Gemini and Task-Oriented Workers
Let’s ground this in a real-world scenario: planning a multi-day trip to Paris. That involves:
- Understanding the traveller’s goal
- Decomposing it into smaller, manageable subtasks
- Assigning each subtask to the appropriate expert agent (worker)
- Executing the plan in sequence or parallel, as required
For this, we use Google’s Gemini model via the @google/genai SDK, along with a schema to structure and validate our planner’s outputs.
Step 1: Define Task & Plan Schemas
We start by describing the shape of our tasks using structured schemas:
const taskSchema = {
type: Type.OBJECT,
properties: {
task_id: { type: Type.NUMBER },
description: { type: Type.STRING },
assigned_to: {
type: Type.STRING,
description: 'Which worker type should handle this? E.g., Trip_Planner, Flight_Agent, Hotel_Agent',
},
},
required: ['task_id', 'description', 'assigned_to'],
};
const planSchema = {
type: Type.OBJECT,
properties: {
goal: { type: Type.STRING },
steps: {
type: Type.ARRAY,
items: taskSchema,
},
},
required: ['goal', 'steps'],
};
Step 2: Planner Prompt & Goal Decomposition
The orchestrator (Gemini in this case) takes a user’s high-level goal and converts it into a structured plan with task assignments:
const userGoal = 'Plan a 3-day trip to Paris, book flights and a hotel within my budget.';
const plannerPrompt = `
You are an expert travel orchestrator. Break the following travel goal into 3–5 sequential subtasks.
Assign each to a worker type like Trip_Planner, Flight_Agent, Hotel_Agent.
Be precise in task descriptions, and ensure budget-awareness.
Goal: ${userGoal}
`;
This prompt primes the model to think step-wise, stay aware of budgetary constraints, and pick the right agent type for each subtask.
Step 3: Generate and Execute the Plan
We ask Gemini to respond with JSON matching our schema. Once the plan comes back, we parse and iterate through the tasks:
const result = await ai.models.generateContent({
model: 'gemini-2.5-pro-preview-03-25',
contents: plannerPrompt,
config: {
responseMimeType: 'application/json',
responseSchema: planSchema,
},
});
const raw = result.text;
let plan;
try {
plan = JSON.parse(raw!);
} catch (e) {
console.error('❌ Failed to parse plan response:', raw);
return;
}
for (const step of plan.steps) {
console.log(`Step ${step.task_id}: ${step.description} (Assignee: ${step.assigned_to})`);
}
Each subtask is precise, scoped, and routed to a designated worker class. The planner is your orchestrator; the Trip_Planner, Flight_Agent, and Hotel_Agent are the workers.
Here’s what the orchestrator’s initial plan looks like:
A More Robust Example
What we’ve seen so far worked, but it was only scratching the surface. We only displayed which agent would handle each task. Let’s walk through how the travel planner evolves into a fully agentic system by actually getting the worker agents to do the work. We’ll compare specific code decisions and highlight how each contributes to modularity, realism, and adaptability.
Manual Task Output vs. Schema-Guided Planning
In the original version, the planning logic was bolted in as a static string:
console.log(`Step 1: Clarify travel dates... (Assignee: Trip_Planner)`);
console.log(`Step 2: Book flights... (Assignee: Flight_Agent)`);
No logic, just printed lines. No LLM involved. A display format, not a planning system.
In the new version, planning happens dynamically through a planner agent using a structured schema:
const plannerPrompt = `
You are an expert travel orchestrator. Break the following travel goal into 3-5 sequential subtasks.
Assign each to a worker type like Trip_Planner, Flight_Agent, Hotel_Agent.
Be precise in task descriptions, and ensure budget-awareness.
Goal: ${userGoal}
`;
const planSchema = {
type: Type.OBJECT,
properties: {
goal: { type: Type.STRING, ... },
steps: { type: Type.ARRAY, items: taskSchema, ... },
},
};
Instead of prewriting the steps, the LLM generates them, and its response must match the schema. Two key shifts here: the plan is goal-driven and dynamic, and the system enforces structure via planSchema, ensuring the LLM output is predictable and parseable.
From Labels to Modular Role-Based Execution
Previously, labels like (Assignee: Flight_Agent) were purely descriptive. They had no impact on logic or execution.
In the new code, these roles drive actual agent behaviour. Each task is dispatched like this:
for (const step of plan.steps) {
await dispatchToWorker(step, memory);
}
Inside dispatchToWorker, the system uses role-based logic:
const agentPrompt = getAgentPrompt(role, step.description, memory);
// ...
result = await ai.models.generateContent({ model, contents: agentPrompt });
This structure gives each worker its own prompt context and execution logic, turning roles into behavioural units.
For example:
case 'flight_agent':
return `You are a Flight Agent. Previous context: ${memory['trip_planner'] || '[No trip context]'}`;
// Chooses best flight from staticData
Roles aren’t just tags anymore. They’re pluggable, specialised agents.
No State vs. Shared Memory Coordination
The first version had no shared context. Each step existed in isolation.
In the agentic version, a shared memory object stores the output of each agent:
memory[role] = (memory[role] || '') + `\n\n[Task ${step.task_id}] ${output}`;
This lets downstream agents access previous decisions:
Previous context:
${memory['trip_planner'] || '[No trip context provided]'}
The Hotel_Agent can see the dates and budget from the Trip_Planner. That inter-agent coordination is essential for coherent, realistic outcomes.
No Grounding vs. Data-Backed Decision Making
The first implementation simulated planning but didn’t use any actual flight or hotel data.
In the newer code, flights and hotels are pulled from a static JSON file (for simplicity; in production these would be wired to real APIs):
const staticData = JSON.parse(readFileSync('./travel.json', 'utf-8'));
// ...
const flights = staticData.flights[city];
const hotels = staticData.hotels[city];
Agents use these options to select concrete results:
result = { text: `Selected flight: ${flights[0].id} - ${flights[0].airline}, $${flights[0].price}` };
A foundational shift: the agent isn’t just imagining a flight, it’s choosing from a real dataset, grounding language in context. You can later extend this to call real APIs (Skyscanner, Booking.com, etc).
Here’s the full execution flow with workers doing actual work:
Conclusion
The orchestrator-worker pattern is deceptively simple but genuinely useful. It enforces separation of concerns, enables scalability, and introduces parallelism into agentic workflows.
In practice, this pattern is also easier to debug and extend than monolithic agent chains. Need better sentiment analysis? Swap out the sentiment worker. Want more detail in your report? Bolt on a new worker for it.