Recall the full request-execute-respond cycle from your LangChain course, and Module 14’s manual while True: loop that generalized it. You’re about to build that exact same mechanism again — but this time, as a real, explicit graph, with every step visible as a genuine node and edge, rather than hidden inside a Python loop.
Building the loop from raw primitives
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain.messages import ToolMessage
class State(TypedDict):
messages: Annotated[list, add_messages]
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"It's 22°C and sunny in {city}."
model = init_chat_model("openai:gpt-4o-mini")
model_with_tools = model.bind_tools([get_weather])
tools_by_name = {"get_weather": get_weather}
def call_model(state: State) -> dict:
return {"messages": [model_with_tools.invoke(state["messages"])]}
def call_tools(state: State) -> dict:
last_message = state["messages"][-1]
results = []
for tool_call in last_message.tool_calls:
result = tools_by_name[tool_call["name"]].invoke(tool_call["args"])
results.append(ToolMessage(content=result, tool_call_id=tool_call["id"]))
return {"messages": results}
def should_continue(state: State) -> str:
last_message = state["messages"][-1]
return "call_tools" if last_message.tool_calls else "END"
builder = StateGraph(State)
builder.add_node("call_model", call_model)
builder.add_node("call_tools", call_tools)
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", should_continue, {"call_tools": "call_tools", "END": END})
builder.add_edge("call_tools", "call_model")
graph = builder.compile()
result = graph.invoke({"messages": [{"role": "user", "content": "What's the weather in Nairobi?"}]})
print(result["messages"][-1].content)
Read this against your LangChain course’s manual loop directly. call_model is the model call. call_tools is the execution-and-ToolMessage-wrapping step. should_continue is the if not ai_response.tool_calls: check, now expressed as a real routing function. call_tools → call_model is the loop-back that let a multi-round tool cycle actually work. Nothing here is new — it’s the exact same mechanism, now genuinely visible as a graph rather than hidden inside Python control flow.
flowchart TD
START --> call_model
call_model -->|tool requested| call_tools
call_model -->|no tool needed| END
call_tools --> call_model
The prebuilt shortcut: ToolNode and tools_condition
Because this exact pattern is so overwhelmingly common, LangGraph ships real, ready-made components for both call_tools and should_continue.
from typing import Annotated
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langchain.chat_models import init_chat_model
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"It's 22°C and sunny in {city}."
model = init_chat_model("openai:gpt-4o-mini")
model_with_tools = model.bind_tools([get_weather])
def call_model(state: MessagesState) -> dict:
return {"messages": [model_with_tools.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_node("tools", ToolNode([get_weather]))
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", tools_condition)
builder.add_edge("tools", "call_model")
graph = builder.compile()
result = graph.invoke({"messages": [{"role": "user", "content": "What's the weather in Nairobi?"}]})
print(result["messages"][-1].content)
ToolNode([get_weather]) replaces your hand-written call_tools entirely — it inspects the last message’s tool calls, executes each one, and wraps every result in a ToolMessage, exactly as you did by hand. tools_condition replaces should_continue — a real, prebuilt routing function checking for tool calls, so genuinely common that LangGraph provides it directly, with no dictionary mapping required this time, since it already knows to route to the literal node named "tools" or to END.
Multiple tools
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langchain.chat_models import init_chat_model
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"It's 22°C and sunny in {city}."
@tool
def get_capital(country: str) -> str:
"""Get the capital city of a country."""
return {"Japan": "Tokyo"}.get(country, "Unknown")
model = init_chat_model("openai:gpt-4o-mini")
tools = [get_weather, get_capital]
model_with_tools = model.bind_tools(tools)
def call_model(state: MessagesState) -> dict:
return {"messages": [model_with_tools.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", tools_condition)
builder.add_edge("tools", "call_model")
graph = builder.compile()
result = graph.invoke({"messages": [{"role": "user", "content": "What's the weather in the capital of Japan?"}]})
print(result["messages"][-1].content)
ToolNode(tools) genuinely handles any number of tools, and any number of simultaneous tool calls the model requests in one turn — the exact multi-round, multi-tool cycle from your LangChain course’s Module 14, working correctly here too, entirely through this same loop.
What ToolNode actually does when a tool fails
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langchain.chat_models import init_chat_model
from langchain.tools import tool
@tool
def get_stock_price(ticker: str) -> str:
"""Get the current stock price for a ticker."""
prices = {"AAPL": 190.50}
return f"${prices[ticker]}" # deliberately unhandled — raises KeyError for unknown tickers
model = init_chat_model("openai:gpt-4o-mini")
model_with_tools = model.bind_tools([get_stock_price])
def call_model(state: MessagesState) -> dict:
return {"messages": [model_with_tools.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_node("tools", ToolNode([get_stock_price]))
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", tools_condition)
builder.add_edge("tools", "call_model")
graph = builder.compile()
result = graph.invoke({"messages": [{"role": "user", "content": "What's the stock price for XYZ?"}]})
print(result["messages"][-1].content)
Run this with a ticker not in the dictionary, and notice your program doesn’t crash. ToolNode genuinely catches the raised exception automatically and reports it back to the model as a ToolMessage, describing the failure — the same real safety net your LangChain course covered for create_agent, confirming directly that it’s built on this exact same ToolNode mechanism underneath.
Conditional routing based on which tool was actually called
Sometimes the workflow genuinely needs to behave differently depending on which tool just ran, not just whether one ran at all.
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langchain.chat_models import init_chat_model
from langchain.tools import tool
@tool
def issue_refund(order_id: str) -> str:
"""Issue a refund for an order."""
return f"Refund issued for {order_id}."
@tool
def get_order_status(order_id: str) -> str:
"""Check an order's status."""
return "Shipped"
model = init_chat_model("openai:gpt-4o-mini")
tools = [issue_refund, get_order_status]
model_with_tools = model.bind_tools(tools)
def call_model(state: MessagesState) -> dict:
return {"messages": [model_with_tools.invoke(state["messages"])]}
def route_after_tools(state: MessagesState) -> str:
last_tool_message = state["messages"][-1]
if "Refund issued" in last_tool_message.content:
return "log_refund"
return "call_model"
def log_refund(state: MessagesState) -> dict:
print("AUDIT LOG: a refund was just issued.")
return {}
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_node("log_refund", log_refund)
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", tools_condition)
builder.add_conditional_edges("tools", route_after_tools, ["log_refund", "call_model"])
builder.add_edge("log_refund", "call_model")
graph = builder.compile()
result = graph.invoke({"messages": [{"role": "user", "content": "Please refund order o_1."}]})
print(result["messages"][-1].content)
Notice a second conditional edge, route_after_tools, placed genuinely after tools rather than replacing tools_condition — a real, deliberate audit step specifically for one particular kind of action, without touching the general-purpose loop-continuation logic tools_condition already handles correctly.
Why ToolNode’s automatic error handling is solving a genuinely real, documented problem
It’s worth grounding this module’s ToolNode error-catching example in real, current data, because “tools sometimes fail” can sound like a minor edge case rather than the actual, dominant failure mode it genuinely is. One real, documented production analysis found that 67 percent of a company’s agent failures traced back to tool integration issues — not the underlying model at all. The same analysis described a genuinely specific, memorable example: an agent that crashed every 47 minutes, exactly when its in-memory cache hit 2GB, a failure that had nothing to do with reasoning quality and everything to do with the surrounding tool infrastructure.
The more dangerous, and more commonly documented, real pattern is what practitioners call silent tool-error swallowing: a tool call fails, and the agent simply continues as if it had succeeded, with no crash and no obvious signal anything went wrong. A commonly cited illustration of this exact pattern involves a customer-support agent processing a refund — it calls the real payments API correctly, but a single hallucinated parameter sends the refund to the wrong account or for the wrong amount, with no exception thrown at all. This is precisely why ToolNode’s automatic error catching — turning a genuine exception into a real, visible ToolMessage the model has to react to — matters as more than a convenience. It converts a failure mode that would otherwise be silent and invisible into one that’s at least loud enough to be noticed.
Common mistakes worth avoiding
Rebuilding the manual version once you know ToolNode exists. Recall this module’s own progression — the manual version was worth building once, to see the real mechanism plainly. In real, ongoing work, ToolNode and tools_condition genuinely save real, repetitive code with no real loss of capability for the common case.
Forgetting the loop-back edge from the tools node to the model. builder.add_edge("tools", "call_model") is what actually makes this a loop rather than a single pass — omit it, and a tool result would compute correctly but never actually reach the model to produce a real final answer.
Assuming ToolNode’s automatic error catching replaces writing defensive tools. Recall the same honest caveat from your LangChain course — a caught, generic error message is real and useful as a safety net, but it’s never as helpful to the model as a tool deliberately written to explain its own likely failures clearly.
What you should take away from this module
- The manual
call_model → call_tools → should_continueloop andToolNode/tools_conditionare genuinely the same mechanism — one built by hand to see it plainly, one prebuilt for real, everyday use. ToolNodehandles any number of tools and any number of simultaneous tool calls in one turn, and automatically catches a tool’s raised exceptions, reporting them back to the model gracefully.- A second, real conditional edge can sit right after
tools, for deliberate, specific logic — like auditing a particular kind of action — without disturbing the general tool-loop mechanism.
Where this goes next
The next module introduces create_react_agent — LangGraph’s own official, prebuilt way to skip building this exact graph by hand every single time, and directly compares it to LangChain’s create_agent, which you already know.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed