Knowing the loop explicitly lets you customize any part of it, which is the reason to use LangGraph.
An agent is a model in a loop with tools. In LangGraph that loop is three pieces:
1. A model node calls a model that knows the tools (model.bind_tools(tools) attaches their schemas). The reply is an AIMessage whose tool_calls list says which tools to run, or is empty when the model is done. 2. A tools node runs each requested tool and returns ToolMessages, each carrying the tool_call_id it answers. 3. A conditional edge after the model: if the last message has tool calls, go to tools; otherwise go to END. An edge from tools back to the model closes the loop.
Because you own every piece, you can:
- add a step before the model that trims or summarizes history,
- check tool calls against a policy before running them, or pause for approval,
- route some tools to special nodes,
- stop after N tool rounds by counting in state.
A few rules keep the history valid. Every tool call must get exactly one ToolMessage with the matching ID, or the next model call fails (LangGraph documents this as INVALID_CHAT_HISTORY). Return tool errors as ToolMessage text instead of raising, so the model can recover.
The next lesson shows the prebuilt pieces that do the same thing in a few lines.
Example
from typing import Literal
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain_core.messages import ToolMessage
from langgraph.graph import StateGraph, START, END, MessagesState
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two integers."""
return a * b
TOOLS = {"multiply": multiply}
model = init_chat_model("claude-sonnet-4-6").bind_tools(list(TOOLS.values()))
def call_model(state: MessagesState) -> dict:
return {"messages": [model.invoke(state["messages"])]}
def run_tools(state: MessagesState) -> dict:
calls = state["messages"][-1].tool_calls
return {"messages": [ToolMessage(str(TOOLS[c["name"]].invoke(c["args"])), tool_call_id=c["id"])
for c in calls]}
def should_continue(state: MessagesState) -> Literal["tools", "__end__"]:
return "tools" if state["messages"][-1].tool_calls else END
b = StateGraph(MessagesState)
b.add_node("model", call_model)
b.add_node("tools", run_tools)
b.add_edge(START, "model")
b.add_conditional_edges("model", should_continue)
b.add_edge("tools", "model")
agent = b.compile()Remember
- Loop = model node -> (tool calls?) -> tools node -> back to model
- bind_tools gives the model the tool schemas
- Each tool call needs exactly one ToolMessage with the matching tool_call_id
- Owning the loop lets you add trimming, policies and approvals
Watch out for
- Dropping a tool call without answering it, leaving an AIMessage with unanswered tool_calls that breaks the next model call.