Building a multi-agent workflow with handoffs and memory
Why single agents fail at complex tasks
A single LLM agent with a long system prompt and a pile of tools can handle a surprising range of tasks, but it struggles when work requires deep specialisation, parallel execution, or context that grows beyond what fits in one conversation. A customer-support flow that needs to simultaneously check inventory, calculate shipping, look up account history, and draft a personalised response is already testing the limits of what a single-agent loop handles cleanly.
Multi-agent architectures solve this by decomposing work across specialised agents, each with a tighter scope, smaller context, and targeted toolset. The tricky parts — which most tutorials gloss over — are the handoffs (how one agent passes work to another without losing state) and memory (how agents share what they've learned without each re-deriving it from scratch).
This article builds a complete multi-agent workflow using TypeScript and the Anthropic Claude API, covering the orchestrator pattern, typed handoffs, shared memory via a key-value store, and error recovery.
The orchestrator-subagent pattern
The most reliable multi-agent topology for production work is a central orchestrator that decomposes a task, dispatches subagents, collects results, and decides next steps. Subagents are narrow specialists: one handles web search, one handles database queries, one handles writing, and so on.
The orchestrator does not execute tools directly. It decides what needs to happen, which agent should handle it, and how to sequence work. Subagents execute tools but do not plan or make high-level decisions.
User Request
│
▼
Orchestrator Agent
├── decides task decomposition
├── dispatches SubAgent A → returns result
├── dispatches SubAgent B → returns result
└── synthesises final response
This separation matters because it keeps each agent's context window small (subagents only see their subtask) and makes the system easier to debug (failures are localised to one agent).
Defining the agent interface
Start with a typed interface that all agents implement. This enforces a consistent handoff contract:
export interface AgentInput {
taskId: string;
task: string;
context: Record<string, unknown>;
memory: SharedMemory;
}
export interface AgentOutput {
taskId: string;
agentName: string;
result: unknown;
summary: string;
memoryUpdates?: MemoryUpdate[];
nextAgent?: HandoffRequest;
error?: string;
}
export interface HandoffRequest {
targetAgent: string;
task: string;
context: Record<string, unknown>;
}
export interface MemoryUpdate {
key: string;
value: unknown;
scope: "task" | "session" | "permanent";
}
export interface SharedMemory {
get(key: string): unknown;
set(key: string, value: unknown, scope?: MemoryUpdate["scope"]): void;
getAll(): Record<string, unknown>;
}
Building shared memory
Memory is the connective tissue between agents. Without it, each agent starts blind and either repeats work or makes inconsistent decisions. Implement a simple in-process store for development, then swap the backend for Redis or Firestore in production:
export class InMemoryStore implements SharedMemory {
private store: Map<string, { value: unknown; scope: MemoryUpdate["scope"] }> =
new Map();
get(key: string): unknown {
return this.store.get(key)?.value;
}
set(key: string, value: unknown, scope: MemoryUpdate["scope"] = "session"): void {
this.store.set(key, { value, scope });
}
getAll(): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [k, v] of this.store) {
result[k] = v.value;
}
return result;
}
flush(scope: MemoryUpdate["scope"]): void {
for (const [key, entry] of this.store) {
if (entry.scope === scope) this.store.delete(key);
}
}
}
// Redis-backed implementation for production
import { createClient } from "redis";
export class RedisMemoryStore implements SharedMemory {
private client: ReturnType<typeof createClient>;
private prefix: string;
private localCache: Map<string, unknown> = new Map();
constructor(redisUrl: string, taskId: string) {
this.client = createClient({ url: redisUrl });
this.prefix = `agent:${taskId}:`;
}
async connect() {
await this.client.connect();
}
get(key: string): unknown {
// Return from local cache for synchronous access mid-turn
return this.localCache.get(key);
}
async load(): Promise<void> {
const keys = await this.client.keys(`${this.prefix}*`);
for (const key of keys) {
const value = await this.client.get(key);
if (value) {
this.localCache.set(key.replace(this.prefix, ""), JSON.parse(value));
}
}
}
set(key: string, value: unknown, scope: MemoryUpdate["scope"] = "session"): void {
this.localCache.set(key, value);
// Fire-and-forget async write
const ttl = scope === "task" ? 3600 : scope === "session" ? 86400 : undefined;
const serialised = JSON.stringify(value);
if (ttl) {
this.client.setEx(`${this.prefix}${key}`, ttl, serialised).catch(console.error);
} else {
this.client.set(`${this.prefix}${key}`, serialised).catch(console.error);
}
}
getAll(): Record<string, unknown> {
return Object.fromEntries(this.localCache);
}
}
Implementing a subagent
Each subagent wraps a Claude API call with its own system prompt and toolset. The subagent reads from shared memory at the start, executes its work, and writes updates back:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
export class ResearchAgent {
readonly name = "research";
async run(input: AgentInput): Promise<AgentOutput> {
const existingResearch = input.memory.get("research_findings");
const systemPrompt = `You are a research specialist. Your job is to gather factual information about a topic.
Use the search tool to find relevant information.
Do not synthesise or make recommendations — only report facts.
${existingResearch ? `\nPrevious research findings:\n${JSON.stringify(existingResearch)}` : ""}`;
const tools: Anthropic.Tool[] = [
{
name: "web_search",
description: "Search the web for current information on a topic",
input_schema: {
type: "object" as const,
properties: {
query: { type: "string", description: "Search query" },
},
required: ["query"],
},
},
];
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: input.task },
];
const findings: unknown[] = [];
let finalText = "";
// Agentic loop
while (true) {
const response = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 2048,
system: systemPrompt,
tools,
messages,
});
if (response.stop_reason === "end_turn") {
const textBlock = response.content.find((b) => b.type === "text");
finalText = textBlock?.type === "text" ? textBlock.text : "";
break;
}
if (response.stop_reason === "tool_use") {
messages.push({ role: "assistant", content: response.content });
const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
const searchResult = await performWebSearch(
(block.input as { query: string }).query
);
findings.push(searchResult);
results.push({
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(searchResult),
});
}
messages.push({ role: "user", content: results });
}
}
return {
taskId: input.taskId,
agentName: this.name,
result: { text: finalText, rawFindings: findings },
summary: finalText.slice(0, 200),
memoryUpdates: [
{
key: "research_findings",
value: { text: finalText, findings },
scope: "task",
},
],
// Hand off to the analysis agent
nextAgent: {
targetAgent: "analysis",
task: `Analyse these research findings and identify key insights: ${finalText}`,
context: { researchSummary: finalText },
},
};
}
}
Building the orchestrator
The orchestrator maintains a task queue, dispatches agents, applies memory updates from their output, and follows handoff instructions:
type AgentRegistry = Map<string, { run(input: AgentInput): Promise<AgentOutput> }>;
export class Orchestrator {
private agents: AgentRegistry;
private memory: SharedMemory;
private maxSteps: number;
constructor(agents: AgentRegistry, memory: SharedMemory, maxSteps = 20) {
this.agents = agents;
this.memory = memory;
this.maxSteps = maxSteps;
}
async run(initialTask: string): Promise<string> {
const taskId = crypto.randomUUID();
let currentTask = initialTask;
let currentAgentName = await this.planInitialAgent(initialTask);
let context: Record<string, unknown> = {};
let steps = 0;
const outputs: AgentOutput[] = [];
while (steps < this.maxSteps) {
const agent = this.agents.get(currentAgentName);
if (!agent) {
throw new Error(`Unknown agent: ${currentAgentName}`);
}
console.log(`[Step ${steps + 1}] Running agent: ${currentAgentName}`);
const output = await agent.run({
taskId,
task: currentTask,
context,
memory: this.memory,
});
outputs.push(output);
// Apply memory updates from the agent
if (output.memoryUpdates) {
for (const update of output.memoryUpdates) {
this.memory.set(update.key, update.value, update.scope);
}
}
// If the agent requested a handoff, follow it
if (output.nextAgent) {
currentAgentName = output.nextAgent.targetAgent;
currentTask = output.nextAgent.task;
context = { ...context, ...output.nextAgent.context };
steps++;
continue;
}
// No handoff — we're done
return this.synthesise(initialTask, outputs);
}
throw new Error(`Workflow exceeded max steps (${this.maxSteps})`);
}
private async planInitialAgent(task: string): Promise<string> {
const agentNames = Array.from(this.agents.keys()).join(", ");
const response = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 256,
messages: [
{
role: "user",
content:
`Available agents: ${agentNames}\n\n` +
`Task: ${task}\n\n` +
`Which agent should handle this task first? ` +
`Reply with only the agent name, nothing else.`,
},
],
});
const textBlock = response.content.find((b) => b.type === "text");
const chosen = textBlock?.type === "text" ? textBlock.text.trim() : "";
return this.agents.has(chosen) ? chosen : Array.from(this.agents.keys())[0];
}
private async synthesise(
originalTask: string,
outputs: AgentOutput[]
): Promise<string> {
const summaries = outputs
.map((o) => `[${o.agentName}]: ${o.summary}`)
.join("\n");
const response = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: [
{
role: "user",
content:
`Original task: ${originalTask}\n\n` +
`Agent outputs:\n${summaries}\n\n` +
`Produce a final comprehensive response to the original task based on all agent outputs.`,
},
],
});
const textBlock = response.content.find((b) => b.type === "text");
return textBlock?.type === "text" ? textBlock.text : "";
}
}
Parallel agent execution
Handoffs are sequential by nature, but many workflows benefit from running agents in parallel. When the orchestrator identifies independent subtasks, it can dispatch multiple agents simultaneously and merge their results:
export class ParallelDispatcher {
constructor(private agents: AgentRegistry, private memory: SharedMemory) {}
async runParallel(
taskId: string,
subtasks: Array<{ agentName: string; task: string; context: Record<string, unknown> }>
): Promise<AgentOutput[]> {
const promises = subtasks.map(({ agentName, task, context }) => {
const agent = this.agents.get(agentName);
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
return agent.run({ taskId, task, context, memory: this.memory });
});
return Promise.all(promises);
}
}
// Inside an orchestrator step:
const parallelResults = await dispatcher.runParallel(taskId, [
{
agentName: "research",
task: "Find recent statistics on renewable energy adoption",
context: {},
},
{
agentName: "data-fetcher",
task: "Retrieve Q4 sales figures from the database",
context: { table: "sales_quarterly" },
},
]);
// Apply all memory updates from parallel outputs
for (const output of parallelResults) {
for (const update of output.memoryUpdates ?? []) {
memory.set(update.key, update.value, update.scope);
}
}
Parallel execution requires that each subtask is truly independent — they should not depend on each other's output and should write to distinct memory keys to avoid races.
Handling agent errors and retries
Multi-agent workflows fail in more complex ways than single-agent loops: one agent may fail mid-handoff, leaving the workflow in an inconsistent state. Build recovery into the orchestrator:
async function runWithRetry(
agent: { run(input: AgentInput): Promise<AgentOutput> },
input: AgentInput,
maxRetries = 2
): Promise<AgentOutput> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const output = await agent.run(input);
if (output.error) {
// Agent reported a soft error — log and retry
console.warn(
`Agent ${output.agentName} soft error (attempt ${attempt + 1}): ${output.error}`
);
lastError = new Error(output.error);
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
continue;
}
}
return output;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
}
}
}
// Return a failed output rather than throwing, so the orchestrator can decide how to proceed
return {
taskId: input.taskId,
agentName: "unknown",
result: null,
summary: "Agent failed after max retries",
error: lastError?.message,
};
}
The orchestrator checks the error field on each output and can reroute to a fallback agent, return a partial result, or escalate to a human review queue depending on your application's requirements.
Wiring the full workflow
Putting it together — a customer support workflow where a triage agent categorises the request, a specialist agent handles it, and a writer agent composes the final response:
async function createCustomerSupportWorkflow() {
const memory = new InMemoryStore();
const agents: AgentRegistry = new Map([
["triage", new TriageAgent()],
["billing-specialist", new BillingAgent()],
["technical-specialist", new TechnicalAgent()],
["response-writer", new ResponseWriterAgent()],
]);
const orchestrator = new Orchestrator(agents, memory, 10);
return orchestrator;
}
// Usage
const workflow = await createCustomerSupportWorkflow();
const response = await workflow.run(
"My payment failed but I was still charged. Order ID: ORD-99123"
);
console.log(response);
The triage agent sets a category key in memory (billing), hands off to billing-specialist with the order ID as context, the specialist fetches account data and writes account_status and charge_details to memory, then hands off to response-writer which reads those keys and composes a polished customer-facing reply.
Observability and debugging
Multi-agent workflows are harder to debug than single-agent ones because failures can originate anywhere in the chain. Log every agent invocation with a correlation ID:
function withLogging<T extends AgentInput, U extends AgentOutput>(
agentName: string,
runFn: (input: T) => Promise<U>
): (input: T) => Promise<U> {
return async (input: T): Promise<U> => {
const start = Date.now();
console.log(JSON.stringify({
event: "agent_start",
agentName,
taskId: input.taskId,
task: input.task.slice(0, 100),
}));
try {
const output = await runFn(input);
console.log(JSON.stringify({
event: "agent_end",
agentName,
taskId: input.taskId,
durationMs: Date.now() - start,
hasError: !!output.error,
nextAgent: output.nextAgent?.targetAgent,
}));
return output;
} catch (err) {
console.error(JSON.stringify({
event: "agent_error",
agentName,
taskId: input.taskId,
durationMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
}));
throw err;
}
};
}
Store this log alongside the shared memory updates and you have a full replay-capable audit trail for every workflow execution — which is invaluable for debugging non-deterministic failures and for satisfying compliance requirements in regulated industries.