ToolNode and tools_condition
ToolNode runs the tools a model asked for. tools_condition is a ready-made router that goes to the tools node when the last message has tool calls, and to END otherwise.
Last updated: 27 Sep, 2026 · LangGraph 1.2
When a model asks for a tool, something has to run it and hand the result back. That is ToolNode. You can try it without a model by putting it in a tiny one-node graph and handing it a message that already contains a tool call.
Creating a ToolNode
from langgraph.prebuilt import ToolNode, tools_condition
tool_node = ToolNode([lookup_order])
builder.add_conditional_edges("call_model", tools_condition) # to "tools" or ENDYou can run ToolNode without a model. Put it in a one-node graph, hand it a message that already contains a tool call, and it runs the tool. Build that up one piece at a time.
The imports
Start with the imports. Each line brings in one piece you need.
from langgraph.prebuilt import ToolNode # the node that runs tools
from langchain.tools import tool # turns a function into a tool
from langchain.messages import AIMessage # the message that carries a tool callThe tool
Define one tool. It is a plain function with the @tool decorator, which lets ToolNode find it by name.
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order by id.""" # the docstring tells the model what it does
return f"Order {order_id}: shipped."Wrapping it in ToolNode
Wrap the tool in a ToolNode. You pass a list, so the node can hold more than one tool and run whichever one is asked for.
tool_node = ToolNode([lookup_order]) # a node that can run any tool in this listA hand-built tool call
Now build the message a model would send when it wants the tool. It has empty text, and its tool_calls list names the tool, the arguments, and an id. Building it by hand lets you test ToolNode without a model.
# an AIMessage that asks for the tool (as a model would return)
asked = AIMessage(content="", tool_calls=[
{"name": "lookup_order", "args": {"order_id": "A17"}, "id": "call_1"}
])Running ToolNode
Put the node in a one-node graph and run it. ToolNode reads the tool call, runs lookup_order with order id A17, and adds a ToolMessage holding the result. The last message is that result.
from langgraph.graph import StateGraph, START, END, MessagesState
b = StateGraph(MessagesState)
b.add_node("tools", tool_node) # ToolNode as the one node
b.add_edge(START, "tools")
b.add_edge("tools", END)
graph = b.compile()
out = graph.invoke({"messages": [asked]}) # ToolNode runs lookup_order
print(out["messages"][-1].content) # the tool's resultRunning a tool call end to end
The same pieces in one file, ready to run.
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode
from langchain.tools import tool
from langchain.messages import AIMessage
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order by id."""
return f"Order {order_id}: shipped."
tool_node = ToolNode([lookup_order])
# an AIMessage that asks for the tool (as a model would return)
asked = AIMessage(content="", tool_calls=[
{"name": "lookup_order", "args": {"order_id": "A17"}, "id": "call_1"}
])
b = StateGraph(MessagesState)
b.add_node("tools", tool_node)
b.add_edge(START, "tools")
b.add_edge("tools", END)
graph = b.compile()
out = graph.invoke({"messages": [asked]})
print(out["messages"][-1].content)What ToolNode did with the call
- The hand-built
AIMessagecarries atool_callforlookup_order, exactly what a model returns when it wants a tool. ToolNoderead the tool call, ran the tool, and appended aToolMessagewith the result.tools_conditionis the prebuilt check that decides whether to send the run to the tools node or to END, based on whether the last message has tool calls.
tools_condition in action
Call tools_condition on a state to see how it routes. When the last message has tool calls it returns the tools node's name, "tools"; when it does not, it returns "__end__".
from langgraph.prebuilt import tools_condition
from langchain.messages import AIMessage
asked = AIMessage(content="", tool_calls=[
{"name": "lookup_order", "args": {"order_id": "A17"}, "id": "call_1"}]) # has a tool call
plain = AIMessage("Your order shipped.") # no tool call
print(tools_condition({"messages": [asked]})) # -> route to the tools node
print(tools_condition({"messages": [plain]})) # -> route to the endWhere ToolNode fits
- The tools half of every agent: the model asks, ToolNode runs, the result goes back.
tools_conditionsaves you writing the same should-I-run-a-tool router by hand.
tools_condition expects the tools node to be named "tools". Name it something else and you must write the router yourself.Related
- Previous: Tools and bind_tools
- Next: Structured output: answers in a fixed shape
- Reference: ToolNode and the agent loop
- Add a second tool and a second tool call in the AIMessage. Are both results returned?
- Give the tool an id that no tool matches and read the error.
Slow is fine. Stopping is the only problem.