DEV Community

Syeed Talha
Syeed Talha

Posted on

Understanding "Router" in LangChain(Sending Queries to the Right Specialist)

In the last two posts, we looked at patterns where a single agent changes its own behavior — either by switching stages (handoffs) or by loading specialized instructions on demand (skills). This post covers a different approach: what if, instead of one agent wearing different hats, you had multiple separate agents, each genuinely specialized, and something in front of them decided who should answer?

That's the router pattern.

The problem: one agent can't be everything at once

Imagine you're building a support system that needs to handle billing questions, technical support questions, and general questions. You could try to build one mega-agent that knows all three domains. But that runs into familiar issues:

  • The agent has to hold billing knowledge, tech support knowledge, and general company knowledge all at once, whether or not a given question needs all three.
  • If a question is genuinely about two things at once — say, a billing charge and a technical bug — one agent trying to juggle both prompts at the same time tends to produce a muddled answer that half-addresses each.
  • Testing and improving the "billing" behavior risks accidentally breaking the "tech support" behavior, because it's all baked into the same prompt.

The router pattern sidesteps this by keeping the specialists completely separate, and adding a small piece of logic up front whose only job is to figure out who should handle the question.

Think of it like a receptionist at a company. They don't try to answer your billing question themselves — they figure out which department it belongs to and send you there. And if your question touches two departments, a good receptionist loops in both instead of guessing.

Two flavors of routing

This example actually shows two related patterns:

  1. Single router — the query goes to exactly one specialist.
  2. Parallel router — the query goes to multiple specialists at once, and their answers get combined.

Let's build up to both.

Full Code

"""
Router Demo
===========
A router classifies the query and sends it to the right specialized agent(s).

Two patterns from the docs:

1. SINGLE ROUTER (Command) - one agent handles the query
   Router -> decides which agent -> that agent answers

2. PARALLEL ROUTER (Send) - multiple agents answer simultaneously
   Router -> fans out to multiple agents -> synthesizes results

Think of it like a receptionist:
  - Single mode: "This is billing" -> sends you to billing
  - Parallel mode: "This involves billing AND tech" -> sends to both, combines answers
"""

import operator
import os
from dotenv import load_dotenv
from typing import Annotated, Sequence, TypedDict

from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send

load_dotenv()

free_llm = ChatOpenAI(
    model="nvidia/nemotron-nano-9b-v2:free",
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ.get("OPENROUTER_API_KEY"),
)

# =============================================
# 1. SPECIALIZED AGENTS
# =============================================

billing_agent = create_agent(
    model=free_llm,
    system_prompt=(
        "You are a billing specialist. Answer questions about: "
        "invoices, payments, refunds, pricing, subscriptions."
    ),
)

tech_support_agent = create_agent(
    model=free_llm,
    system_prompt=(
        "You are a technical support specialist. Answer questions about: "
        "bugs, errors, troubleshooting, installation."
    ),
)

general_agent = create_agent(
    model=free_llm,
    system_prompt=(
        "You are a general assistant. Answer any general questions "
        "about the company, products, or services."
    ),
)

# =============================================
# 2. STATE
# =============================================

class RouterState(TypedDict):
    query: str
    responses: Annotated[list[str], operator.add]

# =============================================
# 3. ROUTER — classifies the query
# =============================================

def classify_query(query: str) -> list[str]:
    """Determine which agent(s) should handle this query."""
    q = query.lower()
    agents = []
    if any(kw in q for kw in ["bill", "pay", "refund", "price", "cost", "subscription", "invoice", "charge"]):
        agents.append("billing")
    if any(kw in q for kw in ["error", "bug", "crash", "install", "fix", "issue", "not working", "won't work", "broken"]):
        agents.append("tech_support")
    if not agents:
        agents.append("general")
    return agents

# =============================================
# 4. NODES
# =============================================

def router(state: RouterState):
    """Classifies query and routes using Command (single) or Send (parallel)."""
    agents = classify_query(state["query"])
    print(f"  [Router] -> {agents}")

    if len(agents) == 1:
        return agents[0]
    else:
        return [Send(agent, state) for agent in agents]

def run_billing(state: RouterState) -> dict:
    result = billing_agent.invoke({"messages": [{"role": "user", "content": state["query"]}]})
    answer = result["messages"][-1].content
    return {"responses": [f"[Billing] {answer[:200]}"]}

def run_tech_support(state: RouterState) -> dict:
    result = tech_support_agent.invoke({"messages": [{"role": "user", "content": state["query"]}]})
    answer = result["messages"][-1].content
    return {"responses": [f"[Tech Support] {answer[:200]}"]}

def run_general(state: RouterState) -> dict:
    result = general_agent.invoke({"messages": [{"role": "user", "content": state["query"]}]})
    answer = result["messages"][-1].content
    return {"responses": [f"[General] {answer[:200]}"]}

def synthesize(state: RouterState):
    """Only used in parallel mode - combines multiple responses."""
    combined = "\n\n".join(state["responses"])
    print(f"  [Synthesize] Combined {len(state['responses'])} responses\n")

# =============================================
# 5. BUILD GRAPH
# =============================================

builder = StateGraph(RouterState)
builder.add_node("billing", run_billing)
builder.add_node("tech_support", run_tech_support)
builder.add_node("general", run_general)
builder.add_node("synthesize", synthesize)
builder.add_conditional_edges(START, router, ["billing", "tech_support", "general", "synthesize"])
builder.add_edge("billing", END)
builder.add_edge("tech_support", END)
builder.add_edge("general", END)
builder.add_edge("synthesize", END)

graph = builder.compile()

# =============================================
# 6. RUN
# =============================================

queries = [
    "How do I get a refund for my subscription?",
    "The app keeps crashing when I open it.",
    "I was charged twice and now the app won't work.",
]

for query in queries:
    print("=" * 60)
    print(f"Query: {query}")
    result = graph.invoke({"query": query, "responses": []})
    if result.get("responses"):
        for r in result["responses"]:
            print(f"  {r}")
    print()
Enter fullscreen mode Exit fullscreen mode

1. The specialists are just separate agents

billing_agent = create_agent(
    model=free_llm,
    system_prompt=(
        "You are a billing specialist. Answer questions about: "
        "invoices, payments, refunds, pricing, subscriptions."
    ),
)

tech_support_agent = create_agent(
    model=free_llm,
    system_prompt=(
        "You are a technical support specialist. Answer questions about: "
        "bugs, errors, troubleshooting, installation."
    ),
)

general_agent = create_agent(
    model=free_llm,
    system_prompt=(
        "You are a general assistant. Answer any general questions "
        "about the company, products, or services."
    ),
)
Enter fullscreen mode Exit fullscreen mode

Notice these are three completely independent agents, each with a narrow, focused system prompt. Unlike the handoffs or skills examples, there's no shared state variable changing one agent's behavior — these are just three different objects, each good at one thing and nothing else.

2. Shared state for collecting results

class RouterState(TypedDict):
    query: str
    responses: Annotated[list[str], operator.add]
Enter fullscreen mode Exit fullscreen mode

query holds the original question. responses is a list that will collect answers — and the Annotated[..., operator.add] part matters here: it tells LangGraph that whenever multiple nodes write to responses at the same time (which happens in parallel mode), the results should be appended together rather than one overwriting another. This is what makes it safe for two specialists to answer simultaneously without stepping on each other.

3. The router: deciding who should answer

def classify_query(query: str) -> list[str]:
    """Determine which agent(s) should handle this query."""
    q = query.lower()
    agents = []
    if any(kw in q for kw in ["bill", "pay", "refund", "price", "cost", "subscription", "invoice", "charge"]):
        agents.append("billing")
    if any(kw in q for kw in ["error", "bug", "crash", "install", "fix", "issue", "not working", "won't work", "broken"]):
        agents.append("tech_support")
    if not agents:
        agents.append("general")
    return agents
Enter fullscreen mode Exit fullscreen mode

This particular example uses simple keyword matching to decide the category — nothing fancy, just checking whether words like "refund" or "crash" show up. In a real project you might replace this with a small LLM call that classifies the query instead, but the important part isn't how the classification happens — it's what the router does with the result.

Notice it returns a list, not a single value. That's the detail that enables parallel routing: if a query matches both billing and tech-support keywords, both get included.

4. Turning the classification into an actual routing decision

def router(state: RouterState):
    """Classifies query and routes using Command (single) or Send (parallel)."""
    agents = classify_query(state["query"])
    print(f"  [Router] -> {agents}")

    if len(agents) == 1:
        return agents[0]
    else:
        return [Send(agent, state) for agent in agents]
Enter fullscreen mode Exit fullscreen mode

This is the heart of the pattern, and it branches into the two modes:

  • If only one agent matched, the router just returns that agent's name as a string. LangGraph interprets this as "go to this one node next" — a single, direct route.
  • If more than one agent matched, the router returns a list of Send(agent, state) objects. Send is a LangGraph primitive that means "kick off this node right now, in parallel, with this specific piece of state." Returning several of them fans the query out to multiple specialists at the same time.

This single function is why the same graph can behave as either a simple single-route dispatcher or a parallel fan-out, depending on what the query actually needs.

5. Each specialist just answers and reports back

def run_billing(state: RouterState) -> dict:
    result = billing_agent.invoke({"messages": [{"role": "user", "content": state["query"]}]})
    answer = result["messages"][-1].content
    return {"responses": [f"[Billing] {answer[:200]}"]}
Enter fullscreen mode Exit fullscreen mode

run_tech_support and run_general follow the identical shape. Each node calls its own dedicated agent with the original query, then appends a labeled answer into responses. Because of the operator.add annotation from earlier, if two of these nodes run in parallel, both of their answers land in the list instead of one clobbering the other.

6. Synthesizing when there's more than one answer

def synthesize(state: RouterState):
    """Only used in parallel mode - combines multiple responses."""
    combined = "\n\n".join(state["responses"])
    print(f"  [Synthesize] Combined {len(state['responses'])} responses\n")
Enter fullscreen mode Exit fullscreen mode

This node only really matters in the parallel case — after billing and tech support have both answered, something needs to combine those separate answers into one coherent response for the user. In this demo it's kept simple (just joining the text together), but this is exactly where you'd add a step that asks an LLM to merge multiple partial answers into a single, well-organized reply.

7. Wiring the graph together

builder = StateGraph(RouterState)
builder.add_node("billing", run_billing)
builder.add_node("tech_support", run_tech_support)
builder.add_node("general", run_general)
builder.add_node("synthesize", synthesize)
builder.add_conditional_edges(START, router, ["billing", "tech_support", "general", "synthesize"])
builder.add_edge("billing", END)
builder.add_edge("tech_support", END)
builder.add_edge("general", END)
builder.add_edge("synthesize", END)

graph = builder.compile()
Enter fullscreen mode Exit fullscreen mode

add_conditional_edges(START, router, [...]) is what plugs the routing function in at the very start of the graph. The list of node names tells LangGraph which destinations the router is allowed to send traffic to. From there, each specialist node has an edge straight to END — meaning a single-route query finishes as soon as one specialist answers, with no unnecessary extra steps.

Walking through the three example queries

queries = [
    "How do I get a refund for my subscription?",
    "The app keeps crashing when I open it.",
    "I was charged twice and now the app won't work.",
]
Enter fullscreen mode Exit fullscreen mode

Query 1: "How do I get a refund for my subscription?"
classify_query finds "refund" and "subscription," matching only the billing keywords. agents = ["billing"]. Since there's exactly one match, the router returns "billing" directly. The graph goes straight to run_billing, gets an answer, and ends. No parallelism, no synthesis step — just a direct route.

Query 2: "The app keeps crashing when I open it."
"Crash" matches the tech support keywords only. agents = ["tech_support"]. Same as above — single route, straight to run_tech_support, then END.

Query 3: "I was charged twice and now the app won't work."
This one matches both: "charge" hits the billing keywords, and "won't work" hits the tech support keywords. agents = ["billing", "tech_support"]. Since there are two matches, the router returns two Send objects. LangGraph runs run_billing and run_tech_support at the same time, each producing its own entry in responses. Both flow into synthesize, which combines them into one final result.

Why this pattern is worth knowing

  • True specialization. Each agent's prompt stays narrow and focused, which tends to produce better, more consistent answers than one agent trying to cover everything.
  • Handles ambiguous or multi-part questions gracefully. Instead of forcing a single agent to guess how to split its attention across two topics, the parallel router hands each part to the agent actually built for it.
  • Efficient by default. Simple, single-topic queries don't pay the cost of running multiple agents — they take the direct path straight to one specialist and stop.
  • Easy to extend. Adding a fourth specialist means writing one more agent, one more run function, one more keyword check, and one more node — the existing routing logic and specialists don't need to change.

How this compares to handoffs and skills

It's worth noting how this pattern differs from the earlier two:

  • Handoffs: one agent, changing stages over time, moving forward through a sequence.
  • Skills: one agent, loading different instructions into itself as needed.
  • Router: multiple independent agents, chosen (one or several) based on what the query needs, potentially running side by side.

Handoffs and skills are about one agent adapting itself. Router is about picking the right specialist, or specialists, from a group — and even splitting a single question across more than one of them when it genuinely needs it.

A mental model to keep

Back to the receptionist: for a simple question, they point you to one department and you're done. For a question that touches two departments, a good receptionist doesn't force you to pick one — they loop both departments in and someone combines the answer for you before it comes back. That's exactly what router plus Send plus synthesize are doing here.

Try it yourself

A natural next step is making classify_query smarter — instead of keyword matching, have it make a quick LLM call that returns a list of relevant categories. The rest of the graph doesn't need to change at all, since the router's contract is just "return one name, or a list of names." That's the benefit of keeping the classification logic separate from the routing mechanism: you can upgrade one without touching the other.

Top comments (0)