A LangGraph state graph has four parts: state schema, nodes, edges, entry point. Compile and invoke.
Step 1 — Define the state schema
State is a TypedDict (or Pydantic model) describing what data flows through the graph.
from typing import TypedDict, Annotated
from langchain_core.messages import BaseMessage
import operator
class AgentState(TypedDict):
# `messages` is a list that ACCUMULATES across nodes (operator.add appends)
messages: Annotated[list[BaseMessage], operator.add]
# `iteration` REPLACES on update (default behavior — last write wins)
iteration: int
Annotated controls how state updates merge:
Annotated[list, operator.add]— append to list.- No annotation — replace.
For agent messages, you almost always want append behavior — accumulate the conversation.
Step 2 — Define nodes
A node is a function that takes state and returns a state update (partial dict):
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, AIMessage
llm = ChatAnthropic(model="claude-sonnet-4-6")
def call_model(state: AgentState) -> dict:
response = llm.invoke(state["messages"])
return {
"messages": [response], # appended (because of operator.add)
"iteration": state["iteration"] + 1, # replaces
}
def call_tool(state: AgentState) -> dict:
# Last message has tool_calls (from the LLM)
last_message = state["messages"][-1]
tool_messages = []
for tc in last_message.tool_calls:
tool = TOOL_REGISTRY[tc["name"]]
result = tool.invoke(tc["args"])
tool_messages.append(
ToolMessage(content=str(result), tool_call_id=tc["id"])
)
return {"messages": tool_messages}
Each node returns only the state pieces it changes. LangGraph merges them based on the schema's annotations.
Step 3 — Define the graph
from langgraph.graph import StateGraph, END
builder = StateGraph(AgentState)
builder.add_node("agent", call_model)
builder.add_node("tools", call_tool)
# Entry point
builder.set_entry_point("agent")
# Edges
def should_continue(state: AgentState) -> str:
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return END
builder.add_conditional_edges("agent", should_continue)
builder.add_edge("tools", "agent") # after tool, go back to agent
graph = builder.compile()
What's happening:
- Start at
agentnode. - After
agent, checkshould_continue:- If LLM wants to call a tool → go to
toolsnode. - Otherwise → END (graph completes).
- If LLM wants to call a tool → go to
- After
tools, always go back toagent.
This is the classic agent loop: LLM → tool → LLM → tool → ... → final answer.
Step 4 — Bind tools and run
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
'''Get current weather.'''
return f"Sunny, 28°C in {city}."
TOOL_REGISTRY = {"get_weather": get_weather}
# Bind tools to the LLM
llm_with_tools = llm.bind_tools([get_weather])
# Update call_model to use bound LLM
def call_model(state):
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response], "iteration": state["iteration"] + 1}
# Run
initial_state = {
"messages": [HumanMessage(content="What's the weather in Mumbai?")],
"iteration": 0,
}
final_state = graph.invoke(initial_state)
print(final_state["messages"][-1].content)
The graph:
- Calls the LLM with "What's the weather in Mumbai?".
- LLM responds with tool_call: get_weather(city="Mumbai").
should_continuesees tool_calls → routes to "tools".call_toolexecutes get_weather → "Sunny, 28°C in Mumbai".add_edge("tools", "agent")routes back to agent.- LLM sees the tool result, generates final response.
should_continuesees no tool_calls → END.
Inspecting the graph
# See the graph structure
print(graph.get_graph().draw_ascii())
# Or save as PNG:
graph.get_graph().draw_png("graph.png")
For agents, having the graph diagram in your repo is good documentation.
Streaming execution
for state_chunk in graph.stream(initial_state):
print(state_chunk)
# {"agent": {"messages": [AIMessage(...)], "iteration": 1}}
# {"tools": {"messages": [ToolMessage(...)]}}
# {"agent": {"messages": [AIMessage(...)], "iteration": 2}}
Each chunk shows which node ran and what state changes. Built-in observability.
Pre-built prebuilt.create_react_agent
For common agent patterns, LangGraph ships pre-built constructors:
from langgraph.prebuilt import create_react_agent
agent = create_react_agent(llm, tools=[get_weather])
result = agent.invoke({"messages": [HumanMessage("Weather in Mumbai?")]})
This is equivalent to the graph above but more concise. Use it for standard ReAct agents; build custom graphs when you need different control flow.
State design tips
- Use Pydantic over TypedDict for complex state — better validation.
- Annotate update behavior (operator.add for lists, custom mergers for complex types).
- Keep state minimal. Don't stuff everything in; pass through only what nodes need.
- Separate ephemeral from persistent state. Use Config for things that shouldn't be checkpointed.
Common first-graph mistakes
- Forgetting
operator.addon messages — replaces instead of accumulating. - No END routing — graph never terminates.
- No conditional logic — graph routes to
toolseven when LLM didn't request a tool. - Returning full state instead of partial — works but is verbose.
- Tool execution in the agent node — confuses concerns. Separate nodes for separate jobs.
Takeaway
State, nodes, edges, entry point. Four pieces and you have an agent. prebuilt.create_react_agent for standard patterns; custom graphs for unique flows. Stream state changes for observability. The pattern compounds: same primitives build simple agents AND complex multi-agent systems.