Building agents with LangGraph
Every agent framework hides the same loop: call the model, run the tools it asked for, repeat until it answers. Most frameworks hide it completely. LangGraph, from the LangChain team, does the opposite: it makes you draw the loop as a graph of steps over shared state. That sounds like more work, and for a simple chatbot it is. For anything with branches, approvals, retries, several agents or runs that last hours, having the control flow written down explicitly is exactly what you want.
This chapter builds a small support agent in LangGraph, step by step, so the concepts stick. LangGraph exists for Python and for JavaScript and TypeScript (LangGraph.js) with the same ideas; the code here is Python.
An agent framework that hides the loop is quicker to start with. What would you lose by not being able to see and edit the loop? Name one situation where that matters.
Four ideas#
State. A typed dictionary that every step reads and writes, for example the list of
messages. Each field can have a reducer that says how updates combine: operator.add on a
list means "append what a node returns" instead of "replace".
Nodes. Plain functions that take the state and return the fields they want to update. "Call the model" is a node. "Run the tools" is a node.
Edges. Which node runs next. A normal edge always goes to the same place. A conditional edge calls a function that looks at the state and picks, which is how loops and branches are made.
Compile. Turns the graph into something you can invoke, stream, and, with a checkpointer,
pause and resume.
Here is the agent we are about to write, running once.
The code#
import operator
from typing import Literal
from typing_extensions import Annotated, TypedDict
from langchain.chat_models import init_chat_model
from langchain.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage
from langchain.tools import tool
from langgraph.graph import END, START, StateGraph
model = init_chat_model("anthropic:claude-opus-5-5")
@tool
def get_order_status(order_id: str) -> str:
"""Look up the delivery status and ETA of one order by its id."""
return orders_api.status(order_id) # your real lookup
tools_by_name = {get_order_status.name: get_order_status}
model_with_tools = model.bind_tools([get_order_status])
class State(TypedDict):
# operator.add: nodes append messages instead of replacing the list
messages: Annotated[list[AnyMessage], operator.add]
def call_model(state: State):
system = SystemMessage("You are a support agent for a food delivery app.")
return {"messages": [model_with_tools.invoke([system] + state["messages"])]}
def run_tools(state: State):
results = []
for call in state["messages"][-1].tool_calls:
output = tools_by_name[call["name"]].invoke(call["args"])
results.append(ToolMessage(content=output, tool_call_id=call["id"]))
return {"messages": results}
def route(state: State) -> Literal["run_tools", END]:
return "run_tools" if state["messages"][-1].tool_calls else END
builder = StateGraph(State)
builder.add_node("call_model", call_model)
builder.add_node("run_tools", run_tools)
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", route, ["run_tools", END])
builder.add_edge("run_tools", "call_model")
agent = builder.compile()
result = agent.invoke({"messages": [HumanMessage("Where is order 44821?")]})
print(result["messages"][-1].content)Every line of the agent loop is visible: the system prompt, the tool dispatch, the decision to
continue. That is the point. To add a rule such as "refunds over Rs 500 go to a human", you add a
node and a branch in route. You do not fight a framework that decided the loop for you.
LangChain also ships a prebuilt agent for the standard loop, for when you do not need to change it. Reach for the graph when you do.
Memory and pausing: checkpointers#
Compile the graph with a checkpointer and LangGraph saves the state after every step, keyed by
a thread_id. Three things come with that for free: conversation memory (call again with the same
thread and the history is there), recovery (a crashed run resumes from its last step), and
pausing for a human (covered in the next chapters).
from langgraph.checkpoint.memory import InMemorySaver
agent = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "customer-17"}}
agent.invoke({"messages": [HumanMessage("Where is order 44821?")]}, config)
agent.invoke({"messages": [HumanMessage("And can I change the address?")]}, config)The second call knows which order "the address" refers to, because the thread's state was
restored. InMemorySaver is for development. In production use a database backed checkpointer,
such as the Postgres one.
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Explicit graph (LangGraph) | Every branch, loop and approval point is visible and editable. Built in persistence and interrupts. | More code and concepts for simple agents, and a framework to learn. | Agents with custom control flow, human approval, long runs or several agents. |
| Prebuilt agent loop | A working tool calling agent in a few lines. | Changing the loop means dropping down a level anyway. | Prototypes, and agents whose loop really is the standard one. |
| No framework | Full control, no dependency, nothing hidden. | You build persistence, streaming and resumption yourself. | Small agents, or teams that want to own every line. |
Checkpoint#
1. In the graph above, which piece creates the loop?
2. What does Annotated[list, operator.add] do on the messages field?
3. What does compiling with a checkpointer and passing a thread_id give you?
- LangGraph: state, nodes, edges, compile. The agent loop is a cycle you can see.
- Reducers say how updates combine. Append for messages.
- Conditional edges make branches and loops. Budgets go there too.
- Checkpointer plus thread_id gives memory, recovery and pausing.
LangGraph models an agent as a graph over shared state. State is a typed dictionary with reducers that say how updates combine, nodes are functions that return state updates, edges decide what runs next, and conditional edges create branches and the agent loop itself: call the model, route to the tool node if there are tool calls, and come back until it answers. Because the loop is explicit, adding an approval branch, a router or a step budget is just another node or condition. Compiling with a checkpointer saves state after every step under a thread id, which gives conversation memory, crash recovery and pausing for a human. For a standard loop a prebuilt agent is quicker. I reach for the graph when the control flow is mine to design.