Skip to main content
Multi-agent systems break complex applications into coordinated components. Importantly, “multi-agent” doesn’t necessarily mean multiple distinct agents — a single agent with dynamic behavior can achieve similar capabilities.

Why multi-agent?

When developers say they need “multi-agent,” they’re usually looking for one or more of these capabilities:

Context management

Surface relevant knowledge without overwhelming the context window. Different tasks (agents) need different context.

Distributed development

Let different teams develop and maintain capabilities independently with clear boundaries.

Parallelization

Spawn specialized workers for subtasks and execute them concurrently for faster results.

Sequential constraints

Enforce step-by-step workflows. Unlock tools and actions only after preconditions are met.
Multi-agent patterns are particularly valuable when a single agent has too many tools and makes poor decisions about which to use, when tasks require specialized knowledge with extensive context (long prompts and domain-specific tools), or when you need to enforce sequential constraints that unlock capabilities only after certain conditions are met.
At the center of multi-agent design is context engineering—deciding what information each agent sees. The quality of your system depends on ensuring each agent has access to the right data for its task.

Patterns

Here are the main patterns for building multi-agent systems, each suited to different use cases:
PatternHow it works
SubagentsA main agent coordinates subagents and background jobs as tools. Centralized control — all routing passes through the main agent. Multiple coordination approaches available.
HandoffsBehavior changes dynamically based on state. Tool calls update a state variable, and the system adjusts behavior accordingly. Supports both handoffs between distinct agents and dynamic configuration changes.
SkillsSpecialized prompts loaded on-demand. The main agent stays in control and gains additional context as needed.
RouterA routing step classifies input and directs it to one or more specialized agents. Results are collected and returned to the user.
Custom workflowBuild bespoke logic with LangGraph, mixing deterministic and agentic steps. Reuse or customize agents as needed.
Tool calling is the primary coordination mechanism across all patterns. Tools can:
  • Invoke sub-agents (subagents)
  • Update state to trigger routing or configuration changes (handoffs)
  • Load context on-demand (skills)
  • Invoke entire multi-agent systems (wrapping a router as a tool)

Choosing a pattern

Use this table to match your requirements to the right pattern:
PatternDistributed developmentParallelizationMulti-hopDirect user interaction
Subagents⭐⭐⭐⭐⭐⭐⭐⭐⭐
Handoffs⭐⭐⭐⭐⭐⭐
Skills⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Router⭐⭐⭐⭐⭐⭐⭐
  • Distributed development: Can different teams maintain components independently?
  • Parallelization: Can multiple agents execute concurrently?
  • Multi-hop: Does the pattern support multiple hops between agents?
  • Direct user interaction: Can subagents converse directly with the user?
You can mix patterns! For example, a subagents pattern can manage workflow sub-graphs or use the router pattern as a tool (querying multiple knowledge bases in parallel, then synthesizing results). A state machine can invoke skills at specific stages (loading specialized context only when reaching certain steps). The one tool for all agents approach can work within a custom workflow to parallelize independent tasks while maintaining deterministic overall structure.

Subagents

In the subagents architecture, a central main agent (often referred to as a supervisor) coordinates subagents by calling them as tools. The main agent decides which subagent to invoke, what input to provide, and how to combine results. Subagents are stateless—they don’t remember past interactions, with all conversation memory maintained by the main agent. This provides context isolation: each subagent invocation works in a clean context window, preventing context bloat in the main conversation. Key characteristics:
  • Centralized control: All routing passes through the main agent
  • No direct user interaction: Subagents return results to the main agent, not the user
  • Subagents via tools: Subagents are invoked via tools
  • Parallel execution: The main agent can invoke multiple subagents in a single turn
Use the subagents pattern when you have multiple distinct domains (e.g., calendar, email, CRM, database), subagents don’t need to converse directly with users, or you want centralized workflow control. For simpler cases with just a few tools, use a single agent.

Tutorial: Build an agent with subagents

Learn how to build a personal assistant using the subagents pattern, where a central main agent (supervisor) coordinates specialized worker agents.

Sync vs async

By default, subagent calls are synchronous—the main agent waits for each subagent to complete before continuing. This is simple and works well for most cases. For long-running tasks (reviewing contracts, conducting research, auditing code), use asynchronous execution. The main agent kicks off a background job and continues conversing with the user while the work completes.
Key characteristics:
  • Three-tool pattern: Kick off job (returns job ID), check status, get results
  • Asynchronous execution: Work proceeds in the background while main agent remains responsive
  • User-initiated checks: Main agent checks job status when the user asks, not on a polling schedule
Handling job completion: When a job finishes, your application needs to notify the user. One approach: surface a notification that, when clicked, sends a HumanMessage like “Check job_123 and summarize the results.”

Tool patterns

There are two main ways to expose subagents as tools:
PatternBest forTrade-off
Tool per agentFine-grained control over each subagent’s input/outputMore setup, but more customization
Single dispatch toolMany agents, distributed teams, convention over configurationSimpler composition, less per-agent customization

Tool per agent

The key idea is wrapping subagents as tools that the main agent can call:
import { createAgent, tool } from "langchain";
import * as z from "zod";

// Create a sub-agent
const subagent = createAgent({...});  

// Wrap it as a tool
const callSubagent = tool(  
  async ({ query }) => {  
    const result = await subagent.invoke({
      messages: [{ role: "user", content: query }]
    });
    return result.messages.at(-1)?.text;
  },
  {
    name: "subagent_name",
    description: "subagent_description",
    schema: z.object({
      query: z.string().describe("The query to send to subagent")
    })
  }
);

// Main agent with subagent as a tool
const mainAgent = createAgent({ model, tools: [callSubagent] });  
The main agent invokes the subagent tool when it decides the task matches the subagent’s description, receives the result, and continues orchestration. See Context engineering for fine-grained control.

Single dispatch tool

An alternative approach uses a single parameterized tool to spawn ephemeral sub-agents for independent tasks. Unlike the tool per agent approach where each sub-agent is wrapped as a separate tool, this uses a convention-based approach with a single task tool: the task description is passed as a human message to the sub-agent, and the sub-agent’s final message is returned as the tool result. Key characteristics:
  • Single task tool: One parameterized tool that can invoke any registered sub-agent by name
  • Convention-based invocation: Agent selected by name, task passed as human message, final message returned as tool result
  • Team distribution: Different teams can develop and deploy agents independently
  • Agent discovery: Sub-agents can be discovered via system prompt (listing available agents) or through progressive disclosure (loading agent information on-demand via tools)
Use this approach when you want to distribute agent development across multiple teams, need to isolate complex tasks into separate context windows, need a scalable way to add new agents without modifying the coordinator, or prefer convention over customization. This approach trades flexibility in context engineering for simplicity in agent composition and strong context isolation.
An interesting aspect of this approach is that sub-agents may have the exact same capabilities as the main agent. In such cases, spawning a sub-agent is really about context isolation as the primary reason—allowing complex, multi-step tasks to run in isolated context windows without bloating the main agent’s conversation history. The sub-agent completes its work autonomously and returns only a concise summary, keeping the main thread focused and efficient.
import { tool, createAgent } from "langchain";
import * as z from "zod";

// Sub-agents developed by different teams
const researchAgent = createAgent({
  model: "gpt-4o",
  prompt: "You are a research specialist...",
});

const writerAgent = createAgent({
  model: "gpt-4o",
  prompt: "You are a writing specialist...",
});

// Registry of available sub-agents
const SUBAGENTS = {
  research: researchAgent,
  writer: writerAgent,
};

const task = tool(
  async ({ agentName, description }) => {
    const agent = SUBAGENTS[agentName];
    const result = await agent.invoke({
      messages: [
        { role: "user", content: description }
      ],
    });
    return result.messages.at(-1)?.content;
  },
  {
    name: "task",
    description: `Launch an ephemeral subagent.

Available agents:
- research: Research and fact-finding
- writer: Content creation and editing`,
    schema: z.object({
      agentName: z
        .string()
        .describe("Name of agent to spawn"),
      description: z
        .string()
        .describe("Task description"),
    }),
  }
);

// Main coordinator agent
const mainAgent = createAgent({
  model: "gpt-4o",
  tools: [task],
  prompt: (
    "You coordinate specialized sub-agents. " +
    "Available: research (fact-finding), " +
    "writer (content creation). " +
    "Use the task tool to delegate work."
  ),
});

Context engineering

Control how context flows between the main agent and its subagents:
CategoryPurposeImpacts
Subagent specsEnsure subagents are invoked when they should beMain agent routing decisions
Subagent inputsEnsure subagents can execute well with optimized contextSubagent performance
Subagent outputsEnsure the supervisor can act on subagent resultsMain agent performance

Subagent specs

The name and description you give a subagent tool determine when the main agent decides to invoke it. These are prompting levers—choose them carefully.
  • Name: How the main agent refers to the sub-agent. Keep it clear and action-oriented (e.g., research_agent, code_reviewer).
  • Description: What the main agent knows about the sub-agent’s capabilities. Be specific about what tasks it handles and when to use it.

Subagent inputs

Customize what context the subagent receives to execute its task. Add input that isn’t practical to capture in a static prompt—full message history, prior results, or task metadata—by pulling from the agent’s state.
import { createAgent, tool, AgentState, ToolMessage } from "langchain";
import { Command } from "@langchain/langgraph";
import * as z from "zod";

// Example of passing the full conversation history to the sub agent via the state.
const callSubagent1 = tool(
  async ({query}) => {
    const state = getCurrentTaskInput<AgentState>();
    // Apply any logic needed to transform the messages into a suitable input
    const subAgentInput = someLogic(query, state.messages);
    const result = await subagent1.invoke({
      messages: subAgentInput,
      // You could also pass other state keys here as needed.
      // Make sure to define these in both the main and subagent's
      // state schemas.
      exampleStateKey: state.exampleStateKey
    });
    return result.messages.at(-1)?.content;
  },
  {
    name: "subagent1_name",
    description: "subagent1_description",
  }
);

Subagent outputs

Customize what the main agent receives back so it can make good decisions. Two strategies:
  1. Prompt the sub-agent: Specify exactly what should be returned. A common failure mode is that the sub-agent performs tool calls or reasoning but doesn’t include results in its final message—remind it that the supervisor only sees the final output.
  2. Format in code: Adjust or enrich the response before returning it. For example, pass specific state keys back in addition to the final text using a Command.
import { tool, ToolMessage } from "langchain";
import { Command } from "@langchain/langgraph";
import * as z from "zod";

const callSubagent1 = tool(
  async ({ query }, config) => {
    const result = await subagent1.invoke({
      messages: [{ role: "user", content: query }]
    });

    // Return a Command to update multiple state keys
    return new Command({
      update: {
        // Pass back additional state from the subagent
        exampleStateKey: result.exampleStateKey,
        messages: [
          new ToolMessage({
            content: result.messages.at(-1)?.text,
            tool_call_id: config.toolCall?.id!
          })
        ]
      }
    });
  },
  {
    name: "subagent1_name",
    description: "subagent1_description",
    schema: z.object({
      query: z.string().describe("The query to send to subagent1")
    })
  }
);

Handoffs

In the handoffs architecture, behavior changes dynamically based on state. The core mechanism: tools update a state variable (e.g., current_step or active_agent) that persists across turns, and the system reads this variable to adjust behavior—either applying different configuration (system prompt, tools) or routing to a different agent. This pattern supports both handoffs between distinct agents and dynamic configuration changes within a single agent.
The term handoffs was coined by OpenAI for using tool calls (e.g., transfer_to_sales_agent) to transfer control between agents or states.
Key characteristics:
  • State-driven behavior: Behavior changes based on a state variable (e.g., current_step or active_agent)
  • Tool-based transitions: Tools update the state variable to move between states
  • Direct user interaction: Each state’s configuration handles user messages directly
  • Persistent state: State survives across conversation turns
Use the handoffs pattern when you need to enforce sequential constraints (unlock capabilities only after preconditions are met), the agent needs to converse directly with the user across different states, or you’re building multi-stage conversational flows. This pattern is particularly valuable for customer support scenarios where you need to collect information in a specific sequence — for example, collecting a warranty ID before processing a refund.

Tutorial: Build a customer support agent using handoffs

Learn how to build a customer support agent using the handoffs pattern, where a single agent transitions between different configurations.
There are two ways to implement handoffs: single agent with middleware (one agent with dynamic configuration) or multiple agent subgraphs (distinct agents as graph nodes).

Single agent with middleware

A single agent changes its behavior based on state. Middleware intercepts each model call and dynamically adjusts the system prompt and available tools. Tools update the state variable to trigger transitions:
import { tool } from "@langchain/core/tools";
import { Command } from "@langchain/langgraph";
import { ToolMessage } from "@langchain/core/messages";
import { z } from "zod";

const recordWarrantyStatus = tool(
  async ({ status }, config) => {
    return new Command({
      update: {
        messages: [
          new ToolMessage({
            content: `Warranty status recorded: ${status}`,
            tool_call_id: config.toolCall?.id!
          })
        ],
        warrantyStatus: status,
        currentStep: "specialist"  // Update state to trigger transition
      }
    });
  },
  {
    name: "record_warranty_status",
    description: "Record warranty status and transition to next step.",
    schema: z.object({
      status: z.string()
    })
  }
);
import { tool, createAgent, AgentState } from "langchain";
import { wrapModelCall, ModelRequest, ModelResponse } from "langchain/agents/middleware";
import { Command } from "@langchain/langgraph";
import { ToolMessage } from "langchain/messages";
import * as z from "zod";

// 1. Define state with current_step tracker
const SupportState = z.object({  
  currentStep: z.string().default("triage"),  
  warrantyStatus: z.string().optional()
});

// 2. Tools update currentStep via Command
const recordWarrantyStatus = tool(
  async ({ status }, config) => {
    return new Command({  
      update: {  
        messages: [  
          new ToolMessage({
            content: `Warranty status recorded: ${status}`,
            tool_call_id: config.toolCall?.id!
          })
        ],
        warrantyStatus: status,
        currentStep: "specialist"  // Transition to next step
      }
    });
  },
  {
    name: "record_warranty_status",
    description: "Record warranty status and transition",
    schema: z.object({ status: z.string() })
  }
);

// 3. Middleware applies dynamic configuration based on currentStep
const applyStepConfig = wrapModelCall(  
  async (request: ModelRequest, handler) => {
    const step = request.state.currentStep || "triage";  

    // Map steps to their configurations
    const configs = {
      triage: {
        prompt: "Collect warranty information...",
        tools: [recordWarrantyStatus]
      },
      specialist: {
        prompt: `Provide solutions based on warranty: ${request.state.warrantyStatus}`,
        tools: [provideSolution, escalate]
      }
    };

    const config = configs[step];
    request = request.override({  
      systemPrompt: config.prompt,  
      tools: config.tools
    });
    return handler(request);
  }
);

// 4. Create agent with middleware
const agent = createAgent({
  model,
  tools: [recordWarrantyStatus, provideSolution, escalate],
  stateSchema: SupportState,
  middleware: [applyStepConfig],  
  checkpointer: new InMemorySaver()  // Persist state across turns
});

Multiple agent subgraphs

Multiple distinct agents exist as separate nodes in a graph. Handoff tools navigate between agent nodes using Command.PARENT to specify which node to execute next:
const transferToSales = tool(
  async () => {
    return new Command({
      goto: "sales_agent",  // Navigate to the sales agent node
      update: { activeAgent: "sales_agent" },
      graph: Command.PARENT  // Navigate in parent graph
    });
  },
  {
    name: "transfer_to_sales",
    description: "Transfer to the sales agent.",
    schema: z.object({})
  }
);
This example shows a multi-agent system with separate sales and support agents. Each agent is a separate graph node, and handoff tools allow agents to transfer conversations to each other.
import { StateGraph, START, MessagesAnnotation, Annotation } from "@langchain/langgraph";
import { createAgent } from "langchain";
import { tool } from "@langchain/core/tools";
import { Command } from "@langchain/langgraph";
import { z } from "zod";

// 1. Define state with active_agent tracker
const MultiAgentState = Annotation.Root({
  ...MessagesAnnotation.spec,
  activeAgent: Annotation<string>({
    reducer: (x, y) => y ?? x,
    default: () => "sales_agent"
  })
});

// 2. Create handoff tools
const transferToSales = tool(
  async () => {
    return new Command({
      goto: "sales_agent",
      update: { activeAgent: "sales_agent" },
      graph: Command.PARENT
    });
  },
  {
    name: "transfer_to_sales",
    description: "Transfer to the sales agent.",
    schema: z.object({})
  }
);

const transferToSupport = tool(
  async () => {
    return new Command({
      goto: "support_agent",
      update: { activeAgent: "support_agent" },
      graph: Command.PARENT
    });
  },
  {
    name: "transfer_to_support",
    description: "Transfer to the support agent.",
    schema: z.object({})
  }
);

// 3. Create agents with handoff tools
const salesAgent = createAgent({
  llm: model,
  tools: [transferToSupport],
  prompt: "You are a sales agent. Help with sales inquiries."
});

const supportAgent = createAgent({
  llm: model,
  tools: [transferToSales],
  prompt: "You are a support agent. Help with technical issues."
});

// 4. Create agent nodes that invoke the agents
const callSalesAgent = async (state: typeof MultiAgentState.State) => {
  const response = await salesAgent.invoke(state);
  return response;
};

const callSupportAgent = async (state: typeof MultiAgentState.State) => {
  const response = await supportAgent.invoke(state);
  return response;
};

// 5. Create router function
const routeToAgent = (state: typeof MultiAgentState.State): string => {
  return state.activeAgent;
};

// 6. Build the graph
const builder = new StateGraph(MultiAgentState)
  .addNode("sales_agent", callSalesAgent)
  .addNode("support_agent", callSupportAgent);

// Start with conditional routing based on initial activeAgent
builder.addConditionalEdges(
  START,
  routeToAgent,
  ["sales_agent", "support_agent"]
);

// After each agent, route to the active agent (enables handoffs)
builder.addConditionalEdges(
  "sales_agent",
  routeToAgent,
  ["sales_agent", "support_agent"]
);
builder.addConditionalEdges(
  "support_agent",
  routeToAgent,
  ["sales_agent", "support_agent"]
);

const graph = builder.compile();
Use single agent with middleware for most handoffs use cases—it’s simpler. Only use multiple agent subgraphs when you need bespoke agent implementations (e.g., a node that’s itself a complex graph with reflection or retrieval steps).
Implementation considerations:
  • Conversation history: Decide what conversation history each agent/state receives—full history, filtered portions, or summaries.
  • Tool semantics: Clarify whether handoff tools only update routing state or also perform actions (e.g., should transfer_to_sales() also create a ticket?).

Skills

In the skills architecture, specialized capabilities are packaged as invokable “skills” that augment an agent’s behavior. Skills are primarily prompt-driven specializations that an agent can invoke on-demand. Key characteristics:
  • Prompt-driven specialization: Skills are primarily defined by specialized prompts
  • Progressive disclosure: Skills become available based on context or user needs
  • Team distribution: Different teams can develop and maintain skills independently
  • Lightweight composition: Skills are simpler than full sub-agents
Use the skills pattern when you want a single agent with many possible specializations, you don’t need to enforce specific constraints between skills, or different teams need to develop capabilities independently. Common examples include coding assistants (skills for different languages or tasks), knowledge bases (skills for different domains), and creative assistants (skills for different formats).
This pattern is conceptually identical to llms.txt (introduced by Jeremy Howard), which uses tool calling for progressive disclosure of documentation. The skills pattern applies the same approach to specialized prompts and domain knowledge rather than just documentation pages.
import { tool, createAgent } from "langchain";
import * as z from "zod";

const loadSkill = tool(
  async ({ skillName }) => {
    // Load skill content from file/database
    return "";
  },
  {
    name: "load_skill",
    description: `Load a specialized skill.

Available skills:
- write_sql: SQL query writing expert
- review_legal_doc: Legal document reviewer

Returns the skill's prompt and context.`,
    schema: z.object({
      skillName: z
        .string()
        .describe("Name of skill to load")
    })
  }
);

const agent = createAgent({
  model: "gpt-4o",
  tools: [loadSkill],
  prompt: (
    "You are a helpful assistant. " +
    "You have access to two skills: " +
    "write_sql and review_legal_doc. " +
    "Use load_skill to access them."
  ),
});

Extending the pattern

When writing custom implementations, you can extend the basic skills pattern in several ways: Dynamic tool registration: Combine progressive disclosure with state management to register new tools as skills load. For example, loading a “database_admin” skill could both add specialized context and register database-specific tools (backup, restore, migrate). This uses the same tool-and-state mechanisms used across multi-agent patterns—tools updating state to dynamically change agent capabilities. Hierarchical skills: Skills can define other skills in a tree structure, creating nested specializations. For instance, loading a “data_science” skill might make available sub-skills like “pandas_expert”, “visualization”, and “statistical_analysis”. Each sub-skill can be loaded independently as needed, allowing for fine-grained progressive disclosure of domain knowledge. This hierarchical approach helps manage large knowledge bases by organizing capabilities into logical groupings that can be discovered and loaded on-demand.

Tutorial: Build an agent with on-demand skill loading

Learn how to implement skills with progressive disclosure, where the agent loads specialized prompts and schemas on-demand rather than upfront.

Router

In the router architecture, a routing step classifies input and directs it to specialized agents. This is useful when you have distinct verticals—separate knowledge domains that each require their own agent. Key characteristics:
  • Router decomposes the query
  • Zero or more specialized agents are invoked in parallel
  • Results are synthesized into a coherent response
Two approaches:

Stateless

Each request is routed independently—no memory between calls. For multi-turn conversations, see Stateful routers.
Stateless router vs Subagents: The subagents pattern can also route to multiple agents. Use the stateless router when you need specialized preprocessing or custom routing logic. Use the subagents pattern when you want the LLM to decide which agents to call dynamically.
Your organization’s knowledge lives in multiple places: GitHub repositories, Notion wikis, and Slack conversations. These are three distinct verticals, each requiring specialized tools and context. When users ask questions like “How do I authenticate API requests?”, the answer may require information from multiple sources. This example builds a router that decomposes queries, identifies which verticals to consult, queries them in parallel, and synthesizes results.
import { StateGraph, Annotation, START, END, Send } from "@langchain/langgraph";
import { createAgent } from "langchain";
import { ChatOpenAI } from "@langchain/openai";

const RouterState = Annotation.Root({
  query: Annotation<string>(),
  routes: Annotation<string[]>(),
  githubResult: Annotation<string | null>(),
  notionResult: Annotation<string | null>(),
  slackResult: Annotation<string | null>(),
  finalAnswer: Annotation<string>()
});

// Specialized agents for each vertical
const githubAgent = createAgent({
  model: "openai:gpt-4o",
  tools: [searchCode, searchIssues, searchPrs],
  prompt: "You are a GitHub expert. Answer questions about code, API references, and implementation details.",
  name: "github_agent"
});

const notionAgent = createAgent({
  model: "openai:gpt-4o",
  tools: [searchNotion, getPage],
  prompt: "You are a Notion expert. Answer questions about internal processes, policies, and team documentation.",
  name: "notion_agent"
});

const slackAgent = createAgent({
  model: "openai:gpt-4o",
  tools: [searchSlack, getThread],
  prompt: "You are a Slack expert. Answer questions by searching relevant threads and discussions.",
  name: "slack_agent"
});

const routerLlm = new ChatOpenAI({ model: "gpt-4o-mini" });

async function decomposeQuery(state: typeof RouterState.State) {  
  // Decompose query and determine which knowledge bases to consult
  const response = await routerLlm.invoke([
    {
      role: "system",
      content: "Analyze this query and determine which knowledge bases to consult. Return a JSON list with one or more of: 'github', 'notion', 'slack'."
    },
    { role: "user", content: state.query }
  ]);
  // Parse LLM response to get routes (simplified for example)
  const routes = ["github", "notion"];
  return { routes };
}

// Route to multiple agents in parallel
function routeToAgents(state: typeof RouterState.State): Send[] {  
  // Fan out to multiple agents in parallel
  return state.routes.map(route => new Send(route, state));  
}

async function queryGithub(state: typeof RouterState.State) {
  const result = await githubAgent.invoke({
    messages: [{ role: "user", content: state.query }]
  });
  return { githubResult: result.messages.at(-1)?.content };
}

async function queryNotion(state: typeof RouterState.State) {
  const result = await notionAgent.invoke({
    messages: [{ role: "user", content: state.query }]
  });
  return { notionResult: result.messages.at(-1)?.content };
}

async function querySlack(state: typeof RouterState.State) {
  const result = await slackAgent.invoke({
    messages: [{ role: "user", content: state.query }]
  });
  return { slackResult: result.messages.at(-1)?.content };
}

async function synthesizeResults(state: typeof RouterState.State) {  
  // Combine results from multiple agents into a coherent answer
  const results: string[] = [];
  if (state.githubResult) results.push(`GitHub: ${state.githubResult}`);
  if (state.notionResult) results.push(`Notion: ${state.notionResult}`);
  if (state.slackResult) results.push(`Slack: ${state.slackResult}`);

  // Use LLM to synthesize
  const synthesisResponse = await routerLlm.invoke([
    { role: "system", content: "Synthesize these search results into a coherent answer." },
    { role: "user", content: results.join("\n\n") }
  ]);
  return { finalAnswer: synthesisResponse.content };
}

// Build workflow with parallel execution
const workflow = new StateGraph(RouterState)
  .addNode("decompose", decomposeQuery)
  .addNode("github", queryGithub)
  .addNode("notion", queryNotion)
  .addNode("slack", querySlack)
  .addNode("synthesize", synthesizeResults)
  .addEdge(START, "decompose")
  .addConditionalEdges("decompose", routeToAgents, ["github", "notion", "slack"])  
  .addEdge("github", "synthesize")
  .addEdge("notion", "synthesize")
  .addEdge("slack", "synthesize")
  .addEdge("synthesize", END)
  .compile();

const result = await workflow.invoke({ query: "How do I authenticate API requests?" });
console.log(result.finalAnswer);

Stateful

For multi-turn conversations, you need to maintain context across invocations.

Tool wrapper

The simplest approach: wrap the stateless router as a tool that a conversational agent can call. The conversational agent handles memory and context; the router stays stateless. This avoids the complexity of managing conversation history across multiple parallel agents.
const searchDocs = tool(
  async ({ query }) => {
    const result = await workflow.invoke({ query });
    return result.finalAnswer;
  },
  {
    name: "search_docs",
    description: "Search across multiple documentation sources",
    schema: z.object({
      query: z.string().describe("The search query")
    })
  }
);

// Conversational agent uses the router as a tool
const conversationalAgent = createAgent({
  model,
  tools: [searchDocs],
  prompt: "You are a helpful assistant. Use search_docs to answer questions."
});

Full persistence

If you need the router itself to maintain state, use persistence to store message history. When routing to an agent, fetch previous messages from state and selectively include them in the agent’s context—this is a lever for context engineering.
Stateful routers require custom history management. If the router switches between agents across turns, conversations may not feel fluid to end users when agents have different tones or prompts. With parallel invocation, you’ll need to maintain history at the router level (inputs and synthesized outputs) and leverage this history in routing logic. Consider the handoffs pattern or subagents pattern instead—both provide clearer semantics for multi-turn conversations.

Custom workflow

In the custom workflow architecture, you define your own bespoke execution flow using LangGraph. You have complete control over the graph structure—including sequential steps, conditional branches, loops, and parallel execution. Use custom workflows when:
  • Standard patterns (subagents, skills, etc.) don’t fit your requirements
  • You need to mix deterministic logic with agentic behavior
  • Your use case requires complex routing or multi-stage processing
Each node in your workflow can be a simple function, an LLM call, or an entire agent with tools. You can also compose other architectures within a custom workflow—for example, embedding a multi-agent system as a single node. The router pattern is an example of a custom workflow.
Calling a LangChain agent from a LangGraph node: The main insight when mixing LangChain and LangGraph is that you can call a LangChain agent directly inside any LangGraph node. This lets you combine the flexibility of custom workflows with the convenience of pre-built agents:
import { createAgent } from "langchain";

const agent = createAgent({ model: "openai:gpt-4o", tools: [...] });

async function agentNode(state: typeof State.State) {
  // A LangGraph node that invokes a LangChain agent
  const result = await agent.invoke({
    messages: [{ role: "user", content: state.query }]
  });
  return { answer: result.messages.at(-1)?.content };
}
Example: RAG pipeline — A common use case is combining retrieval with an agent. This example builds a WNBA stats assistant that retrieves from a knowledge base and can fetch live news.
The workflow demonstrates three types of nodes:
  • Model node (Rewrite): Rewrites the user query for better retrieval using structured output.
  • Deterministic node (Retrieve): Performs vector similarity search — no LLM involved.
  • Agent node (Agent): Reasons over retrieved context and can fetch additional information via tools.
You can use LangGraph state to pass information between workflow steps. This allows each part of your workflow to read and update structured fields, making it easy to share data and context across nodes.
import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
import { createAgent, tool } from "langchain";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import * as z from "zod";

const State = Annotation.Root({
  question: Annotation<string>(),
  rewrittenQuery: Annotation<string>(),
  documents: Annotation<string[]>(),
  answer: Annotation<string>(),
});

// WNBA knowledge base with rosters, game results, and player stats
const embeddings = new OpenAIEmbeddings();
const vectorStore = await MemoryVectorStore.fromTexts(
  [
    // Rosters
    "New York Liberty 2024 roster: Breanna Stewart, Sabrina Ionescu, Jonquel Jones, Courtney Vandersloot.",
    "Las Vegas Aces 2024 roster: A'ja Wilson, Kelsey Plum, Jackie Young, Chelsea Gray.",
    "Indiana Fever 2024 roster: Caitlin Clark, Aliyah Boston, Kelsey Mitchell, NaLyssa Smith.",
    // Game results
    "2024 WNBA Finals: New York Liberty defeated Minnesota Lynx 3-2 to win the championship.",
    "June 15, 2024: Indiana Fever 85, Chicago Sky 79. Caitlin Clark had 23 points and 8 assists.",
    "August 20, 2024: Las Vegas Aces 92, Phoenix Mercury 84. A'ja Wilson scored 35 points.",
    // Player stats
    "A'ja Wilson 2024 season stats: 26.9 PPG, 11.9 RPG, 2.6 BPG. Won MVP award.",
    "Caitlin Clark 2024 rookie stats: 19.2 PPG, 8.4 APG, 5.7 RPG. Won Rookie of the Year.",
    "Breanna Stewart 2024 stats: 20.4 PPG, 8.5 RPG, 3.5 APG.",
  ],
  [{}, {}, {}, {}, {}, {}, {}, {}, {}],
  embeddings
);
const retriever = vectorStore.asRetriever({ k: 5 });

const getLatestNews = tool(
  async ({ query }) => {
    // Your news API here
    return "Latest: The WNBA announced expanded playoff format for 2025...";
  },
  {
    name: "get_latest_news",
    description: "Get the latest WNBA news and updates",
    schema: z.object({ query: z.string() })
  }
);

const agent = createAgent({
  model: "openai:gpt-4o",
  tools: [getLatestNews],
});

const model = new ChatOpenAI({ model: "gpt-4o" });

const RewrittenQuery = z.object({ query: z.string() });

async function rewriteQuery(state: typeof State.State) {
  const systemPrompt = `Rewrite this query to retrieve relevant WNBA information.
The knowledge base contains: team rosters, game results with scores, and player statistics (PPG, RPG, APG).
Focus on specific player names, team names, or stat categories mentioned.`;
  const response = await model.withStructuredOutput(RewrittenQuery).invoke([
    { role: "system", content: systemPrompt },
    { role: "user", content: state.question }
  ]);
  return { rewrittenQuery: response.query };
}

async function retrieve(state: typeof State.State) {
  const docs = await retriever.invoke(state.rewrittenQuery);
  return { documents: docs.map(doc => doc.pageContent) };
}

async function callAgent(state: typeof State.State) {
  const context = state.documents.join("\n\n");
  const prompt = `Context:\n${context}\n\nQuestion: ${state.question}`;
  const response = await agent.invoke({ messages: [{ role: "user", content: prompt }] });
  return { answer: response.messages.at(-1)?.contentBlocks };
}

const workflow = new StateGraph(State)
  .addNode("rewrite", rewriteQuery)
  .addNode("retrieve", retrieve)
  .addNode("agent", callAgent)
  .addEdge(START, "rewrite")
  .addEdge("rewrite", "retrieve")
  .addEdge("retrieve", "agent")
  .addEdge("agent", END)
  .compile();

const result = await workflow.invoke({ question: "Who won the 2024 WNBA Championship?" });
console.log(result.answer);

Connect these docs programmatically to Claude, VSCode, and more via MCP for real-time answers.