Agent Orchestration Patterns
A deep dive into patterns for orchestrating multiple AI agents in TypeScript, covering sequential chains, parallel execution, supervisor hierarchies, and workflow-based coordination.
A single AI agent with a well-crafted prompt and a handful of tools can handle straightforward tasks effectively. But real-world workflows are rarely straightforward. A customer support interaction might require identity verification, account analysis, policy lookup, and action execution — each requiring different domain knowledge, different tools, and different safety constraints. Trying to pack all of this into a single agent produces a bloated system prompt, an unwieldy tool list, and unpredictable behavior.
Agent orchestration solves this by decomposing complex workflows into specialized agents that collaborate. At Klivvr, Klivvr Agent provides first-class orchestration primitives that let you compose agents into workflows without sacrificing the simplicity and testability of individual agents.
The Case for Multi-Agent Systems
The argument for orchestration is not about capability — a sufficiently large context window can theoretically hold all the tools and instructions a single agent needs. The argument is about reliability, maintainability, and control.
Reliability improves because each agent has a narrow scope. A verification agent only has access to identity verification tools. A refund agent only has access to payment tools. When an agent has fewer tools to choose from, it makes better tool selection decisions.
Maintainability improves because each agent can be developed, tested, and updated independently. Changing the refund policy does not require touching the verification agent's prompt or testing the entire monolithic agent end to end.
Control improves because orchestration patterns define explicit boundaries between agents. You can enforce that the refund agent only runs after the verification agent succeeds. You can set different token budgets, temperature settings, and guardrails for each agent based on its risk profile.
interface AgentDefinition {
id: string;
name: string;
description: string;
systemPrompt: string;
tools: Tool[];
config: {
model: string;
maxSteps: number;
temperature: number;
maxTokens: number;
};
}
interface OrchestrationResult {
workflowId: string;
status: "completed" | "failed" | "paused";
agentResults: Map<string, AgentResult>;
finalOutput: string;
totalDurationMs: number;
totalTokensUsed: number;
}
interface AgentResult {
agentId: string;
status: "completed" | "failed" | "skipped";
output: string;
stepsExecuted: number;
tokensUsed: number;
durationMs: number;
}Sequential Chain Pattern
The simplest orchestration pattern is the sequential chain: agents execute one after another, each receiving the output of the previous agent as input. This pattern is ideal for workflows with clear, linear dependencies.
class SequentialChain {
private agents: AgentDefinition[];
private agentFactory: AgentFactory;
constructor(agents: AgentDefinition[], agentFactory: AgentFactory) {
this.agents = agents;
this.agentFactory = agentFactory;
}
async execute(
initialInput: string,
context: Record<string, unknown> = {}
): Promise<OrchestrationResult> {
const startTime = Date.now();
const results = new Map<string, AgentResult>();
let currentInput = initialInput;
let totalTokens = 0;
for (const agentDef of this.agents) {
const agentStart = Date.now();
const agent = this.agentFactory.create(agentDef);
try {
const prompt = this.buildPrompt(currentInput, context, results);
const state = await agent.run(prompt);
const lastAssistantMessage = state.messages
.filter((m) => m.role === "assistant")
.pop();
const output = lastAssistantMessage?.content ?? "";
const tokensUsed = this.estimateTokens(state.messages);
totalTokens += tokensUsed;
results.set(agentDef.id, {
agentId: agentDef.id,
status: state.status === "completed" ? "completed" : "failed",
output,
stepsExecuted: state.stepCount,
tokensUsed,
durationMs: Date.now() - agentStart,
});
if (state.status !== "completed") {
return {
workflowId: this.generateId(),
status: "failed",
agentResults: results,
finalOutput: `Workflow failed at agent: ${agentDef.name}`,
totalDurationMs: Date.now() - startTime,
totalTokensUsed: totalTokens,
};
}
currentInput = output;
context[`${agentDef.id}_output`] = output;
} catch (error) {
results.set(agentDef.id, {
agentId: agentDef.id,
status: "failed",
output: (error as Error).message,
stepsExecuted: 0,
tokensUsed: 0,
durationMs: Date.now() - agentStart,
});
return {
workflowId: this.generateId(),
status: "failed",
agentResults: results,
finalOutput: `Error in agent ${agentDef.name}: ${(error as Error).message}`,
totalDurationMs: Date.now() - startTime,
totalTokensUsed: totalTokens,
};
}
}
const lastResult = Array.from(results.values()).pop();
return {
workflowId: this.generateId(),
status: "completed",
agentResults: results,
finalOutput: lastResult?.output ?? "",
totalDurationMs: Date.now() - startTime,
totalTokensUsed: totalTokens,
};
}
private buildPrompt(
input: string,
context: Record<string, unknown>,
previousResults: Map<string, AgentResult>
): string {
let prompt = input;
if (previousResults.size > 0) {
prompt += "\n\nContext from previous steps:\n";
for (const [agentId, result] of previousResults) {
prompt += `[${agentId}]: ${result.output}\n`;
}
}
return prompt;
}
private estimateTokens(messages: Message[]): number {
return messages.reduce(
(sum, m) => sum + Math.ceil(m.content.length / 4),
0
);
}
private generateId(): string {
return `wf-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
}
}
interface AgentFactory {
create(definition: AgentDefinition): Agent;
}
interface Agent {
run(input: string): Promise<{ messages: Message[]; stepCount: number; status: string }>;
}
interface Message {
role: string;
content: string;
}
interface Tool {}A typical sequential chain for customer support might look like: Triage Agent (classifies the issue) -> Lookup Agent (fetches relevant customer data) -> Resolution Agent (determines and executes the fix) -> Response Agent (crafts the customer-facing reply). Each agent's output flows to the next, and any failure stops the chain with a clear indication of where it broke.
Parallel Execution Pattern
Some subtasks are independent and can run concurrently. When a customer reports an issue, you might simultaneously look up their account details, check for known system outages, and retrieve their recent support history. The parallel pattern executes multiple agents concurrently and aggregates their results.
class ParallelExecutor {
private agentFactory: AgentFactory;
constructor(agentFactory: AgentFactory) {
this.agentFactory = agentFactory;
}
async execute(
agents: AgentDefinition[],
inputs: Map<string, string>,
timeoutMs: number = 30000
): Promise<Map<string, AgentResult>> {
const results = new Map<string, AgentResult>();
const promises = agents.map(async (agentDef) => {
const input = inputs.get(agentDef.id) ?? "";
const agent = this.agentFactory.create(agentDef);
const startTime = Date.now();
try {
const state = await Promise.race([
agent.run(input),
this.timeout(timeoutMs),
]);
if (!state) {
results.set(agentDef.id, {
agentId: agentDef.id,
status: "failed",
output: "Agent timed out",
stepsExecuted: 0,
tokensUsed: 0,
durationMs: timeoutMs,
});
return;
}
const lastMessage = state.messages
.filter((m: Message) => m.role === "assistant")
.pop();
results.set(agentDef.id, {
agentId: agentDef.id,
status: state.status === "completed" ? "completed" : "failed",
output: lastMessage?.content ?? "",
stepsExecuted: state.stepCount,
tokensUsed: this.estimateTokens(state.messages),
durationMs: Date.now() - startTime,
});
} catch (error) {
results.set(agentDef.id, {
agentId: agentDef.id,
status: "failed",
output: (error as Error).message,
stepsExecuted: 0,
tokensUsed: 0,
durationMs: Date.now() - startTime,
});
}
});
await Promise.allSettled(promises);
return results;
}
private timeout(ms: number): Promise<null> {
return new Promise((resolve) => setTimeout(() => resolve(null), ms));
}
private estimateTokens(messages: Message[]): number {
return messages.reduce(
(sum, m) => sum + Math.ceil(m.content.length / 4),
0
);
}
}The parallel pattern uses Promise.allSettled rather than Promise.all to ensure that one agent's failure does not cancel the others. Individual timeouts prevent a single slow agent from blocking the entire workflow. This is important in production where tool latency is unpredictable — a database query might take 50ms or 5 seconds depending on load.
Supervisor Pattern
The supervisor pattern introduces a coordinating agent that decides which specialized agents to invoke and in what order. Unlike the sequential chain, where the flow is predetermined, the supervisor dynamically routes based on the conversation context.
class SupervisorOrchestrator {
private supervisor: AgentDefinition;
private workers: Map<string, AgentDefinition>;
private agentFactory: AgentFactory;
constructor(
supervisor: AgentDefinition,
workers: AgentDefinition[],
agentFactory: AgentFactory
) {
this.supervisor = supervisor;
this.workers = new Map(workers.map((w) => [w.id, w]));
this.agentFactory = agentFactory;
}
async execute(input: string): Promise<OrchestrationResult> {
const startTime = Date.now();
const agentResults = new Map<string, AgentResult>();
let totalTokens = 0;
// The supervisor has a special tool for delegating to workers
const delegateTool: Tool = this.createDelegationTool(agentResults);
const supervisorDef: AgentDefinition = {
...this.supervisor,
tools: [...this.supervisor.tools, delegateTool],
systemPrompt: this.buildSupervisorPrompt(),
};
const supervisorAgent = this.agentFactory.create(supervisorDef);
const state = await supervisorAgent.run(input);
const lastMessage = state.messages
.filter((m: Message) => m.role === "assistant")
.pop();
return {
workflowId: this.generateId(),
status: state.status === "completed" ? "completed" : "failed",
agentResults,
finalOutput: lastMessage?.content ?? "",
totalDurationMs: Date.now() - startTime,
totalTokensUsed: totalTokens,
};
}
private buildSupervisorPrompt(): string {
const workerDescriptions = Array.from(this.workers.values())
.map((w) => `- ${w.name}: ${w.description}`)
.join("\n");
return `${this.supervisor.systemPrompt}
You have access to the following specialized agents:
${workerDescriptions}
Use the delegate_to_agent tool to assign tasks to the appropriate agent.
Analyze the results and decide whether to delegate further or provide a final response.`;
}
private createDelegationTool(
results: Map<string, AgentResult>
): Tool {
return {
name: "delegate_to_agent",
definition: {
name: "delegate_to_agent",
description: "Delegate a task to a specialized agent",
parameters: {
type: "object",
properties: {
agentId: {
type: "string",
description: "The ID of the agent to delegate to",
enum: Array.from(this.workers.keys()),
},
task: {
type: "string",
description: "The specific task to assign to the agent",
},
},
required: ["agentId", "task"],
},
},
execute: async (args: Record<string, unknown>) => {
const { agentId, task } = args as { agentId: string; task: string };
const workerDef = this.workers.get(agentId);
if (!workerDef) return { error: `Unknown agent: ${agentId}` };
const worker = this.agentFactory.create(workerDef);
const workerStart = Date.now();
const state = await worker.run(task);
const output = state.messages
.filter((m: Message) => m.role === "assistant")
.pop()?.content ?? "";
results.set(agentId, {
agentId,
status: state.status === "completed" ? "completed" : "failed",
output,
stepsExecuted: state.stepCount,
tokensUsed: 0,
durationMs: Date.now() - workerStart,
});
return { agentId, output, status: state.status };
},
} as Tool;
}
private generateId(): string {
return `wf-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
}
}
interface OrchestrationResult {
workflowId: string;
status: "completed" | "failed" | "paused";
agentResults: Map<string, AgentResult>;
finalOutput: string;
totalDurationMs: number;
totalTokensUsed: number;
}The supervisor pattern is the most flexible orchestration approach, but also the most expensive — it adds an extra LLM call for routing decisions. Use it when the workflow logic is genuinely dynamic, when the set of worker agents is large, or when you want the supervisor to synthesize results from multiple workers into a coherent response. For workflows with predictable structure, sequential chains or parallel execution are more efficient.
Workflow-Based Orchestration
For complex, long-running processes that span multiple interactions, Klivvr Agent supports workflow-based orchestration. Workflows are defined as state machines with explicit transitions, and agents are invoked at specific states.
interface WorkflowDefinition {
id: string;
name: string;
states: WorkflowState[];
initialState: string;
terminalStates: string[];
}
interface WorkflowState {
id: string;
name: string;
agentId: string;
transitions: WorkflowTransition[];
timeout?: number;
}
interface WorkflowTransition {
event: string;
targetState: string;
condition?: (context: Record<string, unknown>) => boolean;
}
class WorkflowEngine {
private definition: WorkflowDefinition;
private currentState: string;
private context: Record<string, unknown>;
private agentFactory: AgentFactory;
private workers: Map<string, AgentDefinition>;
constructor(
definition: WorkflowDefinition,
agentFactory: AgentFactory,
workers: Map<string, AgentDefinition>
) {
this.definition = definition;
this.currentState = definition.initialState;
this.context = {};
this.agentFactory = agentFactory;
this.workers = workers;
}
async step(input: string): Promise<{ output: string; isComplete: boolean }> {
const state = this.definition.states.find(
(s) => s.id === this.currentState
);
if (!state) throw new Error(`Unknown state: ${this.currentState}`);
const agentDef = this.workers.get(state.agentId);
if (!agentDef) throw new Error(`Unknown agent: ${state.agentId}`);
const agent = this.agentFactory.create(agentDef);
const result = await agent.run(
this.buildStatePrompt(state, input)
);
const output = result.messages
.filter((m: Message) => m.role === "assistant")
.pop()?.content ?? "";
// Determine transition
const event = this.extractEvent(output, result);
const transition = state.transitions.find(
(t) =>
t.event === event &&
(!t.condition || t.condition(this.context))
);
if (transition) {
this.currentState = transition.targetState;
}
const isComplete = this.definition.terminalStates.includes(
this.currentState
);
return { output, isComplete };
}
private buildStatePrompt(state: WorkflowState, input: string): string {
return `Current workflow state: ${state.name}\nContext: ${JSON.stringify(this.context)}\nUser input: ${input}`;
}
private extractEvent(
output: string,
result: { messages: Message[]; stepCount: number; status: string }
): string {
if (result.status === "failed") return "error";
if (output.toLowerCase().includes("resolved")) return "resolved";
if (output.toLowerCase().includes("escalate")) return "escalate";
return "continue";
}
}Workflow-based orchestration provides durability and visibility. The workflow state can be persisted to a database, enabling long-running processes that survive service restarts. The state machine is inspectable — you can see exactly where a conversation is in the workflow, what agents have been invoked, and what transitions are available.
Conclusion
Agent orchestration is about matching the coordination pattern to the problem structure. Sequential chains work for linear workflows. Parallel execution works for independent subtasks. The supervisor pattern works for dynamic routing. Workflow engines work for complex, long-running processes.
In Klivvr Agent, we have found that most production use cases start with a sequential chain and evolve into a supervisor or workflow pattern as requirements grow. The key is to start simple and add orchestration complexity only when the single-agent approach demonstrably fails. Every additional agent in the system adds latency, cost, and debugging surface area. The best multi-agent system is one with the fewest agents that still meet the reliability and maintainability requirements.
Related Articles
AI Agents in Fintech Operations
How AI agents automate fintech operational workflows including compliance monitoring, fraud detection, dispute resolution, and regulatory reporting — with insights from Klivvr Agent deployments.
Human-in-the-Loop Patterns for AI Agents
How to design effective human-in-the-loop workflows for AI agents, covering escalation policies, approval workflows, the autonomy ladder, and trust-building strategies.
Multi-Agent Systems in TypeScript
Architecture patterns for multi-agent systems including supervisor topologies, agent-to-agent communication, task delegation, and shared state management in Klivvr Agent.