Guide

Named tool routing in LangGraph

To route tools by name in LangGraph, add a conditional edge after the model node. Its routing function reads the tool_calls on the latest message, maps each name to a node or subgraph, and returns those destinations, or END if there are none. Unknown names get their own route, and every call must receive a matching ToolMessage.

What named routing is for

When a model requests a tool, something has to decide what happens next. The simplest arrangement sends every call to one shared executor. LangGraph provides that as ToolNode, usually paired with tools_condition, which goes to a node called tools if the last message has tool calls, or otherwise ends the run.

That is a good default, and you should keep it while your tools are interchangeable. Named routing is useful once they are not. A documentation search can run freely, whereas a refund request might need an eligibility check, a review step and its own state. Giving tools their own destinations makes those differences visible in the graph.

This guide concentrates on that one decision. For state design, retries and checkpointing, read building an orchestrator with LangGraph. If graphs are new to you, begin with the LangGraph tutorial for beginners. For how a model requests a tool in the first place, see tool calling for AI agents.

All the samples below ran together against langgraph 1.2.20, with stub functions in place of the model and tools. They have not been run with a live model. Check the current LangGraph Graph API documentation for the version you install.

Step 1: map tool names to destinations

This illustrative sample defines the state, a table of routes and the routing function:

# Illustrative: routes each tool call by name, using stub tool bodies.
from typing import Annotated, TypedDict

from langchain_core.messages import ToolMessage
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages


class State(TypedDict):
    messages: Annotated[list, add_messages]


ROUTES = {
    "search_docs": "docs_tool",
    "get_order": "orders_tool",
    "request_refund": "refund_flow",
}


def route_tool_calls(state):
    calls = getattr(state["messages"][-1], "tool_calls", [])
    if not calls:
        return END
    return sorted({ROUTES.get(call["name"], "unknown_tool") for call in calls})

The add_messages reducer appends new messages to the list instead of overwriting it, and it is essential here because several nodes can write in the same step. The explicit table has a further advantage over using the tool name as a node name. It separates what the model sees from your graph structure, so a node can be renamed without changing the tool's description. It also means that text produced by a model can never select an arbitrary node.

The function returns a sorted list of destinations with duplicates removed. According to the LangGraph documentation, a routing function may return a node name, END or a list of names for fan-out. Models can request several tools at once, so the list is needed. A set of two calls to search_docs still routes once, and that node then answers both calls.

Step 2: write the tool nodes

Each destination handles only the calls intended for it. In this illustrative sample, search_docs and get_order stand for your own functions:

# Illustrative: each node answers only the calls routed to it.
def calls_for(state, names):
    last = state["messages"][-1]
    return [call for call in last.tool_calls if call["name"] in names]


def reply(call, content):
    return ToolMessage(content=content, tool_call_id=call["id"])


def docs_tool(state):
    calls = calls_for(state, {"search_docs"})
    return {"messages": [
        reply(call, search_docs(**call["args"])) for call in calls
    ]}


def orders_tool(state):
    calls = calls_for(state, {"get_order"})
    return {"messages": [
        reply(call, get_order(**call["args"])) for call in calls
    ]}


def unknown_tool(state):
    last = state["messages"][-1]
    calls = [call for call in last.tool_calls if call["name"] not in ROUTES]
    return {"messages": [
        reply(call, f"Unknown tool: {call['name']}") for call in calls
    ]}

Providers generally expect every tool call in an assistant message to be answered, so each call gets a ToolMessage carrying the matching tool_call_id. If a parallel call goes unanswered, the next model request may be rejected. This is why the filtering matters: a node that answered every call would duplicate the replies of the other nodes in its step.

The unknown_tool node is a deliberate route, not an afterthought. Although a model is given a fixed list of tools, nothing guarantees that its output will stay on the list. Returning the error as a message gives the model a chance to correct itself without running anything. You should also validate arguments before use, since they too are model output. Structured output from LLMs covers validation.

Step 3: route to a subgraph

A tool that requires several steps can be a graph of its own. LangGraph's subgraph documentation describes two ways to attach one. If the parent and subgraph share state keys, you can pass the compiled subgraph directly to add_node. If their schemas differ, you call the subgraph from inside a node function and translate the state in both directions.

The refund flow here uses the second form. It has no use for the conversation, and the model has no need to see its internal fields. In this illustrative sample, refund_allowed stands for your own business rule:

# Illustrative: a subgraph with its own schema, behind a wrapper node.
class RefundState(TypedDict):
    order_id: str
    eligible: bool
    outcome: str


def check_eligibility(state):
    return {"eligible": refund_allowed(state["order_id"])}


def record_outcome(state):
    text = "Sent for review" if state["eligible"] else "Not eligible"
    return {"outcome": text}


refund_builder = StateGraph(RefundState)
refund_builder.add_node("check_eligibility", check_eligibility)
refund_builder.add_node("record_outcome", record_outcome)
refund_builder.add_edge(START, "check_eligibility")
refund_builder.add_edge("check_eligibility", "record_outcome")
refund_builder.add_edge("record_outcome", END)
refund_graph = refund_builder.compile()


def refund_flow(state):
    messages = []
    for call in calls_for(state, {"request_refund"}):
        result = refund_graph.invoke({"order_id": call["args"]["order_id"]})
        messages.append(reply(call, result["outcome"]))
    return {"messages": messages}

The wrapper acts as a narrow interface. Only the order ID goes in, and only a short outcome comes out, so the model's history stays free of internal details. Note that the outcome here says "Sent for review". The eligibility decision is made in ordinary code, and no money moves in this sample. A real flow would pause for approval before any action of that kind, as human-in-the-loop approval for AI agents explains.

If the subgraph needs checkpointing, interrupts or streaming from inside, read the current documentation for how those behave across the graph boundary, because the details depend on how it is attached.

Step 4: wire the graph

The conditional edge connects everything. This illustrative sample assumes call_model is your own node, which invokes a model with the tools bound and returns its message:

# Illustrative: assembles the graph; call_model is your own node.
builder = StateGraph(State)
builder.add_node("call_model", call_model)
builder.add_node("docs_tool", docs_tool)
builder.add_node("orders_tool", orders_tool)
builder.add_node("refund_flow", refund_flow)
builder.add_node("unknown_tool", unknown_tool)

builder.add_edge(START, "call_model")
builder.add_conditional_edges(
    "call_model",
    route_tool_calls,
    ["docs_tool", "orders_tool", "refund_flow", "unknown_tool", END],
)
for name in ["docs_tool", "orders_tool", "refund_flow", "unknown_tool"]:
    builder.add_edge(name, "call_model")

graph = builder.compile()

The list passed as the third argument names the possible destinations. The documentation also allows a dictionary that maps a function's return values to node names, which is a handy alternative when you would rather have your router return labels. Each tool node leads back to call_model, so the model can read the results and either finish or ask for more.

In the stubbed run, a single assistant message requested search_docs, request_refund and a made-up tool. Each call received its own ToolMessage with the right ID, and the model then ran again with all three replies in place.

That cycle needs a limit. The documentation states that, from version 1.1.4, the default recursion limit is 10,007 super-steps, and that exceeding it raises GraphRecursionError. That is far too many to rely on as protection against a looping model that costs money on every turn. Pass a much lower recursion_limit as a top-level config key, and track attempts in state if you want a graceful exit, because hitting the limit is an error rather than a designed fallback.

Step 5: hand control back to the parent

Sometimes a node inside a subgraph discovers that the whole workflow must change course. Returning a Command with graph=Command.PARENT jumps to a node in the parent graph. This illustrative sample shows the node:

# Illustrative: a subgraph node hands control to a parent graph node.
from langgraph.types import Command


def escalate(state):
    return Command(
        update={"escalation_reason": "Refund needs a person"},
        goto="human_review",
        graph=Command.PARENT,
    )

Two details are worth knowing. The documentation says that if you update a parent key that the subgraph also uses, the parent needs a reducer for that key. And although the documentation shows a return annotation listing the destination, adding Command[Literal["human_review"]] to a subgraph node caused a compile error when tested against 1.2.20, because the subgraph has no node of that name. Leaving the annotation off worked. Test this against the version you use.

Also note that this pattern suits a subgraph attached directly as a node. Test it carefully if you call the subgraph from a wrapper function, which adds its own layer between the two graphs.

Conditional edges or Command?

LangGraph's guidance is clear: "For routing alone, prefer conditional edges." Use Command when a function needs to combine a state update with a routing decision. The documentation also warns against mixing Command routing with add_edge from the same node, as static edges still run. For parallel work where each branch needs different input, such as one job per item in a list, a routing function can return Send objects instead.

Conditional edges have a further advantage for this use: the routing function is a separate, side-effect-free piece of code that you can test alone.

Step 6: test the routes without a model

Because the router is an ordinary function, its behaviour can be checked by building messages by hand. This illustrative sample uses plain assertions:

# Illustrative: routing tests need no model and no compiled graph.
from langchain_core.messages import AIMessage


def state_with(*names):
    calls = [
        {"name": name, "args": {}, "id": str(i), "type": "tool_call"}
        for i, name in enumerate(names)
    ]
    return {"messages": [AIMessage(content="", tool_calls=calls)]}


def test_no_tool_call_ends_the_run():
    assert route_tool_calls(state_with()) == END


def test_unknown_name_has_a_safe_route():
    assert route_tool_calls(state_with("delete_all")) == ["unknown_tool"]


def test_parallel_calls_fan_out():
    routes = route_tool_calls(state_with("search_docs", "get_order"))
    assert routes == ["docs_tool", "orders_tool"]

Add a test with stubbed tools to check that every tool call receives exactly one reply, because this is the failure most likely to surface only with parallel calls. Then evaluate with real models, since the choice of tool is a separate matter from the routing that follows it. Testing AI agents covers that next layer.

Common pitfalls

  • A branch for every tool. If several tools share the same handling, group them on one node. A separate route should reflect a real difference.
  • Forgetting parallel calls. Code that reads only the first tool call leaves the others unanswered.
  • Letting the router do work. Keep routing free of model calls and side effects, and place any judgement in a node.
  • Treating routing as permission. A route decides where a call goes, not whether this user may make it. Check authorisation inside the tool.

If you need help designing a workflow of this kind, see AI orchestration with LangGraph.

Frequently asked questions

When should I use ToolNode instead of named routing?

Use ToolNode when all tools share the same handling and need nothing extra. Named routing earns its keep when particular tools need their own approval, retries, state or a whole subgraph of further steps.

Can a routing function send one message to several nodes?

Yes. It can return a list of node names, and LangGraph runs them in the same step. Any state field they all write to, such as messages, needs a reducer so that their updates are merged.

Should I use Command or conditional edges for routing?

The LangGraph documentation advises preferring conditional edges for routing alone. Command is for when one function needs to update state and choose the next node together, or when a subgraph node needs to reach its parent graph.

What happens if the model calls a tool that does not exist?

With the design in this guide, the unknown name goes to a dedicated node that returns an error as a ToolMessage. The model then sees the failure and can correct itself, while no real tool is executed.