Tool Node
Replace the hand-written tools node with LangGraph’s prebuilt ToolNode - and see exactly what it does with good calls, bad calls and crashing tools.
What you will be able to do
- Explain what ToolNode does - and what it does not do
- Build agent -> ToolNode -> agent with MessagesState and tools_condition
- Follow a run message by message: human, AI tool call, tool result, answer
- Know how ToolNode reacts to unknown tools, bad arguments and tool exceptions
- Run the same graph with a model that cannot call tools, by turning structured output into tool calls
- Place ToolNode between create_agent and a fully hand-written loop
The idea, in plain English
Lesson 3.4’s tools node was ours: look up the action, run the function, write the observation. ToolNode is LangGraph’s prebuilt version. Give it a list of @tool functions; when it runs, it reads the tool calls in the last AI message, runs each one, and returns a ToolMessage per call - the result plus the call’s id - into the state.
That only works if the state is a conversation. MessagesState is the prebuilt state for it: one messages field whose updates are appended rather than replacing the list. The agent node returns {"messages": [response]}, ToolNode returns its ToolMessages, and the history grows: human, AI with tool call, tool result, AI answer.
ToolNode is the executor and nothing else. The model decides which tool to request, a router - tools_condition, prebuilt - decides whether the graph goes to ToolNode or ends, and the tool does the work. Keeping those apart is what lets you later put validation or human approval in front of ToolNode.
A model that cannot produce tool calls - our llama3 - cannot drive it directly: bind_tools fails with "does not support tools". We ran the graph two ways: with a scripted model producing real tool calls, and with llama3 choosing actions through structured output that the agent node turns into tool calls. LangGraph 1.2.14, langgraph-prebuilt 1.1.0.
Worked example: A tool-using graph agent: agent -> ToolNode -> agent.
1 - The question enters as a message
invoke() starts with one HumanMessage in messages. The agent node sends the whole list to the model.
"What is the weather in Hyderabad?" - the message list after each step of a real run.
What ToolNode does
ToolNode(tools) is a node that executes tool calls. When it runs it looks at the last AI message in the state, runs every tool call in it, and returns one ToolMessage per call - content is the result, name is the tool, tool_call_id links it back to the request. Two calls in one AI message (get_weather and calculate) came back as two ToolMessages: Rainy, 27°C and 96.
It is not a standalone function. Calling it outside a graph failed with "Missing required config key ... for ‘tools’" - it expects to run as a node in a compiled graph. And it does not call the model, choose tools, or decide whether to run at all.
ModelDecides which tool to request, with which arguments.tools_conditionRoutes to ToolNode if the last AI message has tool calls, else to END.ToolNodeRuns the requested tools and returns ToolMessages.Tool (@tool function)Does the actual work.MessagesStateCarries the conversation, appending every message.The graphControls the whole loop.MessagesState and the append rule
In Lesson 3.2 a node’s update replaced a field. MessagesState is different: its messages field has a reducer that appends. The agent returns {"messages": [response]} and the list grows by one; ToolNode returns its ToolMessages and the list grows again. That is why the agent can return only the new message and still keep the whole conversation.
Our run ended with four messages: human "What is the weather in Hyderabad?", AI with the get_weather tool call, tool "Cloudy, 29°C", AI "The weather in Hyderabad is currently cloudy at 29°C."
tools_condition: the prebuilt router
The router after the agent always asks one question: does the last message have tool calls? You can write it - return "tools" if state["messages"][-1].tool_calls else "end" - or use tools_condition from langgraph.prebuilt, which returns "tools" or "__end__". Our checks: an AI message with a tool call gave "tools"; one without gave "__end__".
With tools_condition the node must be named "tools", since that is what it returns. The drawn graph is the 3.4 loop: __start__ --> agent, agent -.-> tools, agent -.-> __end__, tools --> agent.
When things go wrong
ToolNode turns request mistakes into messages the model can read and correct. A call to send_email, which was not in its list, returned "Error: send_email is not a valid tool, try one of [get_weather, calculate]." with status error. A call with {"town": "Mumbai"} instead of city returned "Error invoking tool ‘get_weather’ ... city: Field required. Please fix the error and try again." The run continued.
An exception inside the tool is not converted by default: a tool that raised TimeoutError ended the whole run. Pass handle_tool_errors=True and it came back as a ToolMessage - "Error: TimeoutError(‘service timed out’) Please fix your mistakes." - with status error. Choose deliberately: crash loudly while developing, or let the model see the failure and recover.
Unknown tool nameError ToolMessage listing the valid tools. Run continues.Wrong argument nameError ToolMessage with the validation error. Run continues.Tool raises an exceptionThe run stops - unless handle_tool_errors=True.Last message has no tool callsToolNode returns nothing. tools_condition should not have sent it there.Watch out: ToolNode’s default lets a tool’s own exception end the run. Catch errors inside the tool, or pass handle_tool_errors=True, before a flaky API reaches production.
The model has to support tool calls
ToolNode executes AIMessage.tool_calls; something has to put them there. With a tool-calling model you bind the tools - model.bind_tools(tools) - and the model produces them natively. With llama3, bind_tools fails: "llama3:latest does not support tools".
The graph does not care where tool calls come from, which gives a bridge for such models. Our agent node asked llama3 for a structured Step (action and input, as in Lesson 3.4) and wrapped it in an AIMessage with a tool call. ToolNode and tools_condition ran unchanged. The umbrella question went get_weather("Mumbai") -> Rainy, 27°C, calculate("12 * 8") -> 96, then "Yes, bring an umbrella in Mumbai. The answer to 12 * 8 is 96." The Hyderabad question repeated get_weather three times before answering - the habit from 3.4, which a repeat guard before ToolNode would catch.
ToolNode, create_agent, or your own node
create_agent (Lesson 2.7) builds this exact graph for you: a model node, a ToolNode, and the loop. Use it when the standard loop is what you want. Build the graph yourself with ToolNode when you need to change the loop - insert a validation node or a human approval step before tools run, add a repeat guard, route some tools differently. Write your own tools node, as in 3.4, when execution itself is unusual.
More convenience going up, more control going down - and because you built each layer by hand first, none of them is a black box.
create_agent(model, tools)The whole loop, prebuilt. Least code, least control.StateGraph + ToolNode + tools_conditionYou own the graph; tool execution is prebuilt.StateGraph + your own tools nodeYou own everything, including execution (Lesson 3.4).Tools are actions
ToolNode runs whatever the model requests from the list you gave it. A send_email or delete_file tool runs just as readily as get_weather. Validate arguments inside each tool, check permissions from your own session (not from model arguments), and put a confirmation in front of anything irreversible - Lesson 3.7 adds human approval as a graph node.
Step-by-step code
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
@tool
def get_weather(city: str) -> str:
"""Get the weather for a city."""
weather = {"mumbai": "Rainy, 27°C", "delhi": "Sunny, 34°C", "hyderabad": "Cloudy, 29°C"}
return weather.get(city.lower(), "Weather information is not available.")
tools = [get_weather]
model_with_tools = ChatOllama(model="llama3.1", temperature=0).bind_tools(tools)
def agent(state: MessagesState):
response = model_with_tools.invoke(state["messages"])
return {"messages": [response]} # appended, not replaced
builder = StateGraph(MessagesState)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition) # -> "tools" or "__end__"
builder.add_edge("tools", "agent")
graph = builder.compile()
result = graph.invoke({"messages": [{"role": "user", "content": "What is the weather in Hyderabad?"}]})
for message in result["messages"]:
print(message.type, message.content or message.tool_calls)human What is the weather in Hyderabad?
ai [{'name': 'get_weather', 'args': {'city': 'Hyderabad'}, 'id': 'c0', 'type': 'tool_call'}]
tool Cloudy, 29°C
ai The weather in Hyderabad is currently cloudy at 29°C.
graph.get_graph() edges
__start__ --> agent; agent -.-> tools; agent -.-> __end__; tools --> agent;
With llama3 instead:
ChatOllama(model="llama3").bind_tools(tools).invoke(...)
ResponseError: llama3:latest does not support tools (status code: 400)One AI message, two tool calls
ToolMessage name=get_weather content='Rainy, 27°C' tool_call_id=c0
ToolMessage name=calculate content='96' tool_call_id=c1
send_email (not in the list)
Error: send_email is not a valid tool, try one of [get_weather, calculate]. status=error
get_weather with {"town": "Mumbai"}
Error invoking tool 'get_weather' with kwargs {'town': 'Mumbai'} with error:
city: Field required Please fix the error and try again. status=error
A tool that raises TimeoutError
ToolNode(tools) -> TimeoutError: service timed out (run stops)
ToolNode(tools, handle_tool_errors=True) -> Error: TimeoutError('service timed out')
Please fix your mistakes. status=error
ToolNode(tools).invoke(...) outside a graph
ValueError: Missing required config key 'N/A' for 'tools'.import uuid
from typing import Literal
from langchain_core.messages import AIMessage
from pydantic import BaseModel, Field
class Step(BaseModel):
action: Literal["get_weather", "calculate", "final_answer"]
action_input: str = Field(description="The city name, the arithmetic expression, or the final answer text")
ARGUMENT = {"get_weather": "city", "calculate": "expression"}
decide = ChatOllama(model="llama3", temperature=0).with_structured_output(Step)
SYSTEM = (
"You answer questions using tools: get_weather(city) and calculate(expression). Choose ONE next action. "
"Use a tool for each piece of information you do not have yet. "
"When the tool results contain everything needed, reply with final_answer."
)
def agent(state: MessagesState):
transcript = []
for m in state["messages"]:
if m.type == "human":
transcript.append(f"Question: {m.content}")
elif m.type == "ai" and m.tool_calls:
call = m.tool_calls[0]
transcript.append(f"Action: {call['name']}({list(call['args'].values())[0]!r})")
elif m.type == "tool":
transcript.append(f"Result: {m.content}")
step = decide.invoke([("system", SYSTEM), ("human", "\n".join(transcript))])
if step.action == "final_answer":
return {"messages": [AIMessage(content=step.action_input)]}
call = {"name": step.action, "args": {ARGUMENT[step.action]: step.action_input},
"id": f"call_{uuid.uuid4().hex[:8]}"}
return {"messages": [AIMessage(content="", tool_calls=[call])]} # a real tool call for ToolNode
# Same graph as above: ToolNode([get_weather, calculate]) and tools_condition, unchanged.Should I bring an umbrella in Mumbai, and what's 12 * 8? (4.2 s)
ai get_weather {'city': 'Mumbai'}
tool Rainy, 27°C
ai calculate {'expression': '12 * 8'}
tool 96
ai Yes, bring an umbrella in Mumbai. The answer to 12 * 8 is 96.
What is the weather in Hyderabad? (10.3 s)
ai get_weather {'city': 'Hyderabad'} tool Cloudy, 29°C
ai get_weather {'city': 'Hyderabad'} tool Cloudy, 29°C <- repeated
ai get_weather {'city': 'Hyderabad'} tool Cloudy, 29°C <- repeated
ai The weather in Hyderabad is Cloudy, 29°C.Tip: Name the node "tools" when you use tools_condition - it routes to that name. Any other name needs your own router or a path map.
ToolNode at a glance
ToolNodeRuns the tool calls in the last AI message.
from langgraph.prebuilt import ToolNode
tools_conditionRouter: "tools" if there are tool calls, else "__end__".
add_conditional_edges("agent", tools_condition)MessagesStateState with an appending messages list.
StateGraph(MessagesState)
bind_toolsGive a tool-calling model the schemas.
model.bind_tools(tools)
ToolMessageOne result per call, linked by tool_call_id.
message.tool_call_id
handle_tool_errorsTurn tool exceptions into error messages.
ToolNode(tools, handle_tool_errors=True)
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Add calculate to tools and ask a question that needs both. Count the ToolMessages.”
“Script an AI message calling send_email, then one with the wrong argument name. Read both ToolMessages.”
“Make a tool raise an exception, run it, then pass handle_tool_errors=True and run it again.”
“Replace tools_condition with your own should_continue and rename the node "executor" using a path map.”
“Add a node before ToolNode that skips a tool call already in the history - the 3.4 repeat guard.”
What usually goes wrong
ToolNode knows the tools; the model must too, or it never requests them.
✗ model.invoke(state["messages"])✓ model.bind_tools(tools).invoke(state["messages"])It only executes tool calls already in the last AI message. The agent node calls the model.
Without tools -> agent the model never sees the results.
✗ builder.add_edge("tools", END)✓ builder.add_edge("tools", "agent")By default a tool’s exception stops the run. Handle errors in the tool or set handle_tool_errors=True.
✗ ToolNode(tools)✓ ToolNode(tools, handle_tool_errors=True)Tool calls and results must accumulate. Use MessagesState so updates append.
✗ class State(TypedDict):
messages: list✓ StateGraph(MessagesState)Key points
- ToolNode runs the tool calls in the last AI message and returns one ToolMessage per call.
- It must run inside a graph, and it never calls the model.
- MessagesState appends messages, so the conversation is the state.
- tools_condition routes to "tools" or "__end__"; name the node "tools".
- Unknown tools and bad arguments become error messages; tool exceptions stop the run unless handle_tool_errors=True.
- The model must produce tool calls - llama3 cannot, but structured output can be turned into them.
- create_agent builds this graph for you; ToolNode lets you change it.
Quick check before you move on
Quiz
- 1.
The model calls send_email, which ToolNode does not have. What happens?
- 2.
A tool raises TimeoutError. What happens by default, and how do you change it?
- 3.
Why can the agent node return only the new message?
- 4.
How did llama3 drive ToolNode without tool calling?
Interview questions
What is ToolNode, and what is the difference between a tool and a ToolNode?
A tool performs an operation. ToolNode is the graph node that executes the tool calls a model requested, from a registered list, and returns the results as ToolMessages.
How does a tool-calling graph work?
The agent node calls a model bound to the tools; tools_condition routes to ToolNode if the AI message has tool calls, else to END; ToolNode executes and appends ToolMessages; an edge returns to the agent.
How does ToolNode handle errors?
Unknown tools and invalid arguments become error ToolMessages the model can correct. Exceptions raised inside a tool propagate unless handle_tool_errors is set.
Why use ToolNode instead of create_agent?
To own the graph - add validation, approval, repeat guards or special routing around tool execution - while still using prebuilt execution.
Does adding ToolNode make any model able to use tools?
No. Something must produce tool calls: a tool-calling model, or structured output converted into tool calls by your agent node.
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned