← Back to Agentic AI map
Lesson 3.5 · Building with LangGraph

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.

langgraph

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.

request flowagent -> ToolNode -> agentstep 1 / 4

1 - The question enters as a message

invoke() starts with one HumanMessage in messages. The agent node sends the whole list to the model.

messages
1 - human
state
MessagesState
agent returns
{"messages": [response]}
reducer
append

"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.

Who does what
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.

ToolNode and bad input - checked
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.

Three levels
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

A tool-using agent graph
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)
Output - a real run, with a scripted model making the tool call
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)
What ToolNode does with good and bad calls - checked
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'.
Driving ToolNode with a model that cannot call 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.
Output - llama3 through ToolNode, real runs
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

ToolNode

Runs the tool calls in the last AI message.

from langgraph.prebuilt import ToolNode
tools_condition

Router: "tools" if there are tool calls, else "__end__".

add_conditional_edges("agent", tools_condition)
MessagesState

State with an appending messages list.

StateGraph(MessagesState)
bind_tools

Give a tool-calling model the schemas.

model.bind_tools(tools)
ToolMessage

One result per call, linked by tool_call_id.

message.tool_call_id
handle_tool_errors

Turn 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.

Two tools

“Add calculate to tools and ask a question that needs both. Count the ToolMessages.”

Break a call

“Script an AI message calling send_email, then one with the wrong argument name. Read both ToolMessages.”

Crash a tool

“Make a tool raise an exception, run it, then pass handle_tool_errors=True and run it again.”

Your own router

“Replace tools_condition with your own should_continue and rename the node "executor" using a path map.”

Guard it

“Add a node before ToolNode that skips a tool call already in the history - the 3.4 repeat guard.”

What usually goes wrong

Not binding the tools to the model

ToolNode knows the tools; the model must too, or it never requests them.

✗ model.invoke(state["messages"])
✓ model.bind_tools(tools).invoke(state["messages"])
Thinking ToolNode calls the model

It only executes tool calls already in the last AI message. The agent node calls the model.

Forgetting the edge back

Without tools -> agent the model never sees the results.

✗ builder.add_edge("tools", END)
✓ builder.add_edge("tools", "agent")
Leaving tool exceptions unhandled

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)
A plain state for a tool agent

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

What is ToolNode?
A prebuilt LangGraph node that executes the tool calls in the last AI message and returns their results as ToolMessages.
Does ToolNode decide which tool to use?
No. The model requests the tool, the router sends the graph to ToolNode, and ToolNode executes it.
Why connect ToolNode back to the agent?
So the model sees the results and decides whether to call another tool or answer.
What happens when the model makes no tool call?
tools_condition returns "__end__" and the run finishes.
Can one ToolNode execute several tools?
Yes - all the tools in its list, and several calls from one AI message, each returning its own ToolMessage.

Quiz

  1. 1.

    The model calls send_email, which ToolNode does not have. What happens?

  2. 2.

    A tool raises TimeoutError. What happens by default, and how do you change it?

  3. 3.

    Why can the agent node return only the new message?

  4. 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...