LangGraph: Comprehensive Technical Guide (Beginner to Expert)
Latest: langgraph 1.2.11 | Updated: August 17, 2026
LangGraph: Comprehensive Technical Guide (Beginner to Expert)
Section titled “LangGraph: Comprehensive Technical Guide (Beginner to Expert)”Latest Version: LangGraph 1.2.11 (August 2026) Focus: Python examples with practical, production-ready patterns Author Note: This guide progresses from fundamentals through advanced multi-agent architectures with real-world workflows.
Errata (April 2026). An earlier draft of this page documented fabricated APIs (
langgraph.llm_hooks.pre_model_hook,langgraph.cache.cache_node,langgraph.graph.deferred,langgraph.prebuilt.command_tool,@tool(updates_state=True),langgraph templateCLI subcommand). They are not in the installed package. See the Errata section below for the real replacements. For middleware, read the dedicated Chapter 8 — Middleware page.
What’s real in v1.2.11 (verified August 2026):
ToolRuntimedataclass (langgraph.prebuilt) — injected into tools at execution timeToolCallTransformerabstract class (langgraph.prebuilt) — intercepts and transforms tool call argumentsInjectedState/InjectedStore(langgraph.prebuilt) — inject graph state or the store into tools, invisible to the LLMOverwrite(langgraph.types) — bypass a reducer and replace a channel value directlyMessagesState(langgraph.graph) — built-in TypedDict withadd_messagesreducer, ready to subclassCheckpointMetadata/CheckpointTuple(langgraph.types,langgraph.checkpoint.base) — inspect and traverse checkpoint historyBinaryOperatorAggregate(langgraph.channels.binop) — underlying channel forAnnotated[T, reducer_fn]fields; supportsOverwritebypassTopicchannel (langgraph.channels.topic) — multi-value pub/sub withaccumulatemode for event logsEphemeralValuechannel (langgraph.channels.ephemeral_value) — per-step temporary state, auto-clears, used forSTARTchannelNamedBarrierValue(langgraph.channels.named_barrier_value) — synchronisation barrier that fires when all named writers signalentrypoint+taskFunctional API (langgraph.func) — build workflows withoutStateGraph;entrypoint.finalfor decoupled save valueRetryPolicy/TimeoutPolicy/CachePolicy(langgraph.types) — per-node / per-task resilience and caching configurationSendwith per-instance timeout override (langgraph.types) — dynamic map-reduce dispatch;timeoutkwarg perSendCommand.PARENT(langgraph.types) — signal parent graph’s state from inside a subgraph- Type-safe v2 streaming / invoke API (
version="v2") - Pydantic / dataclass auto-coercion on input
- Python 3.10 – 3.14 support (Python 3.9 dropped)
- Cross-thread memory via
Store+InjectedStore - Fixed time-travel replays with interrupts and subgraphs
Deprecated in v1.2.11:
langgraph.prebuilt.HumanInterrupt→langchain.agents.interrupt.HumanInterruptlanggraph.prebuilt.HumanInterruptConfig→langchain.agents.interrupt.HumanInterruptConfiglanggraph.prebuilt.ActionRequest→langchain.agents.interrupt.ActionRequest@entrypoint(config_schema=...)→@entrypoint(context_schema=...)add_node(..., retry=...)→add_node(..., retry_policy=...);add_node(..., cache=...)→add_node(..., cache_policy=...)
Deprecated (since v1.0, removal planned in v2.0.0):
langgraph.prebuilt.ValidationNode— still importable fromlanggraph.prebuilt.tool_validator; there is no drop-in schema-only replacement. Two migration paths: (1)ToolNode(handle_tool_errors=True)with a pydanticargs_schemaon each tool when tool execution is acceptable (note: the node executes valid calls — unsuitable for validate-only workflows with side-effecting tools); (2) a custom node callingtool.args_schema.model_validate(tc["args"])per tool call and returning aToolMessageon failure, without invoking the tool, for validate-only workflows.
Table of Contents
Section titled “Table of Contents”- Introduction & Fundamentals
- Installation & Setup
- Core Concepts
- Simple Agents
- Multi-Agent Systems
- Tool Integration
- Memory & Persistence
- Debugging & Visualization
- Type-Safe v2 API
- Human-in-the-Loop
- Advanced Patterns
- Errata — removed fabricated sections
- Functional API
- Production Deployment
- Class & API Reference
Introduction & Fundamentals
Section titled “Introduction & Fundamentals”What is LangGraph?
Section titled “What is LangGraph?”LangGraph is a low-level orchestration framework for building stateful, long-running agent systems. Unlike high-level abstractions that hide complexity, LangGraph gives you full control over:
- Agent behaviour through explicit state management
- Conditional logic with fine-grained routing
- Persistence with durable execution across failures
- Memory both short-term (checkpoints) and long-term (stores)
- Human oversight through interrupts and approvals
Built by LangChain Inc, it’s inspired by Google’s Pregel and Apache Beam, providing production-grade infrastructure trusted by Klarna, Replit, and Elastic.
Key Mental Model
Section titled “Key Mental Model”Think of LangGraph as a state machine with graphs:
Initial State → Node A → Condition → [Node B or Node C] → Final State ↓ Checkpoint savedEach node is a Python function. State flows through edges. Conditions route based on logic. Checkpoints persist progress.
Installation & Setup
Section titled “Installation & Setup”Basic Installation
Section titled “Basic Installation”# Core LangGraphpip install langgraph langchain-core
# Async supportpip install aiosqlite
# For database checkpointingpip install langgraph[postgres] # PostgreSQL supportpip install psycopg2-binary # PostgreSQL adapter
# LLM providers (example with Anthropic)pip install langchain-anthropic
# Development & debuggingpip install langgraph-cli # CLI toolsProject Structure
Section titled “Project Structure”my-agent-project/├── agent.py # Main agent definitions├── states.py # State schemas├── nodes.py # Node implementations├── tools.py # Custom tools├── checkpointer.py # Persistence setup├── langgraph.json # CLI config└── requirements.txtMinimal Setup Example
Section titled “Minimal Setup Example”from langgraph.graph import StateGraph, START, ENDfrom langgraph.checkpoint.memory import InMemorySaverfrom typing_extensions import TypedDict
class State(TypedDict): message: str response: str
def process_node(state: State): return {"response": f"Processed: {state['message']}"}
# Build graphbuilder = StateGraph(State)builder.add_node("process", process_node)builder.add_edge(START, "process")builder.add_edge("process", END)
# Compile with memorygraph = builder.compile(checkpointer=InMemorySaver())
# Executeresult = graph.invoke( {"message": "Hello"}, config={"configurable": {"thread_id": "user-1"}})print(result)Core Concepts
Section titled “Core Concepts”1. State Schema
Section titled “1. State Schema”State is the single source of truth for your graph. Define it with TypedDict or Pydantic:
from typing import Annotatedfrom typing_extensions import TypedDictfrom langgraph.graph.message import add_messages
class ChatState(TypedDict): messages: Annotated[list, add_messages] # Merges new + old messages user_id: str context: dict should_continue: bool
# The add_messages reducer automatically appends new messages# If you pass {"messages": [new_msg]}, it merges with existingKey insight: The reducer function (like add_messages) defines how state updates combine with existing state.
Custom reducer example:
from operator import add
class CounterState(TypedDict): count: Annotated[int, add] # 5 + 3 = 8 (not replaced) last_update: str
class AppendListState(TypedDict): items: Annotated[list, lambda x, y: x + y] # Custom append logic2. Nodes
Section titled “2. Nodes”Nodes are Python functions that receive state and return updates:
def my_node(state: State) -> dict: """Process state and return updates.""" processed = transform(state["data"]) return { "data": processed, "step_count": state.get("step_count", 0) + 1 }
# Async nodesasync def async_node(state: State) -> dict: result = await expensive_operation(state["data"]) return {"result": result}Critical: Return only the fields you’re updating. Other fields merge automatically.
3. Edges
Section titled “3. Edges”Edges connect nodes and define control flow:
from langgraph.graph import StateGraph, START, END
builder = StateGraph(State)
# Fixed edge: A → B alwaysbuilder.add_edge("node_a", "node_b")
# START/END pseudo-nodesbuilder.add_edge(START, "node_a") # Entry pointbuilder.add_edge("node_b", END) # Exit point
# Conditional edge: Choose next node based on statedef should_continue(state: State) -> str: if state["counter"] > 5: return "finish" return "loop"
builder.add_conditional_edges( "decision", should_continue, { "finish": END, "loop": "decision" })4. Compilation
Section titled “4. Compilation”The .compile() method turns your graph into an executable Pregel engine:
from langgraph.checkpoint.sqlite import SqliteSaver
# Compile with persistencecheckpointer = SqliteSaver.from_conn_string("checkpoints.db")graph = builder.compile(checkpointer=checkpointer)
# Without persistence (in-memory only)graph = builder.compile()5. Execution
Section titled “5. Execution”Multiple ways to run your graph:
# Synchronous - blockingresult = graph.invoke( {"message": "Hello"}, config={"configurable": {"thread_id": "user-1"}})
# Streaming - get updates as they happenfor event in graph.stream( {"message": "Hello"}, config={"configurable": {"thread_id": "user-1"}}, stream_mode="values" # or "updates" or "debug"): print(event)
# Batch - process multiple inputsresults = graph.batch( [{"message": "A"}, {"message": "B"}], configs=[ {"configurable": {"thread_id": f"user-{i}"}} for i in range(2) ])
# Asynchronousimport asyncioasync_result = await graph.ainvoke({"message": "Hello"}, config={...})
# Streaming asyncasync for event in graph.astream(...): print(event)Simple Agents
Section titled “Simple Agents”Example 1: Linear Chat Pipeline
Section titled “Example 1: Linear Chat Pipeline”A basic chatbot with no branching:
from langgraph.graph import StateGraph, START, ENDfrom langchain_anthropic import ChatAnthropicfrom langgraph.graph.message import add_messagesfrom typing_extensions import TypedDictfrom typing import Annotated
class State(TypedDict): messages: Annotated[list, add_messages] user_name: str
def fetch_user_context(state: State): """Load user info from database.""" # Simulate DB lookup return {"user_name": "Alice"}
def call_model(state: State): """Call LLM with messages.""" model = ChatAnthropic(model="claude-sonnet-5")
system_prompt = f"You're helping {state['user_name']}. Be concise."
response = model.invoke(state["messages"], system_prompt=system_prompt) return {"messages": [response]}
def save_conversation(state: State): """Persist messages to database.""" # Save state["messages"] to DB return {}
# Build the graphbuilder = StateGraph(State)builder.add_node("fetch_context", fetch_user_context)builder.add_node("model", call_model)builder.add_node("save", save_conversation)
builder.add_edge(START, "fetch_context")builder.add_edge("fetch_context", "model")builder.add_edge("model", "save")builder.add_edge("save", END)
# Compile with persistencefrom langgraph.checkpoint.memory import InMemorySavergraph = builder.compile(checkpointer=InMemorySaver())
# Use itconfig = {"configurable": {"thread_id": "chat-session-1"}}result = graph.invoke( {"messages": [{"role": "user", "content": "What's the weather?"}]}, config=config)
# Continue in same thread - context preservedresult = graph.invoke( {"messages": [{"role": "user", "content": "What did you say before?"}]}, config=config)Example 2: Conditional Routing
Section titled “Example 2: Conditional Routing”Route based on message type:
from langgraph.types import Sendfrom langgraph.graph import StateGraph, START, END
class State(TypedDict): query: str query_type: str result: str
def classify_query(state: State) -> dict: """Determine query type.""" query = state["query"].lower()
if any(word in query for word in ["search", "find", "lookup"]): return {"query_type": "search"} elif any(word in query for word in ["calculate", "math", "solve"]): return {"query_type": "math"} else: return {"query_type": "general"}
def search_web(state: State) -> dict: """Handle search queries.""" # Call search API result = f"Search results for: {state['query']}" return {"result": result}
def solve_math(state: State) -> dict: """Handle math queries.""" result = f"Math answer for: {state['query']}" return {"result": result}
def general_response(state: State) -> dict: """Handle general queries.""" model = ChatAnthropic(model="claude-sonnet-5") response = model.invoke(state["query"]) return {"result": response.content}
# Build graph with conditional routingbuilder = StateGraph(State)builder.add_node("classify", classify_query)builder.add_node("search", search_web)builder.add_node("math", solve_math)builder.add_node("general", general_response)
# Route based on classificationdef route_to_handler(state: State) -> str: return state["query_type"]
builder.add_edge(START, "classify")builder.add_conditional_edges( "classify", route_to_handler, { "search": "search", "math": "math", "general": "general" })
# All handlers lead to ENDfor handler in ["search", "math", "general"]: builder.add_edge(handler, END)
graph = builder.compile()
# Test itresult = graph.invoke({"query": "What's the population of Tokyo?"})print(result["result"]) # Routes to search
result = graph.invoke({"query": "Calculate 15% of 2000"})print(result["result"]) # Routes to mathExample 3: Looping with Counter
Section titled “Example 3: Looping with Counter”Agent that can loop (with limits):
class LoopState(TypedDict): iteration: int data: str final_result: str
def process_step(state: LoopState) -> dict: """Do one iteration of processing.""" processed = state["data"] + f" [step-{state['iteration']}]" return { "data": processed, "iteration": state["iteration"] + 1 }
def should_continue(state: LoopState) -> str: """Decide whether to loop or finish.""" if state["iteration"] >= 3: return "finish" return "continue"
def finalize(state: LoopState) -> dict: """Final processing.""" return {"final_result": state["data"]}
builder = StateGraph(LoopState)builder.add_node("process", process_step)builder.add_node("finalize", finalize)
builder.add_edge(START, "process")builder.add_conditional_edges( "process", should_continue, { "continue": "process", # Loop back to self "finish": "finalize" })builder.add_edge("finalize", END)
graph = builder.compile()
# Looping with safeguardconfig = {"configurable": {"thread_id": "loop-test"}}result = graph.invoke( {"iteration": 0, "data": "start"}, config=config)print(result)# Output: {'iteration': 3, 'data': 'start [step-0] [step-1] [step-2]', 'final_result': '...'}Example 4: Streaming Output
Section titled “Example 4: Streaming Output”See the graph execute step-by-step:
# Different streaming modesconfig = {"configurable": {"thread_id": "stream-test"}}
# Mode 1: "values" - full state after each stepprint("=== Streaming Values ===")for event in graph.stream( {"iteration": 0, "data": "test"}, config=config, stream_mode="values"): print(f"State: {event}\n")
# Mode 2: "updates" - only what changedprint("\n=== Streaming Updates ===")for event in graph.stream( {"iteration": 0, "data": "test"}, config=config, stream_mode="updates"): for node_name, updates in event.items(): print(f"{node_name} updated: {updates}\n")
# Mode 3: "debug" - node execution traceprint("\n=== Debug Mode ===")for event in graph.stream( {"iteration": 0, "data": "test"}, config=config, stream_mode="debug"): print(f"Debug: {event}\n")Multi-Agent Systems
Section titled “Multi-Agent Systems”Example 1: Supervisor Pattern
Section titled “Example 1: Supervisor Pattern”One coordinator agent routing to specialists:
from langchain_core.messages import BaseMessage# Note: AgentExecutor and create_tool_calling_agent require `pip install langchain langchain-anthropic`# from langchain.agents import AgentExecutor, create_tool_calling_agent# from langchain_anthropic import ChatAnthropicfrom langchain_core.prompts import ChatPromptTemplatefrom langgraph.types import Sendfrom langchain_core.tools import toolfrom typing import List
# Define specialized agents' tools@tooldef research_tool(query: str) -> str: """Search the web for information.""" return f"Research results for: {query}"
@tooldef calculator_tool(expression: str) -> str: """Evaluate math expressions.""" # In a real scenario, use a safe evaluation library return str(eval(expression))
# Helper function to create a specialist agentdef create_agent(llm, tools: list, system_prompt: str): prompt = ChatPromptTemplate.from_messages([ ("system", system_prompt), ("placeholder", "{chat_history}"), ("human", "{input}"), ("placeholder", "{agent_scratchpad}"), ]) agent = create_tool_calling_agent(llm, tools, prompt) executor = AgentExecutor(agent=agent, tools=tools) return executor
# Create agent runner functiondef agent_node(state, agent, name): result = agent.invoke(state) return {"messages": [BaseMessage(type="human", content=result["output"], name=name)]}
# Create specialized agentsmodel = ChatAnthropic(model="claude-3-5-sonnet-20240620")research_agent = create_agent(model, [research_tool], "You are a research specialist. Find accurate information.")math_agent = create_agent(model, [calculator_tool], "You are a math specialist. Solve problems step-by-step.")
# Supervisor stateclass SupervisorState(TypedDict): messages: Annotated[list, add_messages] next: str
# Supervisor logicdef supervisor_node(state: SupervisorState) -> dict: """Analyze request and pick best agent.""" last_message = state["messages"][-1]
# If the last message is from an agent, the supervisor can decide to end the process if hasattr(last_message, 'name'): return {"next": "END"}
prompt = f"""You manage two specialist agents:- research_agent: For web searches, fact-finding, current info- math_agent: For calculations and equations
Request: {last_message.content}
Which agent should handle this? Reply with ONLY the agent name or FINISH."""
response = model.invoke(prompt) next_agent = response.content.strip()
return {"next": next_agent}
# Build supervisor graphbuilder = StateGraph(SupervisorState)builder.add_node("supervisor", supervisor_node)builder.add_node("research_agent", lambda state: agent_node(state, research_agent, "research_agent"))builder.add_node("math_agent", lambda state: agent_node(state, math_agent, "math_agent"))
builder.add_edge(START, "supervisor")builder.add_conditional_edges( "supervisor", lambda x: x["next"], { "research_agent": "research_agent", "math_agent": "math_agent", "FINISH": END, })
# Agents return to supervisorbuilder.add_edge("research_agent", "supervisor")builder.add_edge("math_agent", "supervisor")
supervisor_graph = builder.compile(checkpointer=InMemorySaver())
# Test itconfig = {"configurable": {"thread_id": "supervisor-test"}}
result = supervisor_graph.invoke( {"messages": [{"role": "user", "content": "Research AI trends and calculate 25% of 1000"}]}, config=config)
print("Final response:", result["messages"][-1].content)Example 2: Parallel Worker Pattern
Section titled “Example 2: Parallel Worker Pattern”Fan-out to multiple workers, collect results:
from langgraph.types import Send
class WorkflowState(TypedDict): tasks: list[dict] results: Annotated[dict, lambda x, y: {**x, **y}] # Merge dicts
def split_tasks(state: WorkflowState) -> list[Send]: """Create parallel work for each task.""" return [ Send( "worker", { "task_id": task["id"], "task_data": task["data"] } ) for task in state["tasks"] ]
def worker_node(state: WorkflowState) -> dict: """Process one task.""" # Simulate work result = f"Processed: {state['task_data']}" return {"results": {state["task_id"]: result}}
def collect_results(state: WorkflowState) -> dict: """Aggregate all results.""" summary = f"Completed {len(state['results'])} tasks" return {"results": {"summary": summary}}
# Build parallel graphbuilder = StateGraph(WorkflowState)builder.add_node("split", split_tasks)builder.add_node("worker", worker_node)builder.add_node("collect", collect_results)
# Fan-out: split → multiple workersbuilder.add_conditional_edges( START, lambda _: "split")builder.add_conditional_edges( "split", lambda _: ["worker"], # All Send objects go to worker ["worker"])
# Fan-in: collect all resultsbuilder.add_edge("worker", "collect")builder.add_edge("collect", END)
parallel_graph = builder.compile()
# Testresult = parallel_graph.invoke({ "tasks": [ {"id": "task-1", "data": "data-a"}, {"id": "task-2", "data": "data-b"}, {"id": "task-3", "data": "data-c"} ]})
print("Results:", result["results"])# Output: {'task-1': 'Processed: data-a', 'task-2': 'Processed: data-b', ...}Example 3: Handoff Pattern
Section titled “Example 3: Handoff Pattern”Agents handing off to each other mid-conversation:
class HandoffState(TypedDict): messages: Annotated[list, add_messages] current_agent: str handoff_reason: str
def agent_a(state: HandoffState) -> dict: """First agent - handles initial request.""" last_message = state["messages"][-1].content
# Check if should handoff if "transfer" in last_message.lower(): return { "current_agent": "agent_b", "handoff_reason": "User requested transfer", "messages": [ { "role": "assistant", "content": "Transferring to agent B..." } ] }
# Normal response response = f"Agent A responds to: {last_message}" return { "current_agent": "agent_a", "messages": [{"role": "assistant", "content": response}] }
def agent_b(state: HandoffState) -> dict: """Second agent - takes over.""" last_message = state["messages"][-1].content response = f"Agent B (now handling): {last_message}" return { "current_agent": "agent_b", "messages": [{"role": "assistant", "content": response}] }
def route_agent(state: HandoffState) -> str: """Route to current agent.""" agent = state.get("current_agent", "agent_a") return agent
# Build handoff graphbuilder = StateGraph(HandoffState)builder.add_node("agent_a", agent_a)builder.add_node("agent_b", agent_b)
builder.add_edge(START, "agent_a")builder.add_conditional_edges( "agent_a", lambda state: "agent_b" if state.get("current_agent") == "agent_b" else "agent_a")builder.add_edge("agent_b", END)
handoff_graph = builder.compile(checkpointer=InMemorySaver())
# Test handoffconfig = {"configurable": {"thread_id": "handoff-test"}}
result = handoff_graph.invoke( {"messages": [{"role": "user", "content": "Help me"}], "current_agent": "agent_a"}, config=config)print("Step 1:", result["messages"][-1].content)
result = handoff_graph.invoke( {"messages": [{"role": "user", "content": "Transfer me to another agent"}]}, config=config)print("Step 2:", result["messages"][-1].content)print("Current agent:", result["current_agent"])Tool Integration
Section titled “Tool Integration”Example 1: Basic Tool Node
Section titled “Example 1: Basic Tool Node”Using LangGraph’s built-in ToolNode:
from langgraph.prebuilt import ToolNode, tools_conditionfrom langchain_core.tools import tool
# Define tools@tooldef get_weather(city: str) -> str: """Get current weather for a city.""" return f"Weather in {city}: Sunny, 72°F"
@tooldef get_stock_price(symbol: str) -> str: """Get current stock price.""" prices = {"AAPL": 150.25, "GOOGL": 140.50} return f"{symbol}: ${prices.get(symbol, 'N/A')}"
@tooldef send_email(to: str, subject: str, body: str) -> str: """Send an email.""" return f"Email sent to {to}: {subject}"
tools = [get_weather, get_stock_price, send_email]
# Create model with toolsmodel = ChatAnthropic(model="claude-sonnet-5")model_with_tools = model.bind_tools(tools)
class ToolState(TypedDict): messages: Annotated[list, add_messages] tool_call_results: list[str]
def agent_node(state: ToolState) -> dict: """Call model which may invoke tools.""" response = model_with_tools.invoke(state["messages"]) return {"messages": [response]}
# Build graph with tool handlingbuilder = StateGraph(ToolState)builder.add_node("agent", agent_node)builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
# tools_condition: Routes to "tools" if tool_calls exist, else ENDbuilder.add_conditional_edges( "agent", tools_condition, { "tools": "tools", END: END })
# After tools, return to agent for next iterationbuilder.add_edge("tools", "agent")
tool_graph = builder.compile()
# Use itresult = tool_graph.invoke({ "messages": [ {"role": "user", "content": "What's the weather in London and AAPL stock price?"} ]})
print("Final response:", result["messages"][-1].content)Example 2: Custom Tool Executor
Section titled “Example 2: Custom Tool Executor”Handle tool execution yourself for more control:
from langchain_core.messages import ToolMessageimport json
class CustomToolState(TypedDict): messages: Annotated[list, add_messages] tool_errors: Annotated[list, lambda x, y: x + y]
def execute_tools(state: CustomToolState) -> dict: """Manually execute tool calls with error handling.""" last_message = state["messages"][-1]
if not hasattr(last_message, "tool_calls"): return {}
tool_results = [] errors = []
for tool_call in last_message.tool_calls: try: tool_name = tool_call["name"] args = tool_call["arguments"]
if tool_name == "get_weather": result = get_weather(args["city"]) elif tool_name == "get_stock_price": result = get_stock_price(args["symbol"]) else: result = "Tool not found"
tool_results.append( ToolMessage( content=result, tool_call_id=tool_call["id"] ) ) except Exception as e: errors.append(f"Tool {tool_name} failed: {str(e)}") tool_results.append( ToolMessage( content=f"Error: {str(e)}", tool_call_id=tool_call["id"] ) )
return { "messages": tool_results, "tool_errors": errors if errors else [] }
# Build with custom tool executorbuilder = StateGraph(CustomToolState)builder.add_node("agent", agent_node)builder.add_node("tools", execute_tools)
builder.add_edge(START, "agent")builder.add_conditional_edges( "agent", lambda state: "tools" if hasattr(state["messages"][-1], "tool_calls") else END, {"tools": "tools", END: END})builder.add_edge("tools", "agent")
custom_tool_graph = builder.compile()Example 3: Conditional Tool Usage
Section titled “Example 3: Conditional Tool Usage”Only use tools when needed:
class ConditionalToolState(TypedDict): query: str use_tools: bool result: str
def should_use_tools(state: ConditionalToolState) -> str: """Decide whether tools are needed.""" query = state["query"].lower()
needs_tools = any( word in query for word in ["weather", "stock", "email", "current", "today"] )
return "use_tools" if needs_tools else "direct_response"
def with_tools(state: ConditionalToolState) -> dict: """Process with tool calling.""" # Call model with tools bound response = model_with_tools.invoke(state["query"]) return {"result": response.content, "use_tools": True}
def without_tools(state: ConditionalToolState) -> dict: """Process without tools.""" response = model.invoke(state["query"]) return {"result": response.content, "use_tools": False}
builder = StateGraph(ConditionalToolState)builder.add_node("route", should_use_tools)builder.add_node("with_tools", with_tools)builder.add_node("without_tools", without_tools)
builder.add_edge(START, "route")builder.add_conditional_edges( "route", should_use_tools, { "use_tools": "with_tools", "direct_response": "without_tools" })builder.add_edge("with_tools", END)builder.add_edge("without_tools", END)
conditional_tool_graph = builder.compile()
# Testresult = conditional_tool_graph.invoke({"query": "What's the weather?"})print("Used tools:", result["use_tools"]) # True
result = conditional_tool_graph.invoke({"query": "Tell me a joke"})print("Used tools:", result["use_tools"]) # FalseMemory & Persistence
Section titled “Memory & Persistence”Short-Term Memory: Checkpointers
Section titled “Short-Term Memory: Checkpointers”Checkpointers save graph state automatically at each step. Resume from failures.
In-Memory (Development)
Section titled “In-Memory (Development)”from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()graph = builder.compile(checkpointer=checkpointer)
# State persists within this Python process only# Useful for development & testingSQLite (Local Persistence)
Section titled “SQLite (Local Persistence)”from langgraph.checkpoint.sqlite import SqliteSaver
# File-basedcheckpointer = SqliteSaver.from_conn_string("checkpoints.db")
# Or in-memory SQLitecheckpointer = SqliteSaver.from_conn_string(":memory:")
graph = builder.compile(checkpointer=checkpointer)PostgreSQL (Production)
Section titled “PostgreSQL (Production)”from langgraph.checkpoint.postgres import PostgresSaverimport psycopg2
checkpointer = PostgresSaver.from_conn_string( "postgresql://user:password@localhost/langgraph_db")
# Async versionfrom langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
async_checkpointer = AsyncPostgresSaver.from_conn_string( "postgresql://user:password@localhost/langgraph_db")
graph = builder.compile(checkpointer=checkpointer)Using Checkpoints
Section titled “Using Checkpoints”config = {"configurable": {"thread_id": "user-123"}}
# First invocationresult = graph.invoke( {"query": "Start process"}, config=config)
# Check current statecurrent_state = graph.get_state(config)print(f"Next node: {current_state.next}")print(f"Values: {current_state.values}")print(f"Checkpoint ID: {current_state.config['configurable']['checkpoint_id']}")
# Continue in same thread - state restored from checkpointresult = graph.invoke( {"query": "Continue"}, config=config)
# Get state history (time-travel debugging)history = graph.get_state_history(config)
for i, checkpoint in enumerate(history): cp_id = checkpoint.config['configurable']['checkpoint_id'] print(f"Step {i}: {cp_id}") print(f" State: {checkpoint.values}")
# Resume from specific checkpoint (time-travel)old_checkpoint_id = history[1].config['configurable']['checkpoint_id']time_travel_config = { "configurable": { "thread_id": "user-123", "checkpoint_id": old_checkpoint_id }}
# Continue from that point in historyresult = graph.invoke( {"query": "New direction"}, config=time_travel_config)Long-Term Memory: Store
Section titled “Long-Term Memory: Store”Store provides cross-thread, persistent key-value storage with hierarchical namespaces:
from langgraph.store.memory import InMemoryStorefrom langgraph.store.postgres import AsyncPostgresStore
# In-memory for developmentstore = InMemoryStore()
# PostgreSQL for production (with vector search)store = AsyncPostgresStore.from_conn_string( "postgresql://user:password@localhost/langgraph_db")
# Store operationsnamespace = ("users", "user-123", "preferences")
# Put dataawait store.aput( namespace=namespace, key="theme", value={"dark_mode": True, "language": "en"})
# Get dataitem = await store.aget(namespace, "theme")print(item.value) # {"dark_mode": True, ...}
# List all in namespaceitems = await store.asearch(namespace_prefix=namespace)for item in items: print(f"{item.key}: {item.value}")
# Deleteawait store.adelete(namespace, "theme")
# Store with vector search for semantic retrievalfrom langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()store_with_search = AsyncPostgresStore.from_conn_string( "postgresql://user:password@localhost/langgraph_db", embeddings=embeddings)
# Store documents with embeddingsawait store_with_search.aput( namespace=("docs", "kb"), key="api-guide", value={ "title": "API Guide", "content": "LangGraph provides APIs for building stateful agents..." }, index=["content"] # Fields to embed)
# Semantic searchresults = await store_with_search.asearch( namespace_prefix=("docs",), query="how to build agents", limit=5)
for result in results: print(f"Score: {result.score}, {result.value['title']}")Injecting Store into Nodes
Section titled “Injecting Store into Nodes”Use LangGraph’s dependency injection:
from langgraph.prebuilt import InjectedStorefrom typing import Annotated
def personalization_node( state: State, store: Annotated[AsyncPostgresStore, InjectedStore]) -> dict: """Node that accesses store automatically.""" user_id = state["user_id"]
# Retrieve preferences namespace = ("users", user_id, "prefs") prefs_item = await store.aget(namespace, "theme") prefs = prefs_item.value if prefs_item else {}
# Update if interaction changes preferences if state.get("user_voted_dark"): await store.aput( namespace, "theme", {"dark_mode": True, "last_updated": datetime.now().isoformat()} )
return {"user_preferences": prefs}
# Compile with storebuilder = StateGraph(State)builder.add_node("personalize", personalization_node)
graph = builder.compile(store=store)Complete Memory Example
Section titled “Complete Memory Example”from langgraph.checkpoint.sqlite import SqliteSaverfrom langgraph.store.memory import InMemoryStorefrom datetime import datetime
class MemoryState(TypedDict): user_id: str message: str response: str conversation_history: Annotated[list, add_messages]
def store_memory_node( state: MemoryState, store: Annotated[InMemoryStore, InjectedStore]) -> dict: """Store user preferences and conversation summary."""
# Extract user preferences from conversation namespace = ("users", state["user_id"], "memory")
# Save conversation turn await store.aput( namespace, f"turn-{datetime.now().isoformat()}", { "user_message": state["message"], "bot_response": state["response"] } )
# Update user profile based on interactions profile_key = "profile" profile = await store.aget(namespace, profile_key) existing = profile.value if profile else {}
updated_profile = { **existing, "total_turns": existing.get("total_turns", 0) + 1, "last_interaction": datetime.now().isoformat() }
await store.aput(namespace, profile_key, updated_profile)
return {}
# Build with memorycheckpointer = SqliteSaver.from_conn_string("memory.db")store = InMemoryStore()
builder = StateGraph(MemoryState)builder.add_node("respond", respond_node)builder.add_node("remember", store_memory_node)
builder.add_edge(START, "respond")builder.add_edge("respond", "remember")builder.add_edge("remember", END)
graph = builder.compile( checkpointer=checkpointer, store=store)
# Use with persistenceconfig = {"configurable": {"thread_id": "user-alice"}}
for i in range(3): result = graph.invoke( {"user_id": "alice", "message": f"Message {i}"}, config=config ) print(result["response"])
# Multi-turn conversations remembered automaticallyDebugging & Visualization
Section titled “Debugging & Visualization”Graph Visualization
Section titled “Graph Visualization”from IPython.display import Image, display
# Get Mermaid diagramdiagram = graph.get_graph().draw_mermaid()print(diagram)
# Display in Jupyter/Colabdisplay(Image(graph.get_graph().draw_mermaid_png()))
# ASCII artprint(graph.get_graph().draw_ascii())Example output:
┌─────────────────────┐ │ START │ └────────────┬────────┘ │ ┌────────────▼────────────┐ │ fetch_context │ └────────────┬────────────┘ │ ┌────────────▼────────────┐ │ call_model │ └────────────┬────────────┘ │ ┌────────────▼────────────┐ │ save_chat │ └────────────┬────────────┘ │ ┌────────────▼────────────┐ │ END │ └─────────────────────────┘Streaming for Debugging
Section titled “Streaming for Debugging”# Debug mode shows node executionconfig = {"configurable": {"thread_id": "debug-1"}}
for event in graph.stream( {"query": "test"}, config=config, stream_mode="debug"): print(f"Event: {event}")
# Output:# {'type': 'task_start', 'timestamp': '...', 'step': 0, 'node': 'fetch_context'}# {'type': 'task_end', 'timestamp': '...', 'step': 0, 'node': 'fetch_context', 'result': {...}}# {'type': 'task_start', 'timestamp': '...', 'step': 1, 'node': 'call_model'}# ...Type-Safe v2 API (v1.1.x)
Section titled “Type-Safe v2 API (v1.1.x)”v2 Streaming
Section titled “v2 Streaming”from langgraph.graph import StateGraph, ENDfrom typing import TypedDict
class State(TypedDict): messages: list result: str
builder = StateGraph(State)# ... add nodes and edges ...graph = builder.compile()
# v2 streaming: opt-in with version="v2"async for part in graph.astream( {"messages": [{"role": "user", "content": "Hello"}]}, version="v2", # Enables type-safe StreamPart output): # part is a StreamPart with .type, .ns, and .data print(f"Type: {part.type}, Data: {part.data}")v2 Invoke
Section titled “v2 Invoke”# v2 invoke returns a GraphOutput instead of a dictresult = await graph.ainvoke( {"messages": [{"role": "user", "content": "Hello"}]}, version="v2",)
# GraphOutput has .value (final state) and .interrupts (any Human-in-the-Loop interrupts)print(result.value) # Final state dictprint(result.interrupts) # List of interrupt points (if any)Pydantic/Dataclass Auto-Coercion
Section titled “Pydantic/Dataclass Auto-Coercion”v1.1.x automatically coerces input dictionaries to the graph’s state type on invoke():
from pydantic import BaseModel
class MyState(BaseModel): query: str result: str = ""
builder = StateGraph(MyState)# ... graph setup ...graph = builder.compile()
# Pass dict directly — auto-coerced to MyStateresult = await graph.ainvoke({"query": "What is LangGraph?"})print(type(result)) # MyStateGetting State at Any Point
Section titled “Getting State at Any Point”# After partial executionconfig = {"configurable": {"thread_id": "user-1"}}
# Start but intercept in middlefor event in graph.stream({"query": "test"}, config=config): pass
# Get state snapshotstate = graph.get_state(config)print(f"Next node to run: {state.next}")print(f"Current values: {state.values}")print(f"Metadata: {state.metadata}")
# Modify stategraph.update_state( config, {"messages": [{"role": "system", "content": "Updated system prompt"}]})
# Continue from modified stateresult = graph.invoke({"query": "continue"}, config=config)Checkpoint Inspection
Section titled “Checkpoint Inspection”# List all checkpoints for a threadconfig = {"configurable": {"thread_id": "user-1"}}
history = list(graph.get_state_history(config))print(f"Total checkpoints: {len(history)}")
for i, snapshot in enumerate(history): cp_id = snapshot.config['configurable']['checkpoint_id'] next_node = snapshot.next print(f"\nCheckpoint {i}: {cp_id}") print(f" Next node(s): {next_node}") print(f" State keys: {list(snapshot.values.keys())}")Batch Debugging
Section titled “Batch Debugging”# Process multiple and collect issuesinputs = [ {"query": "Query 1"}, {"query": "Query 2"}, {"query": "Query 3"}]
configs = [ {"configurable": {"thread_id": f"batch-{i}"}} for i in range(len(inputs))]
results = []errors = []
for inp, cfg in zip(inputs, configs): try: result = graph.invoke(inp, config=cfg) results.append(result) except Exception as e: errors.append((cfg["configurable"]["thread_id"], str(e)))
print(f"Successful: {len(results)}/{len(inputs)}")print(f"Failed: {len(errors)}")for thread_id, error in errors: print(f" {thread_id}: {error}")Human-in-the-Loop
Section titled “Human-in-the-Loop”Basic Interrupts
Section titled “Basic Interrupts”Pause execution and request human input:
from langgraph.types import interrupt, Command
class ApprovalState(TypedDict): action: str amount: float approved: bool approval_reason: str
def request_approval(state: ApprovalState) -> dict: """Pause and ask human for approval."""
# Interrupt with information for human result = interrupt({ "action": state["action"], "amount": state["amount"], "message": f"Approve {state['action']} for ${state['amount']}?" })
# result contains human's response return { "approved": result.get("approved", False), "approval_reason": result.get("reason", "") }
def execute_action(state: ApprovalState) -> dict: """Execute if approved.""" if state["approved"]: return {"action": f"Executed {state['action']}"} else: return {"action": f"Rejected {state['action']}"}
# Build with interruptsbuilder = StateGraph(ApprovalState)builder.add_node("request_approval", request_approval)builder.add_node("execute", execute_action)
builder.add_edge(START, "request_approval")builder.add_edge("request_approval", "execute")builder.add_edge("execute", END)
# MUST compile with checkpointer for interruptscheckpointer = InMemorySaver()approval_graph = builder.compile(checkpointer=checkpointer)
# Usageconfig = {"configurable": {"thread_id": "approval-1"}}
# Start - will interruptevents = []for event in approval_graph.stream( {"action": "transfer", "amount": 500.00}, config=config): events.append(event)
print(events)# Output: [{'__interrupt__': (Interrupt(...), )}]
# Check if interruptedstate = approval_graph.get_state(config)if state.next == ("__interrupt__",): print("Waiting for human approval")
# Human decides human_decision = { "approved": True, "reason": "Amount looks reasonable" }
# Resume with decision resume_events = list(approval_graph.stream( Command(resume=human_decision), config=config ))
print(resume_events) # Graph continuesMulti-Step Approval Workflow
Section titled “Multi-Step Approval Workflow”from enum import Enum
class ApprovalStage(Enum): INITIAL_REVIEW = "initial" COMPLIANCE_CHECK = "compliance" FINAL_APPROVAL = "final"
class WorkflowApprovalState(TypedDict): action: str amount: float approval_stage: ApprovalStage approvals: Annotated[dict, lambda x, y: {**x, **y}]
def initial_review_node(state: WorkflowApprovalState) -> dict: """First level approval."""
approval = interrupt({ "stage": "INITIAL", "question": f"Review {state['action']} for ${state['amount']}?", "reviewer_type": "manager" })
return { "approvals": {"initial": approval.get("approved")}, "approval_stage": ApprovalStage.COMPLIANCE_CHECK }
def compliance_check_node(state: WorkflowApprovalState) -> dict: """Second level - compliance."""
# Only ask if initial approved if not state["approvals"].get("initial"): return { "approval_stage": ApprovalStage.FINAL_APPROVAL, "approvals": {"compliance": False} }
approval = interrupt({ "stage": "COMPLIANCE", "question": "Compliance clearance needed", "reviewer_type": "compliance_officer" })
return { "approvals": {"compliance": approval.get("approved")}, "approval_stage": ApprovalStage.FINAL_APPROVAL }
def final_approval_node(state: WorkflowApprovalState) -> dict: """Executive final approval."""
all_approved = all(state["approvals"].values())
if not all_approved: return {"approvals": {"final": False}}
approval = interrupt({ "stage": "FINAL", "question": "Executive approval required", "reviewer_type": "executive" })
return {"approvals": {"final": approval.get("approved")}}
def execute_if_approved(state: WorkflowApprovalState) -> dict: """Only run if all approvals granted."""
all_approved = all(state["approvals"].values())
if all_approved: # Execute action return {"action": f"EXECUTED: {state['action']}"} else: return {"action": f"REJECTED: {state['action']}"}
# Build workflowbuilder = StateGraph(WorkflowApprovalState)builder.add_node("initial", initial_review_node)builder.add_node("compliance", compliance_check_node)builder.add_node("final", final_approval_node)builder.add_node("execute", execute_if_approved)
builder.add_edge(START, "initial")builder.add_edge("initial", "compliance")builder.add_edge("compliance", "final")builder.add_edge("final", "execute")builder.add_edge("execute", END)
approval_workflow = builder.compile(checkpointer=InMemorySaver())
# Multi-stage executionconfig = {"configurable": {"thread_id": "multi-approval-1"}}
# Stage 1stream_events(approval_workflow.stream( {"action": "hire", "amount": 80000}, config=config))
# Resume with manager approvalstream_events(approval_workflow.stream( Command(resume={"approved": True}), config=config))
# Resume with compliance approvalstream_events(approval_workflow.stream( Command(resume={"approved": True}), config=config))
# Resume with executive approvalstream_events(approval_workflow.stream( Command(resume={"approved": True}), config=config))Interactive Debugging
Section titled “Interactive Debugging”class DebugState(TypedDict): data: str step_result: str needs_adjustment: bool
def step_node(state: DebugState) -> dict: """Process data."""
result = process(state["data"])
# Ask if result is acceptable feedback = interrupt({ "step": "Process", "result": result, "question": "Is this result acceptable? (yes/no/modify)" })
if feedback["action"] == "modify": result = feedback["modified_result"] needs_adjustment = True else: needs_adjustment = feedback["action"] != "yes"
return { "step_result": result, "needs_adjustment": needs_adjustment }
def decide_continue(state: DebugState) -> str: """Route based on feedback.""" return "refine" if state["needs_adjustment"] else "finalize"
# Build interactive debug workflowbuilder = StateGraph(DebugState)builder.add_node("process", step_node)builder.add_node("refine", refine_node)builder.add_node("finalize", finalize_node)
builder.add_edge(START, "process")builder.add_conditional_edges( "process", decide_continue, {"refine": "refine", "finalize": "finalize"})builder.add_edge("refine", "process")builder.add_edge("finalize", END)
debug_workflow = builder.compile(checkpointer=InMemorySaver())
# Interactive useconfig = {"configurable": {"thread_id": "debug-session"}}
# Step through with feedbackstream_events(debug_workflow.stream( {"data": "raw_input"}, config=config))
# Human reviews and responds with modificationsstream_events(debug_workflow.stream( Command(resume={"action": "modify", "modified_result": "adjusted_output"}), config=config))Advanced Patterns
Section titled “Advanced Patterns”Pattern 1: ReAct (Reasoning + Acting)
Section titled “Pattern 1: ReAct (Reasoning + Acting)”The Reflection-Action pattern for autonomous agents, now built with modern LangChain components.
# Note: AgentExecutor and create_tool_calling_agent require `pip install langchain langchain-anthropic`# from langchain.agents import AgentExecutor, create_tool_calling_agent# from langchain_anthropic import ChatAnthropicfrom langchain_core.prompts import ChatPromptTemplatefrom langchain_core.tools import tool
# Define tools@tooldef search_web(query: str) -> str: """Search the web.""" return f"Results for {query}..."
@tooldef calculator(expression: str) -> str: """Calculate expression.""" return str(eval(expression))
tools = [search_web, calculator]
# Create the ReAct agentllm = ChatAnthropic(model="claude-3-5-sonnet-20240620")prompt = ChatPromptTemplate.from_messages( [ ("system", "You are a helpful research assistant. Think before acting."), ("placeholder", "{chat_history}"), ("human", "{input}"), ("placeholder", "{agent_scratchpad}"), ])agent = create_tool_calling_agent(llm, tools, prompt)react_agent = AgentExecutor(agent=agent, tools=tools, verbose=True)
# Use it - the AgentExecutor automatically handles the ReAct loopresult = react_agent.invoke({ "input": "Research population of Tokyo and calculate 15% of that", "chat_history": []})
print(result["output"])Pattern 2: Tree-of-Thoughts
Section titled “Pattern 2: Tree-of-Thoughts”Explore multiple reasoning paths:
from langgraph.types import Send
class ThoughtState(TypedDict): question: str thoughts: Annotated[list[dict], lambda x, y: x + y] best_thought: dict final_answer: str
def generate_thoughts(state: ThoughtState) -> list[Send]: """Generate multiple solution approaches."""
num_paths = 3 returns = []
for i in range(num_paths): returns.append( Send("explore_thought", { "question": state["question"], "path_number": i }) )
return returns
def explore_thought(state: ThoughtState) -> dict: """Explore one reasoning path."""
prompt = f""" Question: {state['question']} Path #{state.get('path_number', 0)}
Provide your reasoning for this specific approach. """
response = model.invoke(prompt)
return { "thoughts": [{ "path": state.get("path_number"), "reasoning": response.content, "quality_score": 0.8 # Could be evaluated }] }
def select_best(state: ThoughtState) -> dict: """Select the best thought."""
if not state["thoughts"]: return {"best_thought": {}}
best = max(state["thoughts"], key=lambda x: x.get("quality_score", 0))
return {"best_thought": best}
def synthesize(state: ThoughtState) -> dict: """Synthesize best thought into answer."""
best_reasoning = state["best_thought"].get("reasoning", "")
prompt = f""" Best reasoning: {best_reasoning}
Provide a final answer based on this reasoning. """
response = model.invoke(prompt)
return {"final_answer": response.content}
# Build tree-of-thoughtsbuilder = StateGraph(ThoughtState)builder.add_node("generate", generate_thoughts)builder.add_node("explore", explore_thought)builder.add_node("select", select_best)builder.add_node("synthesize", synthesize)
builder.add_conditional_edges( START, lambda _: "generate")builder.add_conditional_edges( "generate", lambda _: ["explore"], ["explore"])builder.add_edge("explore", "select")builder.add_edge("select", "synthesize")builder.add_edge("synthesize", END)
tot_graph = builder.compile()
# Use itresult = tot_graph.invoke({ "question": "How should we approach climate change?"})
print("Best thought:", result["best_thought"]["reasoning"])print("Final answer:", result["final_answer"])Pattern 3: Self-Reflection
Section titled “Pattern 3: Self-Reflection”Agent critiques its own output:
class ReflectionState(TypedDict): question: str initial_response: str critique: str refined_response: str reflection_count: int
def generate_response(state: ReflectionState) -> dict: """Generate initial response."""
response = model.invoke(state["question"])
return { "initial_response": response.content, "reflection_count": 0 }
def self_critique(state: ReflectionState) -> dict: """Critique the response."""
prompt = f""" Question: {state['question']} Response: {state['initial_response']}
Critique this response. What could be improved? """
critique = model.invoke(prompt)
return {"critique": critique.content}
def should_refine(state: ReflectionState) -> str: """Decide if response needs refinement."""
if state["reflection_count"] >= 2: return "done"
# Check critique for issues if any(word in state["critique"].lower() for word in ["incorrect", "missing", "unclear", "incomplete"]): return "refine"
return "done"
def refine_response(state: ReflectionState) -> dict: """Create refined response based on critique."""
prompt = f""" Original question: {state['question']} Your response: {state['initial_response']} Critique: {state['critique']}
Provide an improved response addressing the critique. """
refined = model.invoke(prompt)
return { "refined_response": refined.content, "reflection_count": state["reflection_count"] + 1 }
# Build reflection loopbuilder = StateGraph(ReflectionState)builder.add_node("generate", generate_response)builder.add_node("critique", self_critique)builder.add_node("refine", refine_response)
builder.add_edge(START, "generate")builder.add_edge("generate", "critique")
builder.add_conditional_edges( "critique", should_refine, {"refine": "refine", "done": END})
builder.add_edge("refine", "critique") # Loop back for re-critique
reflection_graph = builder.compile()
# Use itresult = reflection_graph.invoke({ "question": "Explain quantum computing to a child"})
print("Initial:", result["initial_response"])print("Refined:", result.get("refined_response", "No refinement needed"))print("Reflection iterations:", result["reflection_count"])Pattern 4: Structured Output with Validation
Section titled “Pattern 4: Structured Output with Validation”from pydantic import BaseModel, field_validator
class ResearchOutput(BaseModel): """Structured research output.""" topic: str key_findings: list[str] sources: list[str] confidence_score: float
@field_validator('confidence_score') def score_in_range(cls, v): if not 0 <= v <= 1: raise ValueError('Must be between 0 and 1') return v
class StructuredState(TypedDict): topic: str raw_research: str structured_output: ResearchOutput validation_passed: bool errors: list[str]
def research_node(state: StructuredState) -> dict: """Conduct research."""
result = model.invoke(f"Research: {state['topic']}")
return {"raw_research": result.content}
def structure_output(state: StructuredState) -> dict: """Parse into structured format."""
prompt = f""" Research content: {state['raw_research']}
Extract into JSON with fields: - topic - key_findings (list) - sources (list) - confidence_score (0-1) """
response = model.invoke(prompt)
try: import json parsed = json.loads(response.content) output = ResearchOutput(**parsed) return { "structured_output": output, "validation_passed": True, "errors": [] } except Exception as e: return { "validation_passed": False, "errors": [str(e)] }
def decide_next(state: StructuredState) -> str: """Route based on validation.""" if state["validation_passed"]: return "success" else: return "retry"
def retry_node(state: StructuredState) -> dict: """Re-attempt with error context."""
prompt = f""" Previous errors: {', '.join(state['errors'])} Retry research on: {state['topic']} """
result = model.invoke(prompt)
return {"raw_research": result.content}
# Build validation graphbuilder = StateGraph(StructuredState)builder.add_node("research", research_node)builder.add_node("structure", structure_output)builder.add_node("retry", retry_node)
builder.add_edge(START, "research")builder.add_edge("research", "structure")
builder.add_conditional_edges( "structure", decide_next, {"success": END, "retry": "retry"})
builder.add_edge("retry", "structure") # Loop back
validation_graph = builder.compile()
# Use itresult = validation_graph.invoke({ "topic": "AI safety"})
if result["validation_passed"]: output = result["structured_output"] print(f"Topic: {output.topic}") print(f"Confidence: {output.confidence_score}") print(f"Findings: {output.key_findings}")Pattern 5: Caching and Memoization
Section titled “Pattern 5: Caching and Memoization”from functools import lru_cachefrom langgraph.store.memory import InMemoryStore
class CacheState(TypedDict): query: str result: str cache_hit: bool
# Simple LRU cache for expensive operations@lru_cache(maxsize=128)def expensive_operation(query: str) -> str: """Simulate expensive operation.""" import time time.sleep(1) return f"Result for {query}"
async def cached_operation_node( state: CacheState, store: Annotated[InMemoryStore, InjectedStore]) -> dict: """Check cache before executing."""
query = state["query"] namespace = ("cache", "results")
# Check cache cached = await store.aget(namespace, query)
if cached: return { "result": cached.value, "cache_hit": True }
# Execute and cache result = expensive_operation(query)
await store.aput( namespace, query, {"result": result, "timestamp": datetime.now().isoformat()} )
return { "result": result, "cache_hit": False }
# Build with cachingbuilder = StateGraph(CacheState)builder.add_node("process", cached_operation_node)
caching_graph = builder.compile(store=InMemoryStore())
# Usageconfig = {"configurable": {"thread_id": "cache-test"}}
# First call - hits expensive operationresult = caching_graph.invoke({"query": "expensive"}, config=config)print("Cache hit:", result["cache_hit"]) # False
# Second call - uses cacheresult = caching_graph.invoke({"query": "expensive"}, config=config)print("Cache hit:", result["cache_hit"]) # TrueErrata: removed fabricated sections
Section titled “Errata: removed fabricated sections”The following subsections appeared in earlier drafts of this guide under a “v1.0.3 Features” heading but do not match any real API in the installed langgraph==1.2.0 package. They have been removed:
- Node Caching —
from langgraph.cache import cache_node, SemanticCache, CachePolicydoes not exist. For caching, use LangGraph’s long-termStore(see Memory & Persistence) or plainfunctools.lru_cache. - Deferred Nodes —
from langgraph.graph import deferredand@deferred(wait_for=[...])are not real. Fan-in is native: edges from multiple sources into the same target wait for all upstream completions. - Pre/Post Model Hooks decorators —
from langgraph.llm_hooks import pre_model_hook, post_model_hookdoes not exist. The real hooks are thepre_model_hook=/post_model_hook=kwargs onlanggraph.prebuilt.create_react_agent(not decorators).langchain.agents.middlewareandlangchain.agents.create_agentdo exist but require the separatelangchainpackage (pip install langchain), which is not bundled withlanggraph. See Chapter 8 — Middleware for verified examples. - Tools State Updates —
@tool(updates_state=True)returningStateUpdateis not a real decorator option. Have your node read the tool result and return the state update as a normal dict. - Command Tool for edgeless flows —
command_tool,CommandRouterare not real. Real equivalent: return alanggraph.types.Command(goto="next_node", update={...})from a node or a tool to drive routing. - LangGraph Templates CLI —
langgraph template list|create|init|publishis not a real subcommand. Uselanggraph new --template NAMEto scaffold from a template.
What’s real and remains documented:
- Cross-thread memory — use
langgraph.store.postgres.AsyncPostgresStorewith hierarchical namespaces, and theInjectedStoreannotation to inject the store into node signatures. Covered in Chapter 5 — Memory & Persistence. - Python 3.10 – 3.14 — LangGraph 1.1.x supports Python 3.10 through 3.14 (Python 3.9 was dropped in 1.1). Type-parameter syntax (PEP 695) works as-is; there’s no LangGraph-specific coupling.
- Type-safe v2 API — opt in with
version="v2"on.invoke/.stream/.ainvoke/.astream. Covered in Type-Safe v2 API above.
Functional API (LangGraph 1.0)
Section titled “Functional API (LangGraph 1.0)”A simpler Python-native way to build workflows with automatic parallelization:
from langgraph.func import entrypoint, taskfrom langgraph.types import interrupt, Commandfrom langgraph.checkpoint.memory import InMemorySaverfrom typing import Optional
# Define parallelizable tasks@taskdef fetch_user_data(user_id: str) -> dict: """Get user info.""" return {"user_id": user_id, "name": "Alice"}
@taskdef fetch_orders(user_id: str) -> list[dict]: """Get user orders.""" return [{"id": "1", "total": 99.99}]
@taskasync def generate_recommendations(user_data: dict, orders: list) -> list[str]: """Generate recommendations (can be async).""" return ["Product A", "Product B"]
# Define entrypoint with automatic parallelization@entrypoint(checkpointer=InMemorySaver())def build_dashboard(user_id: str, *, previous: Optional[dict] = None) -> dict: """ Build dashboard with parallel data fetching.
Args: user_id: User to fetch data for previous: Return value from last invocation (enables state)
Returns: Complete dashboard data """
# Launch tasks in parallel - immediately get futures user_future = fetch_user_data(user_id) orders_future = fetch_orders(user_id)
# Block and wait for results user_data = user_future.result() orders = orders_future.result()
# Now generate recommendations using results recs_future = generate_recommendations(user_data, orders) recommendations = recs_future.result()
# Can interrupt for human approval approved = interrupt({ "recommendations": recommendations, "question": "Approve these recommendations?" })
return { "user": user_data, "orders": orders, "recommendations": recommendations if approved else [], "status": "approved" if approved else "rejected" }
# Executeconfig = {"configurable": {"thread_id": "user-session-1"}}
# Initial run - interrupts for approvalfor result in build_dashboard.stream("user-123", config): print(result)
# Resume after human approvalfor result in build_dashboard.stream(Command(resume=True), config): print(result)
# With previous state for stateful workflows@entrypoint(checkpointer=InMemorySaver())def counter(increment: int, *, previous: Optional[int] = None) -> str: """Accumulate counter.""" current = (previous or 0) + increment return f"Counter: {current}"
config = {"configurable": {"thread_id": "counter"}}counter.invoke(5, config) # "Counter: 5"counter.invoke(3, config) # "Counter: 8" (5+3)Production Deployment
Section titled “Production Deployment”Docker Setup
Section titled “Docker Setup”# DockerfileFROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Start LangGraph serverCMD ["langgraph", "run", "--host", "0.0.0.0", "--port", "8000"]# Build and rundocker build -t my-agent:v1 .docker run -p 8000:8000 \ -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ my-agent:v1CLI Configuration
Section titled “CLI Configuration”{ "langgraph.json": { "dependencies": [ "langchain_anthropic", "langchain_tavily", "./agents" ], "graphs": { "main_agent": "./agents.py:graph", "research_agent": "./agents.py:research_graph" }, "env": "./.env", "python_version": "3.11" }}Remote Execution via SDK
Section titled “Remote Execution via SDK”from langgraph_sdk import get_clientimport asyncio
async def main(): client = get_client(url="https://my-deployment.langraph.app")
# List available assistants (from langgraph.json graphs) assistants = await client.assistants.search() assistant_id = assistants[0]["assistant_id"]
# Create conversation thread thread = await client.threads.create()
# Stream execution async for chunk in client.runs.stream( thread_id=thread["thread_id"], assistant_id=assistant_id, input={"query": "Research AI trends"} ): if chunk.event == "messages/partial": print(chunk.data[0]["content"], end="", flush=True)
# Get final state final_state = await client.threads.get_state(thread["thread_id"]) print(f"\nFinal: {final_state}")
asyncio.run(main())New in v1.2.7 — Compiled Graph APIs
Section titled “New in v1.2.7 — Compiled Graph APIs”Graph Visualization
Section titled “Graph Visualization”get_graph(xray=True) expands compiled subgraphs inline, prefixing their node names with the parent node name. All visualization flows through langchain_core.runnables.graph.Graph:
from typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, END
class State(TypedDict): value: int
# Build a subgraphsub = StateGraph(State)sub.add_node("sub_op", lambda s: {"value": s["value"] * 10})sub.set_entry_point("sub_op")sub.set_finish_point("sub_op")compiled_sub = sub.compile()
# Embed in main graphg = StateGraph(State)g.add_node("scale", compiled_sub)g.add_node("post", lambda s: {"value": s["value"] + 1})g.add_edge(START, "scale")g.add_edge("scale", "post")g.add_edge("post", END)compiled = g.compile()
# Shallow view — subgraph appears as single "scale" nodeprint(sorted(compiled.get_graph().nodes.keys()))# ['__end__', '__start__', 'post', 'scale']
# Deep view — subgraph internals exposedprint(sorted(compiled.get_graph(xray=True).nodes.keys()))# ['__end__', '__start__', 'post', 'scale:sub_op']
# Generate Mermaid markdown for documentationmermaid_md = compiled.get_graph().draw_mermaid()print(mermaid_md[:200])Graph-as-Tool (Beta)
Section titled “Graph-as-Tool (Beta)”compiled.as_tool() converts any compiled graph into a LangChain StructuredTool:
from typing_extensions import TypedDictfrom pydantic import BaseModel, Fieldfrom langgraph.graph import StateGraph
class SummaryState(TypedDict): text: str word_count: int
class SummaryInput(BaseModel): text: str = Field(description="Text to analyze")
g = StateGraph(SummaryState)g.add_node("count", lambda s: {"word_count": len(s["text"].split())})g.set_entry_point("count")g.set_finish_point("count")compiled = g.compile()
import warningsfrom langchain_core._api import LangChainBetaWarningwith warnings.catch_warnings(): warnings.filterwarnings("ignore", category=LangChainBetaWarning) word_count_tool = compiled.as_tool( args_schema=SummaryInput, name="word_count", description="Count words in a piece of text", )result = word_count_tool.invoke({"text": "LangGraph is great for building agents"})print("Word count:", result["word_count"]) # 7Subgraph Traversal
Section titled “Subgraph Traversal”# `compiled` is any CompiledStateGraph returned by g.compile()# Iterate all subgraphs, optionally recursing into nested onesfor name, subgraph in compiled.get_subgraphs(recurse=True): print(f" namespace={name!r} type={type(subgraph).__name__}")Schema Introspection
Section titled “Schema Introspection”import jsonfrom typing_extensions import TypedDictfrom pydantic import BaseModelfrom langgraph.graph import StateGraph, START, END
class Ctx(BaseModel): user_id: str
class S(TypedDict): query: str answer: str
g = StateGraph(S, context_schema=Ctx)g.add_node("n", lambda s: s)g.set_entry_point("n")g.set_finish_point("n")compiled = g.compile()
print(json.dumps(compiled.get_input_jsonschema(), indent=2))print(json.dumps(compiled.get_output_jsonschema(), indent=2))print(json.dumps(compiled.get_context_jsonschema(), indent=2))# Note: get_config_jsonschema() is deprecated — use get_context_jsonschema()Deferred Nodes
Section titled “Deferred Nodes”add_node(defer=True) ensures a node runs at graph quiescence — after all non-deferred work in the entire run has drained, not just the current super-step:
import operatorfrom typing import Annotatedfrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, END
class S(TypedDict): items: Annotated[list[str], operator.add] summary: str
g = StateGraph(S)g.add_node("worker_a", lambda s: {"items": ["a"]})g.add_node("worker_b", lambda s: {"items": ["b"]})g.add_node("summarise", lambda s: {"summary": f"got {s['items']}"}, defer=True)
g.add_edge(START, "worker_a")g.add_edge(START, "worker_b")g.add_edge("worker_a", "summarise") # only one explicit edge — defer=True waits for worker_b toog.add_edge("summarise", END)
result = g.compile().invoke({"items": [], "summary": ""})print(result["summary"]) # got ['a', 'b'] — both items present when defer runsCache Management
Section titled “Cache Management”from typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.cache.memory import InMemoryCachefrom langgraph.types import CachePolicy
class S(TypedDict): x: int
cache = InMemoryCache()
g = StateGraph(S)g.add_node("compute", lambda s: {"x": s["x"] * 2}, cache_policy=CachePolicy())g.set_entry_point("compute")g.set_finish_point("compute")compiled = g.compile(cache=cache)
# Invalidate the entire graph's cacheif compiled.cache is not None: compiled.clear_cache()
# Invalidate only specific nodesif compiled.cache is not None: compiled.clear_cache(nodes=["compute"])
# Async variantimport asyncioif compiled.cache is not None: asyncio.run(compiled.aclear_cache(nodes=["compute"]))REMOVE_ALL_MESSAGES Sentinel
Section titled “REMOVE_ALL_MESSAGES Sentinel”The sentinel is processed by add_messages — the state key must be declared with that reducer (via Annotated[list, add_messages] or MessagesState). Without it, RemoveMessage is stored as a plain message rather than triggering a history wipe.
from typing import Annotatedfrom typing_extensions import TypedDictfrom langchain_core.messages import AnyMessage, HumanMessage, RemoveMessagefrom langgraph.graph.message import add_messages, REMOVE_ALL_MESSAGESfrom langgraph.graph import StateGraph, START, END
class ConvState(TypedDict): # add_messages reducer is required — it is the one that interprets the sentinel messages: Annotated[list[AnyMessage], add_messages]
# Discard entire conversation history and start freshdef reset_conversation(state: ConvState) -> dict: return { "messages": [ RemoveMessage(id=REMOVE_ALL_MESSAGES), # wipe all history HumanMessage(content="New topic", id="n1"), # first message of new context ] }Common Patterns Summary
Section titled “Common Patterns Summary”| Pattern | Use Case | Key Idea |
|---|---|---|
| Linear | Simple pipelines | Node A → B → C → END |
| Conditional | Decision trees | Routes based on state |
| Looping | Iterations | Self-referencing edges with exit condition |
| Supervisor | Multi-agent | Central router to specialists |
| Parallel | Concurrent work | Fan-out with Send, fan-in with collection |
| ReAct | Autonomous agent | Reason → Action → Observe loop |
| Tree-of-Thoughts | Complex reasoning | Multiple parallel thought paths |
| Reflection | Quality improvement | Self-critique → Refine loop |
| Interrupt | Human approval | Pause, wait, resume with Command |
| Caching | Performance | Store expensive results |
| Deferred nodes | End-of-run aggregation | add_node(defer=True) runs at quiescence, after all non-deferred work drains |
| Graph-as-tool | Multi-agent composition | compiled.as_tool() wraps graph as a StructuredTool |
| History reset | Context window management | RemoveMessage(id=REMOVE_ALL_MESSAGES) clears all messages |
Troubleshooting
Section titled “Troubleshooting”Issue: “Checkpointer must be provided for interrupts”
Section titled “Issue: “Checkpointer must be provided for interrupts””Cause: Trying to use interrupt() without a checkpointer
Fix: Always compile with a checkpointer when using interrupts:
graph = builder.compile(checkpointer=InMemorySaver())Issue: State not persisting across invocations
Section titled “Issue: State not persisting across invocations”Cause: Missing thread_id in config
Fix: Always provide consistent thread_id:
config = {"configurable": {"thread_id": "unique-id"}}result = graph.invoke(input, config=config) # Same config each timeIssue: Reducer functions not working
Section titled “Issue: Reducer functions not working”Cause: Not using Annotated with reducer function
Fix: Proper state schema:
# Wrongclass State(TypedDict): messages: list
# Correctclass State(TypedDict): messages: Annotated[list, add_messages]Issue: Tools not being called
Section titled “Issue: Tools not being called”Cause: Model not properly bound to tools
Fix: Use .bind_tools():
model_with_tools = model.bind_tools(tools)response = model_with_tools.invoke(messages) # WorksIssue: Infinite loops
Section titled “Issue: Infinite loops”Cause: Conditional edge always returns to same node
Fix: Add iteration counter or state check:
def should_continue(state) -> str: if state.get("iterations", 0) >= MAX_ITERATIONS: return END return "process"Resources
Section titled “Resources”- Official Docs: https://langchain-ai.github.io/langgraph/
- GitHub: https://github.com/langchain-ai/langgraph
- Examples: https://github.com/langchain-ai/langgraph/tree/main/examples
- Discord Community: LangChain Discord
Performance Tips
Section titled “Performance Tips”- Use async when possible:
ainvoke()andastream()for I/O-bound tasks - Batch processing:
graph.batch()for multiple inputs - Streaming: Use
stream_mode="updates"to reduce data transfer - Checkpointer selection: PostgreSQL > SQLite > In-Memory based on scale
- Cache expensive operations: Store results in long-term Store
- Limit iterations: Always set
MAX_ITERATIONSto prevent runaway loops
Next Steps
Section titled “Next Steps”- Start with simple linear graphs
- Add conditional routing
- Build multi-agent systems
- Integrate tools
- Add persistence with checkpointers
- Deploy with CLI/Docker
- Monitor with LangSmith
Good luck with your AI engineering journey! LangGraph gives you the low-level control to build sophisticated agent systems. Start small, iterate, and scale.
Revision History
Section titled “Revision History”| Version | Date | Changes |
|---|---|---|
| 1.2.7 | June 29, 2026 | Patch release. Version confirmed against installed langgraph==1.2.7, langgraph-checkpoint==4.1.1, langgraph-prebuilt==1.1.0. New deep-dive Vol. 29 covers 10 previously undocumented APIs: Edge/TriggerEdge/draw_graph() visualization internals, get_graph(xray=) subgraph expansion, as_tool() (beta) graph-to-tool conversion, get_subgraphs(recurse=True) namespace traversal, get_input_jsonschema()/get_output_jsonschema()/get_context_jsonschema() schema introspection, clear_cache()/aclear_cache() per-namespace cache invalidation, _messages_delta_reducer batch-invariant DeltaChannel reducer, _add_messages_wrapper partial-application decorator, add_node(defer=True) deferred end-of-run execution (quiescence), and REMOVE_ALL_MESSAGES bulk history reset. |
| 1.2.0 | May 12, 2026 | Minor release. Version confirmed against installed langgraph==1.2.0 (.routine-envs/check-0512-py); langgraph-checkpoint==4.1.0, langgraph-prebuilt==1.1.0. New exports: ToolRuntime, ToolCallTransformer (both in langgraph.prebuilt). All core symbols (StateGraph, END, START, CompiledStateGraph, MemorySaver, create_react_agent, ToolNode, StreamPart, Command, Send, Interrupt, interrupt, entrypoint, task, InMemoryStore) verified with -W error::DeprecationWarning. |
| 1.1.10 | April 28, 2026 | Patch release. Version confirmed against installed langgraph==1.1.10 (.routine-envs/main-py-0428); langgraph-checkpoint==4.0.3. All core symbols verified. |
| 1.1.9 | April 22, 2026 | Patch release; six source-verified reference pages added to the guide. |
| 1.1.8 | April 17, 2026 | Fixed strict add_handler type check that broke OpenTelemetry instrumentation; follows patch 1.1.7 (same day) |
| 1.1.7 | April 17, 2026 | Intermediate patch preceding 1.1.8; stability fixes |
| 1.1.6 | April 10, 2026 | Type-safe v2 streaming and invoke API (version="v2"); Pydantic/dataclass auto-coercion; Python 3.14 support; time-travel bug fixes with interrupts and subgraphs |
| 1.0.3 | November 2025 | Previous documented version |
Class & API Reference
Section titled “Class & API Reference”Source-verified reference for the classes, functions, and types developers actually
touch — consolidated from LangGraph’s full class-by-class audit and re-verified
against the installed langgraph==1.2.11 (langgraph-checkpoint==4.2.0,
langgraph-prebuilt==1.1.0, langchain-core==1.6.0). Each entry gives the module
path, the verified signature, why it matters, and a runnable example. Deeply
private, underscore-prefixed implementation details (langgraph.pregel._algo,
langgraph._internal.*, and similar) are intentionally not given individual
entries — they carry no compatibility guarantee and change between patch releases;
each section that has notable internals closes with a short, named pointer instead
of a full write-up.
Several symbols changed in 1.0–1.2: MessageGraph is deprecated (still importable from langgraph.graph.message, emits a deprecation warning; use StateGraph for new code);
AgentState/AgentStatePydantic/AgentStateWithStructuredResponse and
ValidationNode are deprecated in langgraph.prebuilt (scheduled for
removal in v2.0.0). The AgentState* family is no longer re-exported from the
top-level langgraph.prebuilt.__init__ but remains importable from
langgraph.prebuilt.chat_agent_executor; ValidationNode is still importable
from langgraph.prebuilt.tool_validator. The deprecation notices target
langchain.agents as migration destination (requires the separate langchain
package). create_react_agent is deprecated per official LangGraph v1
migration docs in favour of langchain.agents.create_agent; the installed
langgraph==1.2.11 source does not carry a runtime @deprecated decorator but
migration guidance is clear. The HumanInterrupt/HumanInterruptConfig/ActionRequest family moved to
langchain.agents.interrupt (requires the langchain package). langchain.agents.create_agent and
langchain.agents.middleware exist in the separate langchain package.
Graph Construction & State
Section titled “Graph Construction & State”StateGraph
Section titled “StateGraph”Module: langgraph.graph.state (re-exported from langgraph.graph)
The declarative builder for a stateful graph. Declare a state schema (TypedDict,
dataclass, or Pydantic BaseModel), add nodes and edges, then .compile() to get a
runnable. Each state field maps to a channel: a plain field → LastValue; Annotated[T, reducer] → BinaryOperatorAggregate; Annotated[list[T], Topic(T)] → Topic.
StateGraph( state_schema: type[StateT], context_schema: type[ContextT] | None = None, *, input_schema: type[InputT] | None = None, output_schema: type[OutputT] | None = None,)
.add_node( node: str | Callable, action: Callable | None = None, *, defer: bool = False, metadata: dict | None = None, input_schema: type | None = None, retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None, cache_policy: CachePolicy | None = None, error_handler: Callable | None = None, destinations: dict[str, str] | tuple[str, ...] | None = None, timeout: float | timedelta | TimeoutPolicy | None = None, trace_policy: TracePolicy | None = None,) -> Self
.add_edge(start_key: str | list[str], end_key: str) -> Self.add_conditional_edges(source: str, path: Callable | Runnable, path_map: dict | list[str] | None = None) -> Self.compile(checkpointer=None, *, cache=None, store=None, interrupt_before=None, interrupt_after=None, debug=False, name=None, transformers=None) -> CompiledStateGraphKey facts, cross-verified against source:
defer=Trueschedules the node to run only once all non-deferred work in the entire run has drained — not merely the current super-step. It still needs an explicit incoming edge; with no edge it never runs.destinations=is a visualization hint only — it documents possibleCommandtargets forget_graph()/draw_mermaid(); it has no effect on runtime routing.error_handleris added to the graph as its own node (is_error_handler=Trueinternally); it cannot itself declare anothererror_handler.set_node_defaults()’sretry_policy/timeoutapply to every node including error-handler nodes; itscache_policy/error_handlerdefaults apply to regular nodes only. A per-node value always wins over the graph-wide default. Not inherited by subgraphs.- Nodes can declare extra keyword-only parameters —
config: RunnableConfig,store: BaseStore,writer: StreamWriter,runtime: Runtime[ContextT]— and LangGraph auto-injects them by inspecting the signature atadd_node()time; no manual wiring needed. trace_policy(seeTracePolicyunder Observability & Tracing) anddestinationsare commonly-missed kwargs.
import operatorfrom typing import Annotatedfrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import RetryPolicy, CachePolicyfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.cache.memory import InMemoryCache
class PipelineState(TypedDict): query: str results: Annotated[list[str], operator.add] cost: Annotated[float, operator.add]
class SearchInput(TypedDict): query: str # node only sees this key
def web_search(state: SearchInput) -> dict: return {"results": [f"web:{state['query']}"], "cost": 0.001}
builder = StateGraph(PipelineState)builder.add_node( "web_search", web_search, input_schema=SearchInput, retry_policy=RetryPolicy(max_attempts=4, initial_interval=0.2, backoff_factor=2.0), cache_policy=CachePolicy(ttl=300),)builder.add_edge(START, "web_search")builder.add_edge("web_search", END)graph = builder.compile(checkpointer=InMemorySaver(), cache=InMemoryCache())result = graph.invoke({"query": "langgraph docs", "results": [], "cost": 0.0})State schemas — TypedDict, Pydantic BaseModel, dataclass
Section titled “State schemas — TypedDict, Pydantic BaseModel, dataclass”Module: langgraph.graph.state
StateGraph accepts any of three schema styles. A Pydantic BaseModel coerces dict
input through the constructor (running validators); LangGraph tracks
model_fields_set and writes back only the fields a node explicitly touched — a
node that returns MyState(field_a="x") leaves field_b untouched even though the
model has a default for it. Dataclasses behave the same way for partial updates.
from pydantic import BaseModel, field_validatorfrom langgraph.graph import StateGraph, START, END
class AgentState(BaseModel): messages: list[str] = [] count: int = 0
@field_validator("count") @classmethod def non_negative(cls, v: int) -> int: if v < 0: raise ValueError("count must be >= 0") return v
def increment(state: AgentState) -> AgentState: return AgentState(count=state.count + 1) # messages untouched, stays []
graph = ( StateGraph(AgentState) .add_node("increment", increment) .add_edge(START, "increment") .add_edge("increment", END) .compile())print(graph.invoke({"count": "5"})) # {'messages': [], 'count': 6} — str coerced to intCompiledStateGraph
Section titled “CompiledStateGraph”Module: langgraph.graph.state
The object StateGraph.compile() returns — a thin subclass of Pregel implementing
the full LangChain Runnable protocol plus state-management methods: get_state,
update_state, bulk_update_state, get_state_history, typed stream/invoke
overloads, get_input_jsonschema() / get_output_jsonschema() (used for API validation
at the graph’s declared input_schema/output_schema boundary).
class CompiledStateGraph(Pregel[StateT, ContextT, InputT, OutputT]): builder: StateGraph def get_input_jsonschema(self, config=None) -> dict: ... def get_output_jsonschema(self, config=None) -> dict: ...from typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.checkpoint.memory import InMemorySaver
class ChatState(TypedDict): messages: list
def chat_node(state: ChatState) -> dict: return {"messages": state["messages"] + ["reply"]}
graph = StateGraph(ChatState).add_node("chat", chat_node).add_edge(START, "chat").add_edge("chat", END).compile(checkpointer=InMemorySaver())cfg = {"configurable": {"thread_id": "demo"}}result = graph.invoke({"messages": ["hi"]}, cfg)for chunk in graph.stream({"messages": ["hello"]}, cfg, stream_mode="updates"): print(chunk)add_sequence()
Section titled “add_sequence()”Module: langgraph.graph.state
Shortcut for a linear pipeline: calls add_node for each item and wires
add_edge(prev, next) between consecutive nodes. Does not add START/END
edges — add those yourself. Items may be bare callables (name from __name__) or
(name, callable) tuples. Raises ValueError on an empty sequence or duplicate names.
def add_sequence( self, nodes: Sequence[Callable | tuple[str, Callable]],) -> Selffrom langgraph.graph import StateGraph, START, ENDfrom typing_extensions import TypedDict
class S(TypedDict): value: int
builder = StateGraph(S)builder.add_sequence([ ("double", lambda s: {"value": s["value"] * 2}), ("add_ten", lambda s: {"value": s["value"] + 10}),])builder.add_edge(START, "double")builder.add_edge("add_ten", END)graph = builder.compile()print(graph.invoke({"value": 3})["value"]) # 16set_node_defaults()
Section titled “set_node_defaults()”Module: langgraph.graph.state
Sets graph-wide fallback retry_policy / cache_policy / error_handler / timeout
applied to every node that doesn’t specify its own (per-node values always win).
retry_policy and timeout apply even to error-handler nodes; cache_policy and
error_handler do not (caching a handler’s output, or letting a handler catch itself,
is unsafe). Applied at compile() time; not inherited by subgraphs.
.set_node_defaults( *, retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None, cache_policy: CachePolicy | None = None, error_handler: Callable | None = None, timeout: float | timedelta | TimeoutPolicy | None = None,) -> Selffrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import RetryPolicyfrom typing_extensions import TypedDict
class State(TypedDict): value: int
graph = ( StateGraph(State) .set_node_defaults(retry_policy=RetryPolicy(max_attempts=5, initial_interval=0.01)) .add_node("a", lambda s: {"value": s["value"] + 1}) .add_node("b", lambda s: {"value": s["value"] * 2}, retry_policy=RetryPolicy(max_attempts=2)) .add_edge(START, "a").add_edge("a", "b").add_edge("b", END) .compile())input_schema / output_schema — narrowing a graph or a node
Section titled “input_schema / output_schema — narrowing a graph or a node”Module: langgraph.graph.state
StateGraph(state_schema, input_schema=..., output_schema=...) narrows the public
input/output contract of the whole graph (useful when it’s used as a subgraph or an
API boundary). add_node(..., input_schema=...) narrows just the slice of state one
node receives — only the declared keys are passed in.
from typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, END
class FullState(TypedDict): user_id: str; message: str; internal_counter: int; result: str
class LLMInput(TypedDict): user_id: str; message: str
def llm_node(state: LLMInput) -> dict: return {"result": f"[{state['user_id']}] {state['message']}"}
graph = ( StateGraph(FullState) .add_node("llm", llm_node, input_schema=LLMInput) .add_edge(START, "llm").add_edge("llm", END) .compile())context_schema + Runtime.context
Section titled “context_schema + Runtime.context”Modules: langgraph.graph.state, langgraph.runtime
Declares a typed read-only, per-invocation context object injected into every node
via runtime.context. Unlike state it is never persisted to checkpoints, never
returned by get_state(), and never writable by nodes — use it for user_id, tenant
config, feature flags, or an authenticated principal.
from dataclasses import dataclassfrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.runtime import Runtime
@dataclassclass AppContext: user_id: str locale: str = "en"
class State(TypedDict): result: str
def personalise(state: State, runtime: Runtime[AppContext]) -> dict: return {"result": f"[{runtime.context.locale}] hi {runtime.context.user_id}"}
graph = StateGraph(State, context_schema=AppContext).add_node("p", personalise).add_edge(START, "p").add_edge("p", END).compile()result = graph.invoke({"result": ""}, context=AppContext(user_id="alice", locale="en-GB"))add_messages + MessagesState + REMOVE_ALL_MESSAGES + push_message()
Section titled “add_messages + MessagesState + REMOVE_ALL_MESSAGES + push_message()”Module: langgraph.graph.message (MessagesState also re-exported from langgraph.graph)
add_messages is the standard reducer for a messages list: appends new messages,
upserts in place when an incoming message shares an existing message’s id
(the basis for “edit in place”), auto-assigns a UUID to ID-less messages, and
deletes via RemoveMessage(id=...). The sentinel REMOVE_ALL_MESSAGES
("__remove_all__") clears the entire list in one write when included in the
update list — everything in existing state, and everything in the incoming batch
before the sentinel, is discarded; messages after the sentinel become the new
list. format="langchain-openai" normalises mixed dict/tuple/BaseMessage input
into OpenAI-compatible content blocks via convert_to_openai_messages (needs
langchain-core>=0.3.11). MessagesState is the one-field TypedDict
(messages: Annotated[list[AnyMessage], add_messages]) that ships as the
canonical chatbot state — subclass it to add fields.
push_message() emits a single message immediately to the "messages" stream
during node execution, before the node returns — and (unless state_key=None)
also writes it into the named state channel via the add_messages-compatible
reducer there. Useful for streaming intermediate progress without waiting for the
node to finish; must be called from inside a running node (reads the active
config via a ContextVar).
def add_messages(left: Messages, right: Messages, *, format: Literal["langchain-openai"] | None = None) -> Messages# add_messages() / add_messages(format=...) with no left/right returns a functools.partial,# directly usable as an Annotated reducer: Annotated[list, add_messages(format="langchain-openai")]REMOVE_ALL_MESSAGES: str = "__remove_all__"class MessagesState(TypedDict): messages: Annotated[list[AnyMessage], add_messages]
def push_message(message: MessageLikeRepresentation | BaseMessageChunk, *, state_key: str | None = "messages") -> AnyMessageA separate, experimental batch-safe reducer, _messages_delta_reducer, exists
for pairing add_messages-style semantics with DeltaChannel — it is
batching-invariant but does not implement REMOVE_ALL_MESSAGES or auto-ID
assignment, unlike add_messages.
from langchain_core.messages import HumanMessage, AIMessage, RemoveMessagefrom langgraph.graph.message import add_messages, REMOVE_ALL_MESSAGES
msgs = add_messages([HumanMessage("hi", id="1")], [AIMessage("hello", id="2")])msgs = add_messages(msgs, [AIMessage("hello again", id="2")]) # in-place replace by idmsgs = add_messages(msgs, [RemoveMessage(id="1")]) # delete by idmsgs = add_messages(msgs, [RemoveMessage(id=REMOVE_ALL_MESSAGES), HumanMessage("fresh start")])assert len(msgs) == 1from langchain_core.messages import AIMessageChunkfrom langgraph.graph.message import push_message, MessagesStatefrom langgraph.graph import StateGraph, START, END
def long_running(state: MessagesState) -> dict: push_message(AIMessageChunk(content="Starting… ", id="progress-1")) return {"messages": [AIMessageChunk(content="Done.", id="final-1")]}
graph = StateGraph(MessagesState).add_node("work", long_running).add_edge(START, "work").add_edge("work", END).compile()for chunk in graph.stream({"messages": [("user", "go")]}, stream_mode="messages"): msg, meta = chunkMessageGraph (deprecated)
Section titled “MessageGraph (deprecated)”Module: langgraph.graph.message
The pre-1.0 graph type whose entire state was a bare list[AnyMessage]
(Annotated[list[AnyMessage], add_messages]). Fully deprecated, emits
LangGraphDeprecatedSinceV10. Migrate to StateGraph(MessagesState) —
behaviourally identical, just wraps the list in a "messages" key.
class MessageGraph(StateGraph): def __init__(self) -> None: super().__init__(Annotated[list[AnyMessage], add_messages])# Migration: MessageGraph() -> StateGraph(MessagesState)from langgraph.graph import StateGraph, START, END, MessagesStatefrom langchain_core.messages import AIMessage
def chatbot(state: MessagesState) -> dict: return {"messages": [AIMessage(content=f"Echo: {state['messages'][-1].content}")]}
graph = StateGraph(MessagesState).add_node("chatbot", chatbot).add_edge(START, "chatbot").add_edge("chatbot", END).compile()TAG_NOSTREAM, TAG_HIDDEN
Section titled “TAG_NOSTREAM, TAG_HIDDEN”Module: langgraph.constants. Two sys.intern()d tag strings that affect
streaming/tracing visibility.
TAG_NOSTREAM("nostream") — attach via.with_config({"tags": ["nostream"]})on a chat-model call inside a node to suppress its token stream fromstream_mode="messages"; the model still runs and its final output is used normally.TAG_HIDDEN("langsmith:hidden") — suppresses a node fromstream_mode="debug"/LangSmith traces. LangGraph applies this itself to internal book-keeping nodes (e.g. the__start__input-projection node); passingtags=[TAG_HIDDEN]to your ownadd_node()call has no effect in this version — there is no user-facing storage path for arbitrary node tags inStateNodeSpec.
from langgraph.constants import TAG_HIDDENfrom langgraph.graph import StateGraph, START, ENDfrom typing_extensions import TypedDict
class State(TypedDict): value: int
g = StateGraph(State)g.add_node("compute", lambda s: {"value": s["value"] * 2})g.add_edge(START, "compute"); g.add_edge("compute", END)graph = g.compile()
hidden = [name for name, node in graph.nodes.items() if TAG_HIDDEN in (node.tags or [])]print(hidden) # ['__start__']Graph visualization — get_graph, draw_mermaid, xray, get_subgraphs, schema introspection
Section titled “Graph visualization — get_graph, draw_mermaid, xray, get_subgraphs, schema introspection”Module: langgraph.graph.state / langgraph.pregel.main
get_graph() returns a langchain_core.runnables.graph.Graph — built by a
dry-run simulation of the Pregel loop from an empty checkpoint (no node
functions actually execute); it inspects the statically-declared ChannelWrite
targets to discover edges, so it works even for graphs whose conditional edges
depend on data.
def get_graph(self, config=None, *, xray: int | bool = False) -> Graph: ...def get_subgraphs(self, *, namespace: str | None = None, recurse: bool = False) -> Iterator[tuple[str, PregelProtocol]]: ...xray=Truerecursively expands every nested subgraph to its leaves;xray=1/xray=2/… expands only that many levels. Subgraph-internal node ids are prefixedparent_node:child_node.graph.draw_mermaid()returns Mermaid markdown;graph.draw_mermaid_png()defaults to calling the hosted Mermaid.ink API (MermaidDrawMethod.API) — passdraw_method=MermaidDrawMethod.PYPPETEERfor a fully offline render.get_subgraphs(namespace=None)yields(name, subgraph)for every node whose compiled graph is embedded directly as a node value;recurse=Truedescends and prefixes namespaces ("outer:inner"). Nodes that merely call a compiled subgraph’s.invoke()from inside a wrapper function are not discovered this way — only subgraphs wired in directly viaadd_node("name", compiled_subgraph).get_input_jsonschema()/get_output_jsonschema()return{}when the schema is untyped;get_context_jsonschema()returnsNone(not{}) when nocontext_schemawas passed toStateGraph. The olderget_config_jsonschema()is deprecated since v1.0 in favor ofget_context_jsonschema().Pregel.as_tool()(beta) wraps any compiled graph as aBaseTool— see the Tools & Tool Calling section.
from langgraph.graph import StateGraph, START, ENDfrom typing_extensions import TypedDict
class State(TypedDict): x: int
sub = StateGraph(State)sub.add_node("op", lambda s: {"x": s["x"] * 2})sub.set_entry_point("op"); sub.set_finish_point("op")compiled_sub = sub.compile()
main = StateGraph(State)main.add_node("subgraph", compiled_sub)main.add_edge(START, "subgraph"); main.add_edge("subgraph", END)compiled_main = main.compile()
print(sorted(compiled_main.get_graph().nodes)) # shallow: ['__end__', '__start__', 'subgraph']print(sorted(compiled_main.get_graph(xray=True).nodes)) # expanded: includes 'subgraph:op'BranchSpec — conditional-edge internals
Section titled “BranchSpec — conditional-edge internals”Modules: langgraph.graph._branch
add_conditional_edges() compiles into a BranchSpec NamedTuple stored on
builder.branches[source_node][name].
class BranchSpec(NamedTuple): path: Runnable[Any, Hashable | list[Hashable]] ends: dict[Hashable, str] | None input_schema: type | None = None
@classmethod def from_path(cls, path, path_map, infer_schema: bool = False) -> "BranchSpec": ...ends=Nonemeans open routing — the function’s return value must itself be a node name (or aSendobject).- With
path_map=Noneand aLiteral[...]return annotation on the routing function,endsis auto-inferred from theLiteralmembers. add_conditional_edgescallsfrom_path(..., infer_schema=True), soinput_schemais populated from the router’s parameter type when it’s aTypedDict/dataclass; callingfrom_pathdirectly defaultsinfer_schema=False.
from typing import Literalfrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, END
class State(TypedDict): score: int
def route(state: State) -> Literal["high", "low", "__end__"]: if state["score"] > 50: return "high" if state["score"] > 10: return "low" return "__end__"
builder = StateGraph(State)builder.add_node("high", lambda s: {"score": s["score"] + 100})builder.add_node("low", lambda s: {"score": s["score"] - 5})builder.add_edge(START, "high")builder.add_conditional_edges("high", route)
spec = builder.branches["high"]["route"]print(spec.ends) # {'high': 'high', 'low': 'low', '__end__': '__end__'}Channels — LastValue, BinaryOperatorAggregate + Overwrite, Topic, EphemeralValue, NamedBarrierValue (+ AfterFinish), AnyValue, UntrackedValue, DeltaChannel
Section titled “Channels — LastValue, BinaryOperatorAggregate + Overwrite, Topic, EphemeralValue, NamedBarrierValue (+ AfterFinish), AnyValue, UntrackedValue, DeltaChannel”Modules: langgraph.channels.*; abstract base langgraph.channels.base.BaseChannel
Every state field maps to a channel type that governs what happens when zero, one, or multiple parallel nodes write to it in the same super-step:
| Channel | Concurrent writes | Not written this step | Persists across steps |
|---|---|---|---|
LastValue (default, unannotated field) | raises InvalidUpdateError | retains previous | yes |
LastValueAfterFinish | same as LastValue | value unreadable until finish(); consume() resets value + finished flag | yes, as (value, finished_bool) |
BinaryOperatorAggregate (Annotated[T, reducer]) | folded through operator(a, b) in arrival order (first write bootstraps the accumulator) | unchanged | yes |
Topic(T, accumulate=False) | collects all → list (scalar or list writes both flatten) | resets to [] | no |
Topic(T, accumulate=True) | collects all → list | unchanged | yes |
AnyValue | last wins, no guard (assumes all equal) | clears to MISSING | no |
EphemeralValue(guard=True) | raises InvalidUpdateError | clears | no |
EphemeralValue(guard=False) | last wins | clears | no |
NamedBarrierValue(names={...}) | adds to seen-set; is_available() opens only when all seen (get() returns None — the signal is availability, not a value) | no value exposed until opened | resets (seen=set()) after consume() |
UntrackedValue | guard controls same as EphemeralValue | never checkpointed (checkpoint() → MISSING) | no (always empty on resume) |
DeltaChannel (beta) | reducer-folded, but only a sentinel is stored in checkpoints; full value rebuilt by replaying ancestor writes | n/a | yes (bounded by snapshot_frequency) |
Overwrite(value) (from langgraph.types) bypasses a BinaryOperatorAggregate’s
reducer and replaces the channel outright — at most one Overwrite per channel per
step (a second raises InvalidUpdateError); mixing one Overwrite with normal writes
is fine. It’s detected in three forms: an Overwrite instance, a {"__overwrite__": v} dict, or the JSON round-trip shape {"value": v, "type": "__overwrite__"}.
class BinaryOperatorAggregate(BaseChannel[Value, Value, Value]): def __init__(self, typ: type[Value], operator: Callable[[Value, Value], Value]): ...class Topic(BaseChannel[Sequence[Value], Value | list[Value], list[Value]]): def __init__(self, typ: type[Value], accumulate: bool = False) -> None: ...class EphemeralValue(BaseChannel[Value, Value, Value]): def __init__(self, typ: Any, guard: bool = True) -> None: ...class NamedBarrierValue(BaseChannel[Value, Value, set[Value]]): def __init__(self, typ: type[Value], names: set[Value]) -> None: ...class UntrackedValue(BaseChannel[Value, Value, Value]): def __init__(self, typ: type[Value], guard: bool = True) -> None: ...class DeltaChannel(BaseChannel): def __init__(self, reducer: Callable[[Any, Sequence[Any]], Any], typ: type | None = None, *, snapshot_frequency: int = 1000) -> None: ...class Overwrite: value: AnyDeltaChannel’s reducer receives the whole batch of writes for the step, not
one value at a time, and must be batching-invariant:
reducer(reducer(s, xs), ys) == reducer(s, xs + ys). A _DeltaSnapshot blob is
written every snapshot_frequency updates or every
DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT (env LANGGRAPH_DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT,
default 5000) supersteps, bounding replay depth.
import operatorfrom typing import Annotated, Sequencefrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.channels import Topicfrom langgraph.types import Overwrite
class State(TypedDict): total: Annotated[int, operator.add] # BinaryOperatorAggregate events: Annotated[Sequence[str], Topic(str)] # cleared each step
def accumulate(state: State) -> dict: return {"total": 10, "events": "ran"}
def reset(state: State) -> dict: return {"total": Overwrite(0)} # bypasses operator.add for this write
builder = StateGraph(State)builder.add_node("acc", accumulate); builder.add_node("reset", reset)builder.add_edge(START, "acc"); builder.add_edge("acc", "reset"); builder.add_edge("reset", END)result = builder.compile().invoke({"total": 5, "events": []})print(result["total"]) # 0 — Overwrite replaced the accumulated valueNamedBarrierValueAfterFinish (same module) adds a second gate on top of
NamedBarrierValue: the channel only opens once every name is seen and an explicit
finish() has been called — a “collect, then release” pattern used internally for
subgraph coordination; direct use is rare.
Writing a custom channel: subclass BaseChannel[Value, Update, Checkpoint] and
implement ValueType, UpdateType, get(), update(), from_checkpoint();
override checkpoint() (default calls get()), consume() (return True to
self-clear after each read, as EphemeralValue does), and is_available() as needed.
Node representation internals — StateNodeSpec, PregelNode
Section titled “Node representation internals — StateNodeSpec, PregelNode”Modules: langgraph.graph._node, langgraph.pregel._read
add_node() builds a StateNodeSpec (the declarative record: runnable, input_schema,
retry/cache/timeout policies, is_error_handler/error_handler_node, defer) and
stores it on builder.nodes[name] pre-compile. compile() turns each spec into a
PregelNode (channels subscribed, triggers, writers, bound runnable) stored on
graph.nodes[name]. Useful for introspecting a graph before or after compilation,
e.g. for custom linting or tracing tools.
from langgraph.graph import StateGraph, START, ENDfrom langgraph.types import RetryPolicyfrom typing_extensions import TypedDict
class State(TypedDict): value: int
builder = StateGraph(State)builder.add_node("n", lambda s: {"value": s["value"] + 1}, retry_policy=RetryPolicy(max_attempts=3))spec = builder.nodes["n"] # StateNodeSpec, pre-compileprint(spec.retry_policy, spec.is_error_handler)builder.add_edge(START, "n"); builder.add_edge("n", END)graph = builder.compile()pregel_node = graph.nodes["n"] # PregelNode, post-compileprint(pregel_node.triggers, pregel_node.retry_policy)Pregel + NodeBuilder
Section titled “Pregel + NodeBuilder”Module: langgraph.pregel.main (re-exported from langgraph.pregel)
Pregel is the runtime engine underlying every compiled graph — CompiledStateGraph
is a thin subclass. You rarely instantiate it directly; its constructor exposes the
full set of runtime knobs (stream_eager, step_timeout, trigger_to_nodes, etc.)
that StateGraph.compile() doesn’t surface. NodeBuilder is the low-level fluent API
add_node()/add_edge() compile down to — useful for wiring a Pregel graph by hand
without StateGraph.
Pregel(*, nodes: dict[str, PregelNode | NodeBuilder], channels: dict[str, BaseChannel | ManagedValueSpec] | None, output_channels: str | Sequence[str], input_channels: str | Sequence[str], checkpointer=None, store=None, cache=None, context_schema=None, interrupt_before_nodes=(), interrupt_after_nodes=(), name: str = "LangGraph", ...)
# NodeBuilder (verified 1.2.11 — note the real method names differ from some older docs)nb = NodeBuilder()nb.subscribe_only("channel") # or .subscribe_to("ch1", "ch2", read=True)nb.do(my_function) # set the node actionnb.write_to("output_channel") # declare writesnb.add_retry_policies(RetryPolicy(...)) # NOT with_retry_policy()nb.add_cache_policy(CachePolicy(...)) # NOT with_cache_policy()nb.set_timeout(30.0) # NOT with_timeout()nb.meta("tag1", env="prod") # tags + metadata combined; NOT with_tags()/with_metadata()pregel_node = nb.build() # -> PregelNodefrom langgraph.pregel.main import Pregel, NodeBuilderfrom langgraph.channels.last_value import LastValue
node_double = NodeBuilder().subscribe_only("input").do(lambda x: {"result": x * 2}).write_to("result")node_format = NodeBuilder().subscribe_only("result").do(lambda r: {"output": f"Result is {r}"}).write_to("output")graph = Pregel( nodes={"double": node_double, "format": node_format}, channels={"input": LastValue(int), "result": LastValue(int), "output": LastValue(str)}, input_channels="input", output_channels="output",)print(graph.invoke(5)) # 'Result is 10'Internals (private, unverified-stability): apply_writes / prepare_next_tasks /
should_interrupt / validate_graph / validate_keys (langgraph.pregel._algo,
langgraph.pregel._validate) implement compile-time validation and the core superstep
algorithm; read_channel/read_channels/map_input/map_command (langgraph.pregel._io)
form the I/O layer between the loop and channel state; ChannelWrite/ChannelWriteEntry/
ChannelRead (langgraph.pregel._write, ._read) are the write/read runnables every
compiled node is wrapped in; RunnableCallable/RunnableSeq (langgraph._internal._runnable)
are LangGraph’s own lightweight Runnable wrapper/pipeline (every node function passed
to add_node is wrapped in one); DataclassLike/TypedDictLikeV1/TypedDictLikeV2
(langgraph._internal._typing) are the structural protocols used to detect which kind
of schema a state class is; get_field_default/get_cached_annotated_keys
(langgraph._internal._fields) resolve default values and MRO-ordered field lists for
TypedDict/dataclass/Pydantic schemas; Edge/TriggerEdge (langgraph.pregel._draw) are
the NamedTuples behind get_graph().edges, draw_mermaid(), draw_ascii(). All of
these live in underscore-prefixed (“private”) modules as of the 1.x line and carry no
compatibility guarantee — treat them as debugging aids, not stable API.
Checkpointing & Persistence
Section titled “Checkpointing & Persistence”InMemorySaver (BaseCheckpointSaver)
Section titled “InMemorySaver (BaseCheckpointSaver)”Module: langgraph.checkpoint.memory. Alias: MemorySaver.
The in-process checkpoint backend. Stores, per thread/namespace/checkpoint id, a
(checkpoint_bytes, metadata_bytes, parent_id) tuple in storage, plus pending
task writes in writes, plus deduplicated channel values by version in blobs
(a channel whose value hasn’t changed across checkpoints is stored once, not once
per checkpoint). MemorySaver additionally accepts a filename= for a
PersistentDict-backed on-disk store that survives process restarts (see below)
— plain InMemorySaver() is purely in-memory. saver.list(config, filter=..., limit=...) and saver.delete_thread(thread_id) are the common inspection/cleanup
operations.
from langgraph.checkpoint.memory import InMemorySaverfrom langgraph.graph import StateGraph, START, ENDfrom typing_extensions import TypedDict
class S(TypedDict): x: int
saver = InMemorySaver()graph = StateGraph(S).add_node("inc", lambda s: {"x": s["x"] + 1}).add_edge(START, "inc").add_edge("inc", END).compile(checkpointer=saver)cfg = {"configurable": {"thread_id": "t1"}}for _ in range(3): graph.invoke({"x": 1}, cfg)for tup in saver.list(cfg): print(tup.metadata.get("step"), tup.checkpoint["id"][:8])saver.delete_thread("t1")BaseCheckpointSaver — building a custom backend
Section titled “BaseCheckpointSaver — building a custom backend”Module: langgraph.checkpoint.base
The abstract base every checkpoint backend implements. V is the version type
(int/float/str) used to order channel writes. Four methods make a working
backend: get_tuple, list, put, put_writes (plus async twins for production
use). Override get_next_version(current, channel) if your backend orders versions
differently from the default integer increment.
class BaseCheckpointSaver(Generic[V]): def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: ... def list(self, config, *, filter=None, before=None, limit=None) -> Iterator[CheckpointTuple]: ... def put(self, config, checkpoint: Checkpoint, metadata: CheckpointMetadata, new_versions: ChannelVersions) -> RunnableConfig: ... def put_writes(self, config, writes: list[tuple[str, Any]], task_id: str, task_path: str = "") -> None: ...from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointTuple, get_checkpoint_id
class DictSaver(BaseCheckpointSaver[int]): def __init__(self): super().__init__() self._store: dict[tuple, tuple] = {}
def get_tuple(self, config): tid = config["configurable"]["thread_id"] ns = config["configurable"].get("checkpoint_ns", "") cid = get_checkpoint_id(config) entry = self._store.get((tid, ns, cid)) if cid else None if entry is None: return None cp, meta = entry return CheckpointTuple(config=config, checkpoint=cp, metadata=meta)
def put(self, config, checkpoint, metadata, new_versions): c = config["configurable"] self._store[(c["thread_id"], c.get("checkpoint_ns", ""), checkpoint["id"])] = (checkpoint, metadata) return {"configurable": {**c, "checkpoint_id": checkpoint["id"]}}
def list(self, config, *, filter=None, before=None, limit=None): return iter([])
def put_writes(self, config, writes, task_id, task_path=""): passget_checkpoint_id, get_checkpoint_metadata
Section titled “get_checkpoint_id, get_checkpoint_metadata”Module: langgraph.checkpoint.base.
def get_checkpoint_id(config: RunnableConfig) -> str | None: ...def get_checkpoint_metadata(config: RunnableConfig, *, metadata: CheckpointMetadata) -> CheckpointMetadata: ...get_checkpoint_id reads config["configurable"]["checkpoint_id"]; it raises
KeyError if "configurable" itself is absent (call ensure_config() first, or
guard with .get("configurable", {})). get_checkpoint_metadata is the helper
custom checkpoint-saver authors call inside put() to merge the caller-supplied
metadata with run_id from the config before writing.
CheckpointMetadata, CheckpointTuple, StateSnapshot, PregelTask / PregelExecutableTask
Section titled “CheckpointMetadata, CheckpointTuple, StateSnapshot, PregelTask / PregelExecutableTask”Modules: langgraph.types (CheckpointMetadata, StateSnapshot, PregelTask,
PregelExecutableTask), langgraph.checkpoint.base (CheckpointTuple)
CheckpointTuple is what BaseCheckpointSaver.get_tuple()/list() return —
(config, checkpoint, metadata, parent_config, pending_writes). CheckpointMetadata
rides alongside every checkpoint describing why it was created (source: "input" |
"loop" | "update" | "fork"; step — -1 for the initial “input” checkpoint,
then 0, 1, 2, ...; parents; run_id). StateSnapshot (returned by
graph.get_state() / yielded by get_state_history()) is the user-facing view:
values, next (nodes scheduled next; empty tuple means terminal), config (pass
back to invoke()/update_state() to fork here), metadata, created_at,
parent_config (walk this to traverse history without calling
get_state_history() again), tasks (tuple[PregelTask, ...]), interrupts.
PregelTask describes one node execution: id, name, path, error (set if
that task’s last run raised), interrupts, state (subgraph snapshot if
applicable), result. PregelExecutableTask is the heavier, live in-flight form —
adds input, proc (the runnable), writes (a deque accumulating channel
writes during execution), retry_policy, cache_key, timeout, triggers.
class CheckpointMetadata(TypedDict, total=False): source: Literal["input", "loop", "update", "fork"]; step: int parents: dict[str, str]; run_id: strclass CheckpointTuple(NamedTuple): config: RunnableConfig; checkpoint: Checkpoint; metadata: CheckpointMetadata parent_config: RunnableConfig | None = None; pending_writes: list | None = Noneclass StateSnapshot(NamedTuple): values: dict | Any; next: tuple[str, ...]; config: RunnableConfig metadata: CheckpointMetadata | None; created_at: str | None parent_config: RunnableConfig | None; tasks: tuple[PregelTask, ...]; interrupts: tuple[Interrupt, ...]class PregelTask(NamedTuple): id: str; name: str; path: tuple; error: Exception | None = None interrupts: tuple[Interrupt, ...] = (); state: Any = None; result: Any = Nonefrom langgraph.graph import StateGraph, START, ENDfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.types import interrupt, Commandfrom typing_extensions import TypedDict
class State(TypedDict): step: int; notes: str
def step_node(state: State) -> dict: val = interrupt(f"Approve step {state['step']}?") return {"step": state["step"] + 1, "notes": val}
graph = StateGraph(State).add_node("step", step_node).add_edge(START, "step").add_edge("step", END).compile(checkpointer=InMemorySaver())cfg = {"configurable": {"thread_id": "t1"}}list(graph.stream({"step": 0, "notes": ""}, cfg))snap = graph.get_state(cfg)print(snap.values, snap.interrupts, snap.next) # {'step': 0, ...} (Interrupt(...),) ('step',)list(graph.stream(Command(resume="approved"), cfg))for task in graph.get_state(cfg).tasks: print(task.name, task.error)
# Walk history via parent_config without calling get_state_history againhist = []s = graph.get_state(cfg)while s is not None: hist.append(s.values) s = graph.get_state(s.parent_config) if s.parent_config else Noneget_state, get_state_history, update_state / bulk_update_state (time-travel)
Section titled “get_state, get_state_history, update_state / bulk_update_state (time-travel)”Module: langgraph.graph.state (methods on CompiledStateGraph); StateUpdate
lives in langgraph.types.
def get_state(self, config, *, subgraphs: bool = False) -> StateSnapshot: ...def get_state_history(self, config, *, filter=None, before=None, limit=None) -> Iterator[StateSnapshot]: ...def update_state(self, config, values, as_node: str | None = None, task_id: str | None = None) -> RunnableConfig: ...def bulk_update_state(self, config, supersteps: Sequence[Sequence[StateUpdate]]) -> RunnableConfig: ...
class StateUpdate(NamedTuple): values: dict[str, Any] | None; as_node: str | None = None; task_id: str | None = NoneInjects state externally into a checkpointed thread — for seeding, time-travel
patching, or resuming after human review. update_state() is a thin wrapper:
bulk_update_state(config, [[StateUpdate(values, as_node, task_id)]]).
bulk_update_state accepts a list of super-steps, each a list of StateUpdate
objects; each inner list’s writes are folded through the field reducers together,
and the outer sequence orders supersteps atomically — useful for seeding test
fixtures without running any node. get_state_history yields newest-first;
before= (a config pointing at a checkpoint) restricts to strictly older history;
limit= caps the count. Both update_state/bulk_update_state require a
checkpointer — calling them on a graph compiled without one raises.
from langgraph.checkpoint.memory import InMemorySaverfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import StateUpdatefrom typing_extensions import TypedDict
class State(TypedDict): status: str; result: str; messages: list[str]; count: int
graph = StateGraph(State).add_node("process", lambda s: {"status": "processing"}).add_edge(START, "process").add_edge("process", END).compile(checkpointer=InMemorySaver())cfg = {"configurable": {"thread_id": "t1"}}graph.invoke({"status": "pending", "result": "", "messages": [], "count": 0}, cfg)new_cfg = graph.update_state(cfg, {"result": "approved", "status": "done"}, as_node="process")print(graph.get_state(new_cfg).values)
graph.bulk_update_state(cfg, [ [StateUpdate(values={"messages": ["seed-1"], "count": 10}, as_node="process")], [StateUpdate(values={"messages": ["seed-2"], "count": 20}, as_node="process")],])GraphOutput (version="v2")
Section titled “GraphOutput (version="v2")”Module: langgraph.types
The typed return of invoke()/ainvoke() when called with version="v2": separates
the graph’s final value (.value) from any pending interrupts (.interrupts) instead
of overloading the return dict with a magic "__interrupt__" key. Dict-style access
(output["key"], "key" in output) still works but is deprecated
(LangGraphDeprecatedSinceV11, removal targeted for v3.0) — use .value/.interrupts.
class GraphOutput(Generic[OutputT]): value: OutputT interrupts: tuple[Interrupt, ...] = ()from langgraph.graph import StateGraph, START, ENDfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.types import interrupt, Command, GraphOutputfrom typing_extensions import TypedDict
class S(TypedDict): data: str; approved: bool
def review(state: S) -> dict: decision = interrupt({"prompt": f"Approve {state['data']}?"}) return {"approved": decision == "yes"}
graph = StateGraph(S).add_node("review", review).add_edge(START, "review").add_edge("review", END).compile(checkpointer=InMemorySaver())cfg = {"configurable": {"thread_id": "t1"}}out: GraphOutput = graph.invoke({"data": "deploy", "approved": False}, cfg, version="v2")if out.interrupts: print(out.interrupts[0].id, out.interrupts[0].value)final: GraphOutput = graph.invoke(Command(resume="yes"), cfg, version="v2")print(final.value["approved"]) # TrueDurability
Section titled “Durability”Module: langgraph.types
A Literal["sync", "async", "exit"] type alias controlling when checkpoint
writes are flushed, accepted by invoke/ainvoke/stream/astream (a per-call
knob, not a compile() knob). "sync" persists before the next step starts
(safest, resumable from any step, zero loss on crash); "async" persists
concurrently with the next step (a crash mid-step can lose that step’s
checkpoint); "exit" persists only when the run ends or is interrupted (fastest,
no mid-run recovery).
Durability = Literal["sync", "async", "exit"]result = graph.invoke({"counter": 0}, config, durability="sync") # safestresult = graph.invoke({"counter": 0}, config, durability="exit") # fastestJsonPlusSerializer
Section titled “JsonPlusSerializer”Module: langgraph.checkpoint.serde.jsonplus
The default serializer for every BaseCheckpointSaver. Despite the name, uses
ormsgpack (binary MessagePack) as the primary encoding, falling back to a legacy
JSON-plus format for types msgpack can’t handle. pickle_fallback=True is a
security risk (arbitrary deserialization) — never enable in production. Set
LANGGRAPH_STRICT_MSGPACK=true to restrict deserialization to a built-in
safe-type allowlist (recommended for production) — an unknown type then raises
InvalidModuleError instead of silently deserializing; extend the allowlist via
allowed_msgpack_modules=[("myapp.models", "UserProfile")] or
serde.with_msgpack_allowlist([MyClass, ...]). The allowlist is built by
langgraph._internal._serde.build_serde_allowlist, which recursively traverses
your Pydantic/dataclass/TypedDict/Enum state schemas plus a curated set of
BaseMessage types.
JsonPlusSerializer(*, pickle_fallback: bool = False, allowed_json_modules: Iterable[tuple[str, ...]] | Literal[True] | None = None, allowed_msgpack_modules: AllowedMsgpackModules | Literal[True] | None = ...)import osos.environ["LANGGRAPH_STRICT_MSGPACK"] = "true" # set before importing langgraphfrom langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
serde = JsonPlusSerializer(allowed_msgpack_modules=[("myapp.models", "UserProfile")])EncryptedSerializer + SerializerProtocol / CipherProtocol / SerializerCompat
Section titled “EncryptedSerializer + SerializerProtocol / CipherProtocol / SerializerCompat”Modules: langgraph.checkpoint.serde.encrypted (EncryptedSerializer),
langgraph.checkpoint.serde.base (protocols)
EncryptedSerializer wraps any SerializerProtocol (default JsonPlusSerializer)
with a CipherProtocol, encrypting checkpoint bytes at rest; the type string gains a
+ciphername suffix (e.g. "msgpack+aes") so loads_typed can distinguish encrypted
from plain blobs — loads_typed splits on the first + and falls through to the
inner serde unchanged when it’s absent, so pre-existing unencrypted checkpoints
still read correctly after you turn encryption on (supports incremental rollout).
from_pycryptodome_aes() is a ready-made AES-EAX (authenticated) cipher factory
reading a 16/24/32-byte key from key= or the LANGGRAPH_AES_KEY env var —
there is no key-ID stored alongside the ciphertext, so rotating the key means
old blobs become unreadable unless you keep the old key around long enough to
re-encrypt everything. SerializerProtocol (@runtime_checkable) is just
dumps_typed(obj) -> (type_str, bytes) / loads_typed((type_str, bytes)) -> obj —
no inheritance required. SerializerCompat wraps an old-style dumps/loads-only
serde, using type(obj).__name__ as the type tag.
class EncryptedSerializer(SerializerProtocol): def __init__(self, cipher: CipherProtocol, serde: SerializerProtocol = JsonPlusSerializer()): ... @classmethod def from_pycryptodome_aes(cls, serde=JsonPlusSerializer(), **kwargs) -> EncryptedSerializer: ...class CipherProtocol(Protocol): def encrypt(self, plaintext: bytes) -> tuple[str, bytes]: ... def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes: ...import osfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.checkpoint.serde.encrypted import EncryptedSerializer
serde = EncryptedSerializer.from_pycryptodome_aes(key=os.urandom(32)) # pip install pycryptodomesaver = InMemorySaver(serde=serde)register_serde_event_listener + SerdeEvent
Section titled “register_serde_event_listener + SerdeEvent”Module: langgraph.checkpoint.serde.event_hooks
Observability hook into the serde subsystem: emit_serde_event fires whenever
JsonPlusSerializer (de)serialises a type outside the msgpack allowlist, or a
blocked type. Subscribe with register_serde_event_listener(fn) (returns an
unregister callable) to audit-log or build a dynamic allowlist. Listener
exceptions are caught and logged per-listener (never propagated — a broken
listener cannot break serialization); all listeners are global (process-wide),
guarded by a lock with a short critical section.
class SerdeEvent(TypedDict): kind: str; module: str; name: str; method: NotRequired[str] # kind in {"msgpack_unregistered_allowed", "msgpack_blocked", "msgpack_method_blocked"}def register_serde_event_listener(listener: Callable[[SerdeEvent], None]) -> Callable[[], None]: ...from langgraph.checkpoint.serde.event_hooks import register_serde_event_listener, SerdeEvent
audit_log: list[SerdeEvent] = []unregister = register_serde_event_listener(audit_log.append)# ... run graphs ...unregister()PersistentDict
Section titled “PersistentDict”Module: langgraph.checkpoint.memory. Internal to MemorySaver(filename=...).
A defaultdict subclass that pickles itself to disk atomically (via a .tmp file),
powering MemorySaver when you pass a filename so checkpoints survive process
restarts without a real database — good for local dev, reproducible test fixtures, and
lightweight single-process scripts. flag="c" (default) creates-or-opens; "r" is
read-only (sync() a no-op); "n" always overwrites.
class PersistentDict(defaultdict): def __init__(self, *args, filename: str, **kwds): ... def sync(self) -> None: ... # atomic flush to disk def close(self) -> None: ... # sync() + clear()ReplayState — time-travel subgraph coordination
Section titled “ReplayState — time-travel subgraph coordination”Module: langgraph._internal._replay (private; used internally by time-travel replay)
When you invoke(None, config) at a historical checkpoint_id, LangGraph must load
each subgraph from its checkpoint immediately before the replay point on first
visit, then fall back to normal latest-checkpoint loading on subsequent visits within
the same loop (so the graph can still make forward progress). A single ReplayState
instance, shared by reference for the whole replayed run, tracks which subgraph
namespaces have already been seeded — stripping the :task_id suffix so repeated
loop iterations of the same subgraph are recognised as “already visited.”
class ReplayState: def __init__(self, checkpoint_id: str) -> None: ... def _is_first_visit(self, checkpoint_ns: str) -> bool: ... # strips ":task_id" def get_checkpoint(self, checkpoint_ns, checkpointer, checkpoint_config) -> CheckpointTuple | None: ...from langgraph.checkpoint.memory import InMemorySaverfrom langgraph.graph import StateGraph, START, ENDfrom typing_extensions import TypedDict
class State(TypedDict): n: int
graph = StateGraph(State).add_node("inc", lambda s: {"n": s["n"] + 1}).add_edge(START, "inc").add_edge("inc", END).compile(checkpointer=InMemorySaver())cfg = {"configurable": {"thread_id": "t1"}}for _ in range(3): graph.invoke({"n": 0}, cfg)history = list(graph.get_state_history(cfg))old_cfg = history[-1].configgraph.invoke(None, old_cfg) # re-replay from the oldest checkpoint; branches historyPostgresSaver / ShallowPostgresSaver / AsyncPostgresSaver — production checkpoint backends
Section titled “PostgresSaver / ShallowPostgresSaver / AsyncPostgresSaver — production checkpoint backends”Modules: langgraph.checkpoint.postgres, .shallow, .aio — separate package
langgraph-checkpoint-postgres, not installed in the verification venv; signatures
below are per-source but not independently re-verified here.
Persist checkpoints in PostgreSQL via psycopg3 (+ optional psycopg_pool).
PostgresSaver stores full history (every step — enables get_state_history() /
time-travel); ShallowPostgresSaver stores only the latest row per thread+namespace
(constant storage, no history, best for high-volume production agents that don’t need
replay — deprecated as of langgraph-checkpoint-postgres 2.0.20 in favor of
PostgresSaver + durability="exit"). AsyncPostgresSaver is the asyncio-native
twin. All three expose .from_conn_string(uri, pipeline=True) as a context
manager (not a factory that returns a saver directly), and require calling
.setup() once to create tables/run migrations.
from langgraph.checkpoint.postgres import PostgresSaverwith PostgresSaver.from_conn_string(DB_URI) as checkpointer: checkpointer.setup() graph = builder.compile(checkpointer=checkpointer)Internals (private, unverified-stability): create_checkpoint / empty_checkpoint
/ delta_channels_to_snapshot (langgraph.pregel._checkpoint) build the Checkpoint
TypedDict (v, id, ts, channel_values, channel_versions, versions_seen) at
the end of every superstep and decide when a DeltaChannel needs a full snapshot blob;
PregelExecutableTask + CacheKey (langgraph.types) are the execution-time task
dataclass and its (ns, key, ttl) cache-entry identity; DeltaChannelHistory
(langgraph.checkpoint.base, beta) is the per-channel write-history record a custom
checkpointer’s get_delta_channel_history() returns for DeltaChannel reconstruction.
Streaming & Transformers
Section titled “Streaming & Transformers”stream_mode overview + get_stream_writer() / StreamWriter
Section titled “stream_mode overview + get_stream_writer() / StreamWriter”Module: langgraph.config (get_stream_writer), langgraph.types (StreamWriter alias)
graph.stream()/astream() accept stream_mode as a string or list of:
"values" (full state per step), "updates" (per-node deltas), "messages"
(token-by-token (chunk, metadata)), "custom" (arbitrary payloads from
get_stream_writer()), "debug" (task/checkpoint trace events), "tasks"
(TaskPayload/TaskResultPayload), "checkpoints" (CheckpointPayload), and
"tools" (structured tool-call events — see Tools & Tool Calling). get_stream_writer()
returns the callable bound to the current node/task; calling it with any
JSON-serialisable value pushes to "custom" immediately, without touching graph
state — the caller sees it in real time via stream_mode="custom". It is a safe
no-op outside an active run. runtime.stream_writer is the equivalent accessed
through Runtime.
StreamWriter: TypeAlias = Callable[[Any], None]def get_stream_writer() -> StreamWriter: ...from langgraph.graph import StateGraph, START, ENDfrom langgraph.config import get_stream_writerfrom typing_extensions import TypedDict
class State(TypedDict): items: list[str]; processed: list[str]
def batch_processor(state: State) -> dict: writer = get_stream_writer() results = [] for i, item in enumerate(state["items"]): writer({"progress": i + 1, "total": len(state["items"])}) results.append(item.upper()) return {"processed": results}
graph = StateGraph(State).add_node("p", batch_processor).add_edge(START, "p").add_edge("p", END).compile()for chunk in graph.stream({"items": ["a", "b"], "processed": []}, stream_mode="custom"): print(chunk)TaskPayload / TaskResultPayload (stream_mode="tasks")
Section titled “TaskPayload / TaskResultPayload (stream_mode="tasks")”Module: langgraph.types
stream_mode="tasks" emits a start event (TaskPayload) then a finish event
(TaskResultPayload) per node execution, sharing an id. In the default (v1) API
each is yielded directly; distinguish start from result by the presence of "input"
(start-only) — result is always present on the finish event (possibly {}), so
check error/interrupts rather than "result" in data to detect failure.
class TaskPayload(TypedDict): id: str; name: str; input: Any; triggers: list[str]; metadata: NotRequired[dict]class TaskResultPayload(TypedDict): id: str; name: str; error: str | None; interrupts: list[dict]; result: dict[str, Any]for event in graph.stream({"x": 1}, stream_mode="tasks"): if "input" in event: print(f"START {event['name']} input={event['input']}") else: print(f"FINISH {event['name']} result={event['result']} error={event['error']}")CheckpointPayload + CheckpointTask (stream_mode="checkpoints")
Section titled “CheckpointPayload + CheckpointTask (stream_mode="checkpoints")”Module: langgraph.types
stream_mode="checkpoints" emits one CheckpointPayload per checkpoint write —
config, metadata, values (full state), next, parent_config, and
tasks: list[CheckpointTask] (each with id, name, and optional error/result/
interrupts). Handy for audit trails and progress dashboards without touching node code.
class CheckpointTask(TypedDict): id: str; name: str; error: NotRequired[str]; result: NotRequired[Any] interrupts: NotRequired[list[dict]]; state: StateSnapshot | RunnableConfig | Noneclass CheckpointPayload(TypedDict, Generic[StateT]): config: RunnableConfig | None; metadata: CheckpointMetadata; values: StateT next: list[str]; parent_config: RunnableConfig | None; tasks: list[CheckpointTask]for cp in graph.stream({"step": 0}, config, stream_mode="checkpoints"): print(f"step={cp['metadata'].get('step')} next={cp['next']} values={cp['values']}")Typed v2 StreamPart union
Section titled “Typed v2 StreamPart union”Module: langgraph.types
Passing version="v2" to stream() wraps every chunk in a typed TypedDict with a
type discriminator, ns (namespace tuple), and data — replacing bare
tuples/dicts with an exhaustively-matchable union: ValuesStreamPart,
UpdatesStreamPart, MessagesStreamPart, CustomStreamPart, CheckpointStreamPart,
TasksStreamPart, DebugStreamPart.
StreamPart = ValuesStreamPart[OutputT] | UpdatesStreamPart | MessagesStreamPart \ | CustomStreamPart | CheckpointStreamPart[StateT] | TasksStreamPart | DebugStreamPart[StateT]for part in graph.stream({"x": 0}, config, stream_mode=["values", "updates"], version="v2"): match part["type"]: case "values": print("STATE →", part["data"]) case "updates": print("UPDATE →", part["data"])Generative UI — push_ui_message() / delete_ui_message() / UIMessage / RemoveUIMessage / ui_message_reducer
Section titled “Generative UI — push_ui_message() / delete_ui_message() / UIMessage / RemoveUIMessage / ui_message_reducer”Module: langgraph.graph.ui
The UI-streaming protocol for frontends that render components from stream events.
push_ui_message both writes a UIMessage to the "custom" stream and (unless
state_key=None) applies it to a state key (default "ui") via ui_message_reducer
— pass merge=True to shallow-merge props into an existing component with the same
id (for incremental updates like a progress bar) instead of replacing it wholesale.
delete_ui_message(id) emits a RemoveUIMessage tombstone; ui_message_reducer
raises ValueError if asked to remove an unknown id. message= links a UI
component to a specific AIMessage via metadata["message_id"].
def push_ui_message(name: str, props: dict, *, id: str | None = None, metadata: dict | None = None, message: AnyMessage | None = None, state_key: str | None = "ui", merge: bool = False) -> UIMessagedef delete_ui_message(id: str, *, state_key: str = "ui") -> RemoveUIMessagedef ui_message_reducer(left, right) -> list[AnyUIMessage]class UIMessage(TypedDict): type: Literal["ui"]; id: str; name: str; props: dict; metadata: dictclass RemoveUIMessage(TypedDict): type: Literal["remove-ui"]; id: strfrom typing import Annotatedfrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.graph.ui import push_ui_message, delete_ui_message, ui_message_reducer, AnyUIMessage
class State(TypedDict): result: str; ui: Annotated[list[AnyUIMessage], ui_message_reducer]
def task(state: State) -> dict: bar = push_ui_message("ProgressBar", {"pct": 0}) push_ui_message("ProgressBar", {"pct": 100}, id=bar["id"], merge=True) delete_ui_message(bar["id"]) return {"result": "done"}
graph = StateGraph(State).add_node("task", task).add_edge(START, "task").add_edge("task", END).compile()result = graph.invoke({"result": "", "ui": []})print(result["ui"]) # [] — component was removedv3 streaming API — GraphRunStream / AsyncGraphRunStream, SubgraphRunStream, StreamChannel, StreamMux, ProtocolEvent
Section titled “v3 streaming API — GraphRunStream / AsyncGraphRunStream, SubgraphRunStream, StreamChannel, StreamMux, ProtocolEvent”Modules: langgraph.stream.run_stream, .transformers, .stream_channel, ._mux, ._types
Status: @beta — graph.stream_events(version="v3") / astream_events(version="v3")
returns a caller-driven stream handle instead of a flat iterator. There is no
background thread: iterating any projection attribute (run.values, run.messages,
run.custom, …) is what pulls the graph forward, one raw ProtocolEvent at a time.
Under version="v3", stream_mode= is rejected — the modes that run are derived
from the registered transformers instead.
def stream_events( self, input, config=None, *, version: Literal["v1", "v2", "v3"] = "v2", interrupt_before=None, interrupt_after=None, control: RunControl | None = None, transformers: Sequence[Callable[[tuple[str,...]], Any]] | None = None,) -> Any: ...
class GraphRunStream: # sync; AsyncGraphRunStream mirrors this API values: StreamChannel[dict]; updates: StreamChannel[dict]; custom: StreamChannel[Any] messages: StreamChannel[ChatModelStream]; subgraphs: StreamChannel[SubgraphRunStream] lifecycle: StreamChannel[LifecyclePayload] @property def output(self) -> dict | None: ... @property def interrupted(self) -> bool: ... def abort(self) -> None: ... def interleave(self, *names: str) -> Iterator[tuple[str, Any]]: ...ValuesTransformer → run.values and MessagesTransformer → run.messages are
the only two always-registered native transformers along with LifecycleTransformer
→ run.lifecycle and SubgraphTransformer → run.subgraphs. Everything else is
opt-in — pass the transformer class (not an instance — the mux calls
factory(scope) itself, once per subgraph namespace) via compile(transformers=[...])
or stream_events(transformers=[...]):
| Transformer | Projection | Built-in? | Notes |
|---|---|---|---|
ValuesTransformer | run.values | yes (native, always on) | Full state snapshot per step; also tracks run.interrupted/run.interrupts. |
UpdatesTransformer | run.updates | opt-in | Per-node delta dict, {node_name: update}. |
CustomTransformer | run.custom | opt-in | Payloads written via get_stream_writer() inside nodes. |
MessagesTransformer | run.messages | yes (native, always on) | Yields one ChatModelStream/AsyncChatModelStream handle per LLM call, pushed at message-start; iterate .text/.reasoning/.tool_calls/.output while the model is still generating. Only populated for models actually invoked with v3 streaming — a plain AIMessage state write produces values snapshots, not token events. |
CheckpointsTransformer | run.checkpoints | opt-in | Requires a checkpointer; needs stream_mode to include "checkpoints" on v1/v2. |
DebugTransformer | run.debug | opt-in | Surfaces stream_mode="debug" events ("task"/"task_result"/"checkpoint"). |
TasksTransformer | run.tasks | opt-in | Surfaces stream_mode="tasks" events. |
LifecycleTransformer | run.lifecycle | yes (native, built into v3) | LifecyclePayload dicts (event, namespace, graph_name, trigger_call_id, cause, error) for every child subgraph/@task namespace start/finish strictly below the transformer’s own scope; the root namespace itself is never reported. |
SubgraphTransformer | run.subgraphs | yes (native, built into v3) | Discovers direct child subgraphs only (len(ns) == len(scope)+1); each handle wraps its own scoped mini-mux and exposes .values/.messages/.lifecycle/.subgraphs recursively for grandchildren. |
ToolCallTransformer | run.tool_calls | opt-in (langgraph.prebuilt._tool_call_transformer) | See Tools & Tool Calling. |
run.output / .interrupted / .interrupts drive the run to completion and return
the result; run.abort() stops early and is idempotent; run.interleave(*names)
merges multiple projections into one (name, item) stream in strict arrival order
(a monotonic push-stamp, not round-robin — ainterleave for async). Each projection
is a single-consumer, pump-driven queue: iterating it twice raises RuntimeError;
use projection.tee(n)/.atee(n) for real fan-out to multiple consumers sharing
one buffer. SubgraphRunStream handles have wire_pump=False — iterating them
silently drives the root graph, not just the subgraph. Use the with/async with
form so abort()/cleanup happens even on early exit.
Registration order matters for content-mutating transformers: before_builtins = True runs a transformer before MessagesTransformer/ToolCallTransformer, so
a PII-redaction transformer can rewrite message text before it’s snapshotted — but
take care not to mutate namespace/id/result/error/interrupts, which
LifecycleTransformer/SubgraphTransformer depend on for bookkeeping.
from langgraph.graph import StateGraph, START, END, MessagesStatefrom langgraph.checkpoint.memory import InMemorySaverfrom langchain_core.messages import HumanMessage, AIMessage
def chat(state: MessagesState) -> dict: return {"messages": [AIMessage(content="hi")]}
graph = StateGraph(MessagesState).add_node("chat", chat).add_edge(START, "chat").add_edge("chat", END).compile(checkpointer=InMemorySaver())cfg = {"configurable": {"thread_id": "t1"}}with graph.stream_events({"messages": [HumanMessage("hello")]}, cfg, version="v3") as run: for snapshot in run.values: print(snapshot["messages"][-1].content) print("final:", run.output)Writing a custom StreamTransformer (base class in langgraph.stream._types):
implement init() (return {key: StreamChannel(...)} — this becomes run.<key> if
_native = True, else run.extensions["<key>"]) and process(event) -> bool (return
False to suppress the event from the main log). Class vars: required_stream_modes
(which raw modes the mux must emit for you), before_builtins, requires_async/
schedule(coro) for async-only work fired from a sync process().
from langgraph.stream._types import StreamTransformer, ProtocolEventfrom langgraph.stream.stream_channel import StreamChannel
class EventCounter(StreamTransformer): _native = True def __init__(self, scope=()): super().__init__(scope) self._log: StreamChannel[int] = StreamChannel() self._n = 0 def init(self): return {"event_count": self._log} def process(self, event: ProtocolEvent) -> bool: self._n += 1 self._log.push(self._n) return True
with graph.stream_events({"messages": []}, version="v3", transformers=[EventCounter]) as run: for n in run.event_count: print("events so far:", n)StreamChannel[T] (langgraph.stream.stream_channel) is the drainable, single-
consumer queue backing every projection — a second iter()/aiter() on the same
channel raises RuntimeError (sync/async mode is locked in on first use); use
.tee(n)/.atee(n) to fan out to multiple independent consumers sharing one
buffer. Items are pump-driven — the channel does not eagerly buffer everything up
front; iterating pulls the next item only on demand. StreamMux
(langgraph.stream._mux) is the central dispatcher: it assigns a monotonic seq
to every ProtocolEvent, routes it through all registered transformers, and
auto-forwards named StreamChannel pushes back into the main event log;
_make_child(scope) builds a scoped mini-mux for a nested subgraph (propagated
only from transformer factories, not pre-built instances). ProtocolEvent is
the universal envelope: {"type": "event", "seq": int, "method": StreamMode, "params": {"namespace": [...], "timestamp": ..., "data": ..., "interrupts": [...]}}
— seq (root-mux-only, monotonic) is the reliable ordering key, not timestamp
(wall-clock, can go backwards).
Internals (private, unverified-stability): StreamMessagesHandler /
StreamMessagesHandlerV2 (langgraph.pregel._messages) are the LangChain callback
handlers that actually power stream_mode="messages" (dedup by message id,
run_inline=True for ordering); _RemoteGraphRunStream / _ChannelProjection /
_ProjectionRegistry (langgraph.pregel._remote_run_stream) adapt RemoteGraph’s SDK
events to the same GraphRunStream surface; _SubgraphRunStreamMixin
(langgraph.stream.run_stream) carries path/graph_name/trigger_call_id/status
metadata for subgraph handles and delegates pump calls to the parent.
Store & Memory
Section titled “Store & Memory”BaseStore, InMemoryStore
Section titled “BaseStore, InMemoryStore”Modules: langgraph.store.base (BaseStore), langgraph.store.memory (InMemoryStore)
Cross-thread, cross-run key-value (optionally vector-searchable) memory abstraction
— distinct from a checkpointer, which is scoped to one thread_id. Items live under
hierarchical namespace tuples (like a folder path) plus a key. BaseStore has
exactly two abstract methods — batch(ops) and abatch(ops) — every
convenience method (get, put, search, delete, list_namespaces, and their
async twins) delegates to one of them, so a custom store adapter only needs to
implement those two. Namespaces must be non-empty tuples of non-empty strings with
no dots, and the root segment must not be "langgraph" (violations raise
InvalidNamespaceError).
InMemoryStore is the built-in implementation, backing _data[namespace][key] -> Item and, when configured, _vectors[namespace][key][field_path] for
cosine-similarity search. Without index=, search() only supports filter=
(exact-match); with it, both filter= and semantic query= work, and results are
SearchItems carrying a cosine-similarity .score. put(namespace, key, value, index=...): index=False skips embedding for that item; index=["field.path"]
embeds only those JSON paths. Sync batch() embeds queries via a
ThreadPoolExecutor so it never blocks an async caller from a sync path;
abatch() uses asyncio.gather. InMemoryStore.supports_ttl is False — TTL
requires an adapter that opts in (e.g. PostgresStore); passing ttl= on
InMemoryStore raises NotImplementedError.
class BaseStore(ABC): supports_ttl: bool = False @abstractmethod def batch(self, ops: Iterable[Op]) -> list[Result]: ... @abstractmethod async def abatch(self, ops: Iterable[Op]) -> list[Result]: ...
def __init__(self, *, index: IndexConfig | None = None) -> None: ...from langgraph.store.memory import InMemoryStore
store = InMemoryStore()store.put(("users", "alice"), "preferences", {"theme": "dark"})item = store.get(("users", "alice"), "preferences")print(item.value, item.namespace, item.key, item.created_at)results = store.search(("users",), filter={"theme": "dark"})print(store.list_namespaces(prefix=("users",)))store.delete(("users", "alice"), "preferences")
# Semantic search — embed can be a plain sync/async callable, no LangChain neededdef embed(texts: list[str]) -> list[list[float]]: return [[float(len(t))] for t in texts]
sstore = InMemoryStore(index={"dims": 1, "embed": embed, "fields": ["text"]})sstore.put(("docs",), "py", {"text": "Python programming guide"})for hit in sstore.search(("docs",), query="python scripting", limit=2): print(hit.key, hit.score)Item / SearchItem
Section titled “Item / SearchItem”Module: langgraph.store.base
Item is what store.get() returns: value, key, namespace, created_at,
updated_at (both always timezone-aware datetimes). SearchItem extends it with
score: float | None (cosine similarity, present on semantic-search results,
None when not ranked). store.get() returns None for a missing key — always
check before accessing .value.
class Item: __slots__ = ("value", "key", "namespace", "created_at", "updated_at") def dict(self) -> dict: ...class SearchItem(Item): __slots__ = ("score",) # float | None — None when the search had no query, filter-onlyGetOp, PutOp, SearchOp, ListNamespacesOp, MatchCondition
Section titled “GetOp, PutOp, SearchOp, ListNamespacesOp, MatchCondition”Module: langgraph.store.base
The batch-operation protocol every store implements. SearchOp.filter supports
$eq/$ne/$gt/$gte/$lt/$lte (a bare value means $eq);
ListNamespacesOp.match_conditions uses MatchCondition(match_type="prefix" | "suffix", path=(...)), where "*" in the path matches one arbitrary segment.
class GetOp(NamedTuple): namespace: tuple[str, ...]; key: str; refresh_ttl: bool = Trueclass PutOp(NamedTuple): namespace: tuple[str, ...]; key: str value: dict | None # None => delete index: Literal[False] | list[str] | None = None ttl: float | None = None # minutes; requires supports_ttlclass SearchOp(NamedTuple): namespace_prefix: tuple[str, ...] filter: dict | None = None limit: int = 10; offset: int = 0 query: str | None = None refresh_ttl: bool = Trueclass MatchCondition(NamedTuple): match_type: Literal["prefix", "suffix"]; path: tuple[str | Literal["*"], ...]class ListNamespacesOp(NamedTuple): match_conditions: tuple[MatchCondition, ...] | None = None max_depth: int | None = None; limit: int = 100; offset: int = 0from langgraph.store.base import GetOp, PutOp, ListNamespacesOp, MatchConditionfrom langgraph.store.memory import InMemoryStore
store = InMemoryStore()store.batch([PutOp(("counters",), "views", {"count": 42})])(get_result,) = store.batch([GetOp(("counters",), "views")])print(get_result.value)
(namespaces,) = store.batch([ ListNamespacesOp(match_conditions=(MatchCondition(match_type="prefix", path=("counters",)),))])print(namespaces)IndexConfig + TTLConfig
Section titled “IndexConfig + TTLConfig”Module: langgraph.store.base
The two config TypedDicts that unlock vector search and expiry on InMemoryStore /
PostgresStore. fields supports a small JSON-path syntax (["$"] embeds the whole
document — the default; ["a.b"] nested; ["items[*].x"] per-array-element;
["{a,b}"] multi-field). Per-item overrides: store.put(..., index=["field"]) or
index=False to skip indexing that one item. TTLConfig has four fields —
omit_expired is easy to miss in older write-ups.
class IndexConfig(TypedDict, total=False): dims: int; embed: Embeddings | EmbeddingsFunc | AEmbeddingsFunc | str; fields: list[str]class TTLConfig(TypedDict, total=False): refresh_on_read: bool # default True omit_expired: bool # default False — exclude expired items from reads/lists default_ttl: float | None # minutes; None = never sweep_interval_minutes: int | Nonefrom langgraph.store.memory import InMemoryStore
store = InMemoryStore( index={"dims": 16, "embed": my_embed, "fields": ["title", "chapters[*].content"]},)EmbeddingsLambda + ensure_embeddings + get_text_at_path / tokenize_path
Section titled “EmbeddingsLambda + ensure_embeddings + get_text_at_path / tokenize_path”Module: langgraph.store.base.embed
ensure_embeddings() normalises whatever you pass as index["embed"] — a LangChain
Embeddings instance, a plain sync/async callable list[str] -> list[list[float]],
or a "provider:model" string (dispatched to langchain.embeddings.init_embeddings,
requires langchain>=0.3.9) — into a LangChain Embeddings instance, wrapping bare
callables in EmbeddingsLambda. get_text_at_path/tokenize_path implement the
fields JSON-path syntax used to pick which parts of a stored value get embedded:
dot paths ("a.b"), array indexing ("[0]", "[*]", "[-1]"), multi-field
selection ("{a,b.c}"), and "$" for the whole object serialized as JSON.
def ensure_embeddings(embed: Embeddings | EmbeddingsFunc | AEmbeddingsFunc | str | None) -> Embeddingsclass EmbeddingsLambda(Embeddings): def __init__(self, func: EmbeddingsFunc | AEmbeddingsFunc) -> None: ...def get_text_at_path(obj: Any, path: str | list[str]) -> list[str]def tokenize_path(path: str) -> list[str]from langgraph.store.base.embed import get_text_at_path
doc = {"title": "Guide", "sections": [{"heading": "Intro", "body": "..."}]}print(get_text_at_path(doc, "sections[*].heading")) # ['Intro']print(get_text_at_path(doc, "{title,sections[*].heading}"))AsyncBatchedBaseStore
Section titled “AsyncBatchedBaseStore”Module: langgraph.store.base.batch
Base class for production async store adapters (Redis, Postgres, …) accessed from
inside a running event loop. Instead of one round-trip per aget/aput/asearch,
it queues ops on an asyncio.Queue drained by a background task that batches
everything queued in the same tick into one abatch() call, deduplicating repeated
reads and collapsing consecutive puts to the same key. A @_check_loop guard on
the sync methods raises asyncio.InvalidStateError if you call them from the same
event loop the store’s background task owns (that would deadlock) — always use
the async variants (aget, aput, …) inside async code. InMemoryStore does
not inherit from this — it’s a plain BaseStore — so use
AsyncBatchedBaseStore as the base only for stores whose backend genuinely
benefits from request coalescing (e.g. a store wrapping a single shared HTTP/DB
connection).
class AsyncBatchedBaseStore(BaseStore): def __init__(self) -> None: ... # starts a background asyncio.Task drainerimport asyncioasync def safe(): store = MyAsyncBatchedStore() await store.aput(("ns",), "key1", {"data": 1}) # correct item = await store.aget(("ns",), "key1") # store.get(("ns",), "key1") # WRONG inside this loop — deadlocksasyncio.run(safe())PostgresStore / AsyncPostgresStore / PoolConfig / ANNIndexConfig / HNSWConfig / IVFFlatConfig / PostgresIndexConfig
Section titled “PostgresStore / AsyncPostgresStore / PoolConfig / ANNIndexConfig / HNSWConfig / IVFFlatConfig / PostgresIndexConfig”Module: langgraph.store.postgres and .base — separate package
langgraph-checkpoint-postgres, not installed in the verification venv; not
independently re-verified here.
Durable, shared-across-threads key-value storage in Postgres with optional pgvector
ANN indexing. PostgresIndexConfig extends IndexConfig with distance_type and
ann_index_config, which is either HNSWConfig (proximity graph — best default,
consistent high recall, use when the dataset changes frequently) or IVFFlatConfig
(cluster-based — faster to build, better for very large, mostly-static datasets;
build the index after bulk-loading data). PoolConfig (min_size, max_size,
kwargs) configures the underlying psycopg_pool.ConnectionPool.
class PoolConfig(TypedDict, total=False): min_size: int; max_size: int | None; kwargs: dictclass HNSWConfig(ANNIndexConfig, total=False): kind: Literal["hnsw"]; m: int; ef_construction: intclass IVFFlatConfig(ANNIndexConfig, total=False): kind: Literal["ivfflat"]; nlist: intfrom langgraph.store.postgres import PostgresStorefrom langgraph.store.postgres.base import PostgresIndexConfig, HNSWConfig
index: PostgresIndexConfig = { "dims": 1536, "embed": my_embed, "fields": ["text"], "distance_type": "cosine", "ann_index_config": HNSWConfig(kind="hnsw", m=16, ef_construction=64),}with PostgresStore.from_conn_string(DB_URI, index=index) as store: store.setup()Tools & Tool Calling
Section titled “Tools & Tool Calling”ToolNode — full API
Section titled “ToolNode — full API”Module: langgraph.prebuilt.tool_node (re-exported from langgraph.prebuilt)
Executes every tool call found in the last AIMessage, in parallel where possible.
def __init__( self, tools: Sequence[BaseTool | Callable], *, name: str = "tools", tags: list[str] | None = None, handle_tool_errors: bool | str | Callable[..., str] | type[Exception] | tuple[type[Exception], ...] = _default_handle_tool_errors, messages_key: str = "messages", wrap_tool_call: ToolCallWrapper | None = None, awrap_tool_call: AsyncToolCallWrapper | None = None,) -> None: ...handle_tool_errors has five forms, verified: True (default-style template), a
fixed str (used verbatim), an exception type or tuple[type, ...] filter, or a
Callable[[Exception], str] formatter; False disables catching entirely (errors
propagate to the graph’s error_handler, if any). The actual default is not the
literal True — it’s the function _default_handle_tool_errors, which always
catches argument-validation errors (ToolInvocationError) but re-raises genuine
tool-execution errors, so the model can self-correct on bad arguments while a real
bug still surfaces. messages_key lets ToolNode read/write a differently-named
state field.
from langchain_core.tools import toolfrom langgraph.prebuilt import ToolNodefrom langchain_core.messages import AIMessage
@tooldef divide(a: float, b: float) -> float: """Divide a by b.""" if b == 0: raise ValueError("Division by zero") return a / b
node = ToolNode([divide], handle_tool_errors="Tool failed; try different inputs.")call = {"name": "divide", "args": {"a": 1.0, "b": 0.0}, "id": "c1", "type": "tool_call"}result = node.invoke({"messages": [AIMessage(content="", tool_calls=[call])]})print(result["messages"][0].content) # Tool failed; try different inputs.wrap_tool_call middleware — ToolCallRequest, ToolCallWrapper, ToolInvocationError
Section titled “wrap_tool_call middleware — ToolCallRequest, ToolCallWrapper, ToolInvocationError”Module: langgraph.prebuilt.tool_node
wrap_tool_call (sync) / awrap_tool_call (async) is middleware around every tool
invocation — sanitise args, rate-limit, retry, cache, or short-circuit — via a
(request: ToolCallRequest, execute) -> ToolMessage | Command interceptor that may
call execute(request) zero, one, or many times.
@dataclassclass ToolCallRequest: tool_call: ToolCall # {"name", "args", "id", "type"} tool: BaseTool | None # None if the model named an unregistered tool state: Any runtime: ToolRuntime
def override(self, **overrides) -> "ToolCallRequest": ... # dataclasses.replace; immutable-update pattern
ToolCallWrapper = Callable[[ToolCallRequest, Callable[[ToolCallRequest], ToolMessage | Command]], ToolMessage | Command]- Direct attribute assignment on
ToolCallRequestemits aDeprecationWarning— always go through.override(tool_call=..., state=...), which returns a new instance, so interceptors never corrupt shared state across parallel tool calls. request.tool is Nonewhen the model hallucinated a tool name;execute()still returns an invalid-toolToolMessagerather than raising, so branch onrequest.tool is Noneexplicitly if you need different handling.ToolInvocationError(aToolExceptionsubclass, raised internally on bad tool-call arguments) filters its message down to only the parameters the LLM actually controls — injected parameters (InjectedState/InjectedStore/ToolRuntime) are excluded, so the model isn’t confused by validation errors on arguments it never supplied.ToolNodecatches it by default and turns it into an errorToolMessage— overridehandle_tool_errorsto change that behaviour.
class ToolInvocationError(ToolException): message: str; tool_name: str; tool_kwargs: dict; source: ValidationErrorfrom typing import Callablefrom langchain_core.tools import toolfrom langchain_core.messages import AIMessage, ToolMessagefrom langgraph.prebuilt import ToolNodefrom langgraph.prebuilt.tool_node import ToolCallRequest
@tooldef set_volume(value: int) -> str: """Set the volume level.""" return f"Volume set to {value}"
def clamp_value(request: ToolCallRequest, execute: Callable) -> ToolMessage: args = dict(request.tool_call["args"]) args["value"] = max(0, min(100, args.get("value", 0))) return execute(request.override(tool_call={**request.tool_call, "args": args}))
node = ToolNode([set_volume], wrap_tool_call=clamp_value)call = {"name": "set_volume", "args": {"value": 150}, "id": "v1", "type": "tool_call"}result = node.invoke({"messages": [AIMessage(content="", tool_calls=[call])]})print(result["messages"][0].content) # Volume set to 100InjectedState, InjectedStore, ToolRuntime
Section titled “InjectedState, InjectedStore, ToolRuntime”Module: langgraph.prebuilt (re-exports langgraph.prebuilt.tool_node)
Three annotation mechanisms that hide framework-supplied parameters from the
model’s tool schema entirely, then fill them in at execution time. InjectedState
and InjectedStore are InjectedToolArg subclasses from langchain_core, so
annotate a parameter with them explicitly; ToolRuntime needs no Annotated
wrapper — a parameter simply typed runtime: ToolRuntime is matched by name+type
and auto-injected, bundling everything the other two (plus get_stream_writer())
used to require separately.
class InjectedState(InjectedToolArg): def __init__(self, field: str | None = None) -> None: ... # None => inject the whole state dict; else state[field]class InjectedStore(InjectedToolArg): ... # injects the compiled graph's BaseStore
@dataclassclass ToolRuntime(Generic[ContextT, StateT]): state: StateT; context: ContextT; config: RunnableConfig stream_writer: StreamWriter; tool_call_id: str | None store: BaseStore | None; tools: list[BaseTool] execution_info: ExecutionInfo | None; server_info: ServerInfo | None def emit_output_delta(self, delta: Any) -> None: ... # partial output on the "tools" channel; silent no-op if not streamingexecution_info gives you checkpoint_id/task_id/thread_id/node_attempt
(1-indexed, increments on retry) for observability inside a tool; server_info is
populated only under LangGraph Server/Platform deployments (None locally).
from typing import Annotatedfrom typing_extensions import TypedDictfrom langchain_core.tools import toolfrom langgraph.prebuilt import InjectedState, InjectedStore, ToolNodefrom langchain_core.messages import AIMessage
class AppState(TypedDict): messages: list user_name: str
@tooldef greet(greeting: str, name: Annotated[str, InjectedState("user_name")]) -> str: """Greet the user; name is injected from state, invisible to the model.""" return f"{greeting}, {name}!"
node = ToolNode([greet])call = {"name": "greet", "args": {"greeting": "Hello"}, "id": "g1", "type": "tool_call"}result = node.invoke({"messages": [AIMessage(content="", tool_calls=[call])], "user_name": "Alice"})print(result["messages"][0].content) # Hello, Alice!from langchain_core.tools import toolfrom langgraph.prebuilt.tool_node import ToolRuntime
@tooldef analyse(query: str, runtime: ToolRuntime) -> str: """Analyse a query, streaming progress.""" for step in ["planning", "searching", "synthesising"]: runtime.emit_output_delta({"step": step}) return f"Complete analysis of: {query}"ToolCallWithContext
Section titled “ToolCallWithContext”Module: langgraph.prebuilt.tool_node. The internal TypedDict payload used
to dispatch a tool call in parallel via Send, while still carrying the state
snapshot at dispatch time: {"tool_call": ToolCall, "__type": "tool_call_with_context", "state": Any}. ToolNode recognizes the "__type" discriminator (double-underscore
to avoid colliding with any user state key literally named type) and populates
ToolRuntime.state from the "state" field. This is what enables independently
interruptible, parallel per-tool-call execution in ReAct-style graphs.
tools_condition
Section titled “tools_condition”Module: langgraph.prebuilt.tool_node (re-exported from langgraph.prebuilt)
The standard conditional-edge function for tool-calling loops. Inspects the last
message: an AIMessage with non-empty .tool_calls routes to "tools", otherwise
"__end__". Accepts a bare message list, a state dict (reads
state[messages_key]), or a Pydantic BaseModel (reads
getattr(state, messages_key)); pass messages_key= if your state doesn’t use the
default "messages" field.
def tools_condition( state: list[AnyMessage] | dict[str, Any] | BaseModel, messages_key: str = "messages",) -> Literal["tools", "__end__"]: ...from typing import Annotatedfrom typing_extensions import TypedDictfrom langchain_core.messages import BaseMessagefrom langgraph.graph import StateGraph, START, ENDfrom langgraph.graph.message import add_messagesfrom langgraph.prebuilt import ToolNode, tools_condition
class State(TypedDict): messages: Annotated[list[BaseMessage], add_messages]
builder = StateGraph(State)builder.add_node("model", lambda s: s) # stand-in for a real LLM callbuilder.add_node("tools", ToolNode([]))builder.add_edge(START, "model")builder.add_conditional_edges("model", tools_condition) # -> "tools" or ENDbuilder.add_edge("tools", "model")ToolCallTransformer + ToolCallStream (stream_mode="tools")
Section titled “ToolCallTransformer + ToolCallStream (stream_mode="tools")”Modules: langgraph.prebuilt._tool_call_transformer / ._tool_call_stream
(re-exported: ToolCallTransformer from langgraph.prebuilt)
An opt-in _native = True transformer (required_stream_modes = ("tools",)) that
converts raw "tools"-channel protocol events into a live handle per tool call,
exposed on run.tool_calls — register at compile time
(compile(transformers=[ToolCallTransformer])), then iterate run.tool_calls
while streaming with stream_mode="tools". Under the hood this is powered by
StreamToolCallHandler (langgraph.pregel._tools, private) which fires
tool-started/tool-output-delta/tool-finished/tool-error events; tag a tool
with TAG_NOSTREAM to suppress its events entirely. process() always returns
True, so raw "tools" channel events still flow to any other consumer alongside
the per-call handles.
class ToolCallStream: tool_call_id: str; tool_name: str; input: dict | None output: Any # set on tool-finished error: str | None # set on tool-error completed: bool output_deltas: StreamChannel[Any] # iterate sync (`for`) or async (`async for`)from langgraph.prebuilt import ToolNode, ToolCallTransformergraph = builder.compile(transformers=[ToolCallTransformer])async with graph.astream({"messages": []}, stream_mode="tools", version="v2") as run: async for tc in run.tool_calls: print("started:", tc.tool_name, tc.input) async for delta in tc.output_deltas: print(" delta:", delta) print("final:", tc.output)create_react_agent — and the AgentState / ValidationNode migration
Section titled “create_react_agent — and the AgentState / ValidationNode migration”Module: langgraph.prebuilt.chat_agent_executor / .tool_validator
create_react_agent compiles a "agent" + "tools" ReAct loop. It is
deprecated per official LangGraph v1 migration docs in favour of
langchain.agents.create_agent (from the separate langchain package; install
with pip install langchain). The installed langgraph==1.2.11 source does not
carry a runtime @deprecated decorator, but official migration guidance is clear.
pre_model_hook/post_model_hook hooks remain available on create_react_agent
for users working with langgraph standalone.
def create_react_agent( model, tools, *, prompt=None, response_format=None, pre_model_hook=None, post_model_hook=None, state_schema=None, context_schema=None, checkpointer=None, store=None, interrupt_before=None, interrupt_after=None, debug=False, version: Literal["v1", "v2"] = "v2", name=None,) -> CompiledStateGraph: ...pre_model_hook(state) -> dict | Nonecan return{"llm_input_messages": [...]}to give the model a trimmed/annotated view for this call only, without mutating the persistedmessagesfield (it bypasses theadd_messagesreducer).post_model_hook(state) -> dict | Command | Noneruns after the LLM call; useful for token-budget enforcement. If it returnsCommand(goto="__end__")while the lastAIMessagestill carries pendingtool_calls, the prebuilt router will still try to schedule them unless you also strip/replace the message so the router sees no tool calls.response_format=adds agenerate_structured_responsenode making a separate model call viawith_structured_output()after the loop ends — extra latency/cost, result lands instate["structured_response"].version="v2"(default) dispatches each tool call as an independentSendtask so one tool’s failure doesn’t block sibling calls;"v1"runs them together in oneToolNodeinvocation.AgentState(the defaultstate_schema) is{"messages": Annotated[Sequence[BaseMessage], add_messages], "remaining_steps": NotRequired[RemainingSteps]}; a customstate_schemamust include aremaining_stepsfield orcreate_react_agentraisesValueError.AgentState,AgentStatePydantic,AgentStateWithStructuredResponseare deprecated and no longer re-exported fromlanggraph.prebuilt.__init__, but remain importable fromlanggraph.prebuilt.chat_agent_executor. Migrate toresponse_format=instead of a custom structured-response state class, or a hand-writtenTypedDict/Pydantic model with the same two fields for a customstate_schema.ValidationNode(schema-only tool-argument validator, no execution) is also deprecated (since v1.0, planned removal in v2.0.0) but still importable fromlanggraph.prebuilt.tool_validator. There is no drop-in schema-only replacement.ToolNodeexecutes valid tool calls after schema validation — callers that relied onValidationNodeto validate without side effects cannot useToolNodeas a direct substitute. Two migration paths:- Tool execution is acceptable: use
ToolNode(handle_tool_errors=True)with a pydanticargs_schemaon each tool; argument-validation failures surface asToolMessageerrors that prompt the LLM to retry. - Schema-only, no execution: write a custom node that calls
tool.args_schema.model_validate(tc["args"])for each tool call and returns aToolMessagewith the validation error on failure, without invoking the tool.
- Tool execution is acceptable: use
Correction vs. some older write-ups: from langgraph.prebuilt import AgentState does not merely warn — it raises ImportError in the installed
1.2.11 (AgentState isn’t re-exported from the langgraph.prebuilt package
__init__ at all). Import it from langgraph.prebuilt.chat_agent_executor
instead.
from typing import Annotated, Sequence, NotRequiredfrom typing_extensions import TypedDictfrom langchain_core.messages import BaseMessagefrom langgraph.graph.message import add_messagesfrom langgraph.managed.is_last_step import RemainingStepsfrom langgraph.prebuilt import create_react_agent
class MyAgentState(TypedDict): messages: Annotated[Sequence[BaseMessage], add_messages] remaining_steps: NotRequired[RemainingSteps]
agent = create_react_agent(model, tools=[], state_schema=MyAgentState) # no deprecation warning# Modern equivalent of ValidationNode — pydantic args_schema + ToolNode.handle_tool_errorsfrom langchain_core.tools import toolfrom langgraph.prebuilt import ToolNodefrom pydantic import BaseModel
class SearchParams(BaseModel): query: str; max_results: int = 10
@tool(args_schema=SearchParams)def search(query: str, max_results: int = 10) -> str: """Search the web.""" return f"Found {max_results} results"
tool_node = ToolNode([search], handle_tool_errors=True)ToolOutputMixin
Section titled “ToolOutputMixin”Module: re-exported from langgraph.types; actually defined in
langchain_core.messages.tool (some older write-ups place it in
langgraph.prebuilt.tool_node — that location is wrong, it never lived there).
An empty marker mixin. When a BaseTool returns an object, ToolNode uses it (not
its string coercion) directly only if it’s a ToolMessage, a Command, or a list of
those — ToolMessage and Command both inherit ToolOutputMixin. Anything else is
str()-coerced and wrapped in a fresh ToolMessage. Subclass it yourself to
future-proof a custom structured tool-return type.
from langgraph.types import ToolOutputMixin # works: re-exported
class RichToolResult(ToolOutputMixin): def __init__(self, content: str, metadata: dict): self.content = content; self.metadata = metadata def __str__(self) -> str: return self.contentPregel.as_tool() (beta)
Section titled “Pregel.as_tool() (beta)”Module: langgraph.pregel.main (delegates to
langchain_core.tools.convert_runnable_to_tool). Wraps any compiled graph as a
BaseTool.
def as_tool( self, args_schema: type[BaseModel] | None = None, *, name: str | None = None, description: str | None = None, arg_types: dict[str, type] | None = None,) -> BaseTool: ...Schema is inferred from the graph’s TypedDict/Pydantic input schema when
possible; use arg_types={"key": type} to expose only a subset of state keys as
tool arguments (avoids surfacing output-only fields as required inputs the model
must fill in). The returned tool’s .invoke() calls graph.invoke().
from typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, END
class SumState(TypedDict): a: int; b: int; result: int
graph = StateGraph(SumState)graph.add_node("add", lambda s: {"result": s["a"] + s["b"]})graph.add_edge(START, "add"); graph.add_edge("add", END)compiled = graph.compile()
tool = compiled.as_tool(name="sum_graph", description="Add two integers.", arg_types={"a": int, "b": int})print(tool.invoke({"a": 3, "b": 4})) # {'a': 3, 'b': 4, 'result': 7}Internals (private, unverified-stability): _IdleProgressCallbackHandler
(langgraph.pregel._retry) is the callback handler that resets a node’s idle-timeout
clock on every LangChain event (LLM token, tool start/end, retriever hit, …) when
TimeoutPolicy(refresh_on="auto") is in effect.
Section titled “Internals (private, unverified-stability): _IdleProgressCallbackHandler
(langgraph.pregel._retry) is the callback handler that resets a node’s idle-timeout
clock on every LangChain event (LLM token, tool start/end, retriever hit, …) when
TimeoutPolicy(refresh_on="auto") is in effect.”Caching
Section titled “Caching”CachePolicy + CacheKey
Section titled “CachePolicy + CacheKey”Module: langgraph.types
CachePolicy memoises a node’s or @task’s return value keyed on its input.
key_func defaults to a pickle-hash of the full input — supply your own to
normalise (case-fold, strip irrelevant fields, namespace by user) or to compute a
deterministic key. Both a graph-level cache backend (compile(cache=...) /
@entrypoint(cache=...)) and a per-node/task cache_policy= are required
together — either one alone silently does nothing.
@dataclass(frozen=True)class CachePolicy(Generic[KeyFuncT]): key_func: KeyFuncT = default_cache_key # (input) -> str|bytes; default: pickle hash ttl: int | None = None # seconds; None = never expires
class CacheKey(NamedTuple): ns: tuple[str, ...]; key: str; ttl: int | Nonefrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import CachePolicyfrom langgraph.cache.memory import InMemoryCachefrom typing_extensions import TypedDict
class State(TypedDict): query: str; result: str
call_log = []def expensive(state: State) -> dict: call_log.append(state["query"]) return {"result": f"answer:{state['query']}"}
def query_only_key(node_input: dict) -> str: return node_input.get("query", "")
builder = StateGraph(State)builder.add_node("expensive", expensive, cache_policy=CachePolicy(key_func=query_only_key, ttl=60))builder.add_edge(START, "expensive"); builder.add_edge("expensive", END)graph = builder.compile(cache=InMemoryCache())graph.invoke({"query": "foo", "result": ""})graph.invoke({"query": "foo", "result": ""}) # served from cacheassert len(call_log) == 1BaseCache, InMemoryCache
Section titled “BaseCache, InMemoryCache”Module: langgraph.cache.base / langgraph.cache.memory
class BaseCache(ABC, Generic[ValueT]): serde: SerializerProtocol = JsonPlusSerializer(pickle_fallback=False) def get(self, keys: Sequence[FullKey]) -> dict[FullKey, ValueT]: ... def set(self, pairs: Mapping[FullKey, tuple[ValueT, int | None]]) -> None: ... def clear(self, namespaces: Sequence[Namespace] | None = None) -> None: ... # + async twins aget/aset/aclear
class InMemoryCache(BaseCache): def __init__(self, *, serde: SerializerProtocol | None = None) -> None: ...FullKey = tuple[Namespace, str], Namespace = tuple[str, ...] — namespacing
gives per-task, per-user cache isolation without separate cache instances.
InMemoryCache protects its dict with a threading.RLock; TTL expiry is stored as
an absolute expiry timestamp on set() and checked lazily on get() (no
background sweep). clear(namespaces=[ns]) evicts one namespace; clear() with
no args wipes everything.
import timefrom langgraph.cache.memory import InMemoryCache
cache = InMemoryCache()ns = ("pipeline", "step1")cache.set({(ns, "run_42"): ({"result": 99}, 2)}) # 2-second TTLprint(cache.get([(ns, "run_42")])) # {(('pipeline', 'step1'), 'run_42'): {'result': 99}}time.sleep(2.1)print(cache.get([(ns, "run_42")])) # {} — expired, lazily evictedRedisCache
Section titled “RedisCache”Module: langgraph.cache.redis. Install: pip install redis.
Drop-in Redis-backed replacement for InMemoryCache — uses MGET/pipelined
SET/SETEX for efficient batch reads/writes. When Redis is unreachable, operations
silently no-op (graph still runs correctly, just without caching). Keys are namespaced
{prefix}{ns1}:{ns2}:...:{cache_key}; default serde is JsonPlusSerializer() — wrap
it in EncryptedSerializer to encrypt cached values at rest.
RedisCache(redis: Any, *, serde: SerializerProtocol | None = None, prefix: str = "langgraph:cache:")import redisfrom langgraph.cache.redis import RedisCache
cache = RedisCache(redis.Redis(host="localhost", port=6379), prefix="myapp:")graph = builder.compile(cache=cache)clear_cache() / aclear_cache() — graph-level and task-level
Section titled “clear_cache() / aclear_cache() — graph-level and task-level”Modules: langgraph.pregel.main (methods on CompiledStateGraph),
langgraph.func (methods on the _TaskFunction a @task-decorated function is
wrapped in)
compiled_graph.clear_cache(nodes: Sequence[str] | None = None) / await ...aclear_cache(...) invalidates cached node results for a graph (or specific
nodes) — but not @task caches, which live in a separate namespace; clear
those independently with my_task.clear_cache(cache) / await my_task.aclear_cache(cache). Calling clear_cache() on a graph compiled without
cache= raises ValueError. A task’s cache namespace is ("langgraph", "cache", "writes", "<function identifier>"); a lambda or dynamic function with no stable
__qualname__ falls back to "__dynamic__".
def clear_cache(self, cache: BaseCache) -> None: ... # on a @task functionasync def aclear_cache(self, cache: BaseCache) -> None: ...from langgraph.func import entrypoint, taskfrom langgraph.types import CachePolicyfrom langgraph.cache.memory import InMemoryCache
cache = InMemoryCache()
@task(cache_policy=CachePolicy(ttl=3600))def fetch_data(key: str) -> str: return f"data_for_{key}"
@entrypoint(cache=cache)def pipeline(key: str) -> str: return fetch_data(key).result()
pipeline.invoke("k1")fetch_data.clear_cache(cache) # next call for "k1" re-executes fetch_data onlyError Handling, Retry & Timeout Policies
Section titled “Error Handling, Retry & Timeout Policies”RetryPolicy (single and chained)
Section titled “RetryPolicy (single and chained)”Module: langgraph.types
Controls node/task retry behaviour with exponential backoff. add_node(..., retry_policy=...) and @task(retry_policy=...) accept either one RetryPolicy or a
list — LangGraph tries each in order and uses the first whose retry_on predicate
matches the raised exception (no match → the exception propagates immediately). This
lets you give rate-limit errors a slow, patient policy and network blips a fast one,
without a custom callable.
class RetryPolicy(NamedTuple): initial_interval: float = 0.5 backoff_factor: float = 2.0 max_interval: float = 128.0 max_attempts: int = 3 jitter: bool = True retry_on: type[Exception] | Sequence[type[Exception]] | Callable[[Exception], bool] = default_retry_onWait time per attempt is min(max_interval, initial_interval * backoff_factor ** (attempt - 1)), optionally jittered; max_attempts counts the total attempts
including the first try. default_retry_on is a blocklist strategy: always
retries ConnectionError and 5xx httpx.HTTPStatusError/requests.HTTPError;
never retries a fixed set of deterministic-failure types (ValueError,
TypeError, ArithmeticError, ImportError, LookupError, NameError,
SyntaxError, RuntimeError, ReferenceError, StopIteration,
StopAsyncIteration, OSError); retries everything else by default (so
new/unknown transient exception types from future SDK versions are retried
without any predicate update). Supply a Callable[[Exception], bool] for custom
logic, e.g. to also retry 429s or to never retry a specific application exception.
import httpxfrom langgraph.types import RetryPolicyfrom langgraph.graph import StateGraph, START, ENDfrom typing_extensions import TypedDict
class State(TypedDict): result: str
rate_limit_policy = RetryPolicy(initial_interval=5.0, max_attempts=6, retry_on=lambda e: isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 429)network_policy = RetryPolicy(initial_interval=0.5, max_attempts=3, retry_on=lambda e: isinstance(e, httpx.TransportError))
builder = StateGraph(State)builder.add_node("fetch", my_fetch_fn, retry_policy=[rate_limit_policy, network_policy])builder.add_edge(START, "fetch"); builder.add_edge("fetch", END)graph = builder.compile()TimeoutPolicy (.coerce(), run_timeout vs idle_timeout)
Section titled “TimeoutPolicy (.coerce(), run_timeout vs idle_timeout)”Module: langgraph.types
Two independent clocks per node/task attempt: run_timeout (hard wall-clock cap,
never refreshed) and idle_timeout (max time without observable progress).
refresh_on="auto" (default) resets the idle clock on any LangChain callback
event (LLM token, tool call, …) and explicit runtime.heartbeat();
"heartbeat" restricts it to only explicit heartbeat() calls. A bare
float/timedelta passed anywhere a TimeoutPolicy is expected is coerced via
TimeoutPolicy.coerce() to TimeoutPolicy(run_timeout=value).
@dataclass(frozen=True)class TimeoutPolicy: run_timeout: float | timedelta | None = None idle_timeout: float | timedelta | None = None refresh_on: Literal["auto", "heartbeat"] = "auto" @classmethod def coerce(cls, value: float | timedelta | TimeoutPolicy | None) -> TimeoutPolicy | None: ...- Timeouts rely on
asynciocancellation — they only fire reliably onasync defnodes/tasks. A sync node/task with atimeout=raises at setup time (sync_timeout_unsupported) in the current version — Python cannot safely pre-empt a running sync function; onlyasyncio.Task.cancel()at anawaitpoint is a safe cancellation point. - Each retry attempt gets a fresh timeout clock — the timeout budget is per-attempt, not cumulative across retries.
import asynciofrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import TimeoutPolicyfrom langgraph.runtime import Runtimefrom typing_extensions import TypedDict
class State(TypedDict): processed: int
async def batch(state: State, runtime: Runtime) -> dict: for _ in range(10): await asyncio.sleep(0.05) runtime.heartbeat() # keeps idle clock alive return {"processed": 10}
builder = StateGraph(State)builder.add_node("batch", batch, timeout=TimeoutPolicy(idle_timeout=1.0, refresh_on="heartbeat"))builder.add_edge(START, "batch"); builder.add_edge("batch", END)graph = builder.compile()error_handler on add_node / set_node_defaults
Section titled “error_handler on add_node / set_node_defaults”Module: langgraph.graph.state
A per-node (or graph-wide via set_node_defaults(error_handler=...)) fallback node
that runs when the node exhausts its retries and still raises. Retries always run
first; the handler fires only after they’re exhausted. The handler function’s
second parameter is a NodeError (see below), not the raw exception; the handler
itself is never retried or caught if it too raises.
def error_handler(state: State, error: NodeError) -> dict | Command: ...builder.add_node("risky", risky_fn, retry_policy=RetryPolicy(max_attempts=2), error_handler=error_handler)from langgraph.errors import NodeErrorfrom langgraph.types import Command
def api_error_handler(state, error: NodeError) -> Command: return Command(update={"error_info": f"[{error.node}] {type(error.error).__name__}: {error.error}"})NodeError, NodeTimeoutError, NodeCancelledError, GraphDrained + the exception hierarchy
Section titled “NodeError, NodeTimeoutError, NodeCancelledError, GraphDrained + the exception hierarchy”Module: langgraph.errors
NodeError is a frozen dataclass, not an exception — (node: str, error: BaseException) — injected as the second parameter into error_handler functions.
NodeTimeoutError deliberately does not inherit from the built-in
TimeoutError (an OSError subclass excluded by default_retry_on) so the
default RetryPolicy retries a timed-out attempt automatically; carries node,
kind ("run" | "idle"), elapsed, run_timeout, idle_timeout.
NodeCancelledError wraps a user-raised asyncio.CancelledError so it surfaces
through the normal error path rather than a silent teardown (framework-initiated
cancellation of sibling tasks is left as plain CancelledError and silently torn
down instead). GraphDrained is raised when RunControl.request_drain()
completes cooperatively (checkpoint already saved — the run can be resumed with
the same thread_id).
Exception├── GraphBubbleUp — internal signalling; never catch in node code│ ├── GraphInterrupt — raised by interrupt()│ ├── ParentCommand — Command.PARENT bubbling through a subgraph│ └── GraphDrained — cooperative drain completed; resumable├── GraphRecursionError(RecursionError)├── InvalidUpdateError — concurrent conflicting channel write / bad node return├── EmptyInputError — graph invoked with empty input├── TaskNotFound — distributed-mode task lookup failure├── NodeCancelledError└── NodeTimeoutErrorclass NodeError: # frozen dataclass, not an Exception node: str; error: BaseExceptionclass NodeTimeoutError(Exception): # NOT a TimeoutError subclass node: str; kind: Literal["idle", "run"]; elapsed: float run_timeout: float | None; idle_timeout: float | Noneclass GraphDrained(Exception): # (technically GraphBubbleUp) reason: strfrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.errors import NodeError, GraphRecursionError, GraphDrained
class State(TypedDict): value: int; error_info: str
def risky(state: State) -> dict: if state["value"] < 0: raise ValueError(f"negative: {state['value']}") return {"value": state["value"] * 2}
def handler(state: State, error: NodeError) -> dict: return {"value": 0, "error_info": f"{error.node} failed: {error.error}"}
g = StateGraph(State)g.add_node("risky", risky, error_handler=handler)g.add_edge(START, "risky"); g.add_edge("risky", END)print(g.compile().invoke({"value": -1, "error_info": ""}))# {'value': 0, 'error_info': 'risky failed: negative: -1'}
try: graph.invoke({"counter": 0}, config={"recursion_limit": 5})except GraphRecursionError: print("hit recursion_limit — add a termination condition or raise the limit")except GraphDrained as e: print(f"drained cooperatively: {e.reason} — resume with the same thread_id")GraphRecursionError, InvalidUpdateError, EmptyInputError, ErrorCode
Section titled “GraphRecursionError, InvalidUpdateError, EmptyInputError, ErrorCode”Module: langgraph.errors. ErrorCode is an Enum with exactly 5 members,
verified: GRAPH_RECURSION_LIMIT, INVALID_CONCURRENT_GRAPH_UPDATE,
INVALID_GRAPH_NODE_RETURN_VALUE, MULTIPLE_SUBGRAPHS, INVALID_CHAT_HISTORY —
embedded in exception messages / server troubleshooting URLs, but the OSS
exception classes don’t expose it as an attribute, so branch on exception type,
not on an error code.
GraphRecursionErroris aRecursionErrorsubclass, raised when the Pregel loop hitsconfig["recursion_limit"](defaultDEFAULT_RECURSION_LIMIT, 10007 in this version — overridable via envLANGGRAPH_DEFAULT_RECURSION_LIMITor per-call viaconfig={"recursion_limit": N}). Fix by raising the limit, or short-circuiting with aRemainingSteps/IsLastStepmanaged value.InvalidUpdateErrorfires when a channel that only accepts one writer per step (LastValue, or a secondOverwritein the same step) receives two or more concurrent writes. It carries no structured.error_codeattribute — theErrorCodestring is embedded in the message only.EmptyInputErrorfires on the first invocation of a thread when no state and no prior checkpoint exists to seed it.
from langgraph.errors import GraphRecursionErrorfrom langgraph.graph import StateGraph, START
builder = StateGraph(dict)builder.add_node("loop", lambda s: {"n": s.get("n", 0) + 1})builder.add_edge(START, "loop"); builder.add_edge("loop", "loop")graph = builder.compile()try: graph.invoke({}, config={"recursion_limit": 5})except GraphRecursionError as e: print(f"Caught: {e}")GraphDrained, RunControl
Section titled “GraphDrained, RunControl”Module: langgraph.errors / langgraph.runtime. Cooperative-shutdown
primitives — distinct from a hard kill.
class RunControl: __slots__ = ("_drain_reason",) def request_drain(self, reason: str = "shutdown") -> None: ... # single attribute write; thread-safe with no lock @property def drain_requested(self) -> bool: ... @property def drain_reason(self) -> str | None: ...
class GraphDrained(GraphBubbleUp): def __init__(self, reason: str = "shutdown") -> None: ...Pass your own RunControl via graph.invoke(input, control=my_control) (or to
stream()/stream_events()) so an external SIGTERM handler / background thread
can call my_control.request_drain(reason); without an externally-supplied
control=, the executor’s internal RunControl isn’t reachable from outside.
Inside a node, check runtime.drain_requested / runtime.drain_reason (both
delegate to runtime.control) and return normally — do not raise
GraphDrained yourself; the engine checks the flag at the next super-step
boundary (after your node’s writes are committed) and raises it there so the
checkpoint is safely saved first. GraphDrained is a GraphBubbleUp — like
GraphInterrupt, it is caught by the Pregel engine on the way out, not a bug
report; catch it at the call site to detect a graceful stop and resume the
thread later with graph.invoke(None, config=...).
import threadingfrom langgraph.runtime import RunControl
control = RunControl()threading.Thread(target=lambda: control.request_drain("SIGTERM"), daemon=True).start()# result = graph.invoke(inputs, config, control=control)Internals (private, unverified-stability): _TimedAttemptScope / _AttemptContext
/ _AttemptEvent (langgraph.pregel._retry) implement the timeout-enforcement
boundary and retry-lifecycle event objects that TimeoutPolicy/RetryPolicy compile
down to.
Section titled “Internals (private, unverified-stability): _TimedAttemptScope / _AttemptContext
/ _AttemptEvent (langgraph.pregel._retry) implement the timeout-enforcement
boundary and retry-lifecycle event objects that TimeoutPolicy/RetryPolicy compile
down to.”Human-in-the-loop & Interrupts
Section titled “Human-in-the-loop & Interrupts”interrupt() + Interrupt
Section titled “interrupt() + Interrupt”Module: langgraph.types
Pauses the current node, surfaces value to the caller, and waits for
Command(resume=...). With a checkpointer attached, invoke()/stream() return
normally (the interrupt does not propagate to the caller as an exception) and the
pending payload is visible via graph.get_state(config).interrupts or the
"__interrupt__" key of the returned dict.
def interrupt(value: Any) -> Any: ...
@dataclass(frozen=True, slots=True)class Interrupt: value: Any id: str # derived from an xxh3_128 hash of the checkpoint namespace — stable per node path/run- On resume, the entire node re-runs from the top;
interrupt()matches resume values by the call-order index within that node execution (or byidwhen resuming via a mapping), so multiple sequentialinterrupt()calls in one node each occupy their own slot and are answered one at a time across successive resumes, and already-resolved calls return immediately without re-pausing. Put side-effects that must run exactly once inside@taskfunctions (memoised, skipped on replay), not directly above aninterrupt()call. Command(resume=value)answers the next pending interrupt withvalue;Command(resume={interrupt_id: value, ...})answers specific interrupts by.id— required when several parallel tasks (e.g.Send-dispatched or@task-dispatched) each hold their own interrupt simultaneously.- Works inside
@taskfunctions too, provided a checkpointer is attached.NodeInterrupt(the pre-1.0 mechanism) is fully removed/deprecated; useinterrupt().
from typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.types import interrupt, Command
class State(TypedDict): draft: str; approved: bool
def review(state: State) -> dict: decision = interrupt({"question": "Approve?", "draft": state["draft"]}) return {"approved": decision == "approve"}
graph = StateGraph(State).add_node("review", review).add_edge(START, "review").add_edge("review", END).compile(checkpointer=InMemorySaver())config = {"configurable": {"thread_id": "t1"}}
graph.invoke({"draft": "hello", "approved": False}, config) # pausesfinal = graph.invoke(Command(resume="approve"), config) # resumesprint(final["approved"]) # True
# Resuming a specific interrupt by id (needed when multiple are pending at once)snap = graph.get_state(config)first_id = snap.interrupts[0].id if snap.interrupts else None# graph.invoke(Command(resume={first_id: "approve"}), config)Command(resume=...) is the only field needed to answer a pending interrupt()
— see the Prebuilt Nodes, Command & Send section for the full Command
reference; it can be combined with update=/goto= in the same Command if the
resuming node also needs to patch state or route explicitly.
HumanResponse (+ deprecated HumanInterrupt / ActionRequest / HumanInterruptConfig)
Section titled “HumanResponse (+ deprecated HumanInterrupt / ActionRequest / HumanInterruptConfig)”Module: langgraph.prebuilt.interrupt (moved to langchain.agents.interrupt
as of LangGraph 1.0; the langgraph.prebuilt.interrupt re-exports still work but
each emits LangGraphDeprecatedSinceV10 — the langchain package itself isn’t
installed in the verification venv, so the langchain.agents.interrupt import
path is confirmed only via the deprecation shim’s pointer, not independently
re-verified). HumanResponse is the conventional shape a human-review resume
value takes; it remains a stable, non-deprecated TypedDict.
A structured HITL contract standardising what a human operator can do with a paused graph: accept as-is, ignore/skip, send free-text feedback, or edit the proposed action, and a matching response shape the node reads back after resume.
class ActionRequest(TypedDict): action: str; args: dictclass HumanInterruptConfig(TypedDict): allow_ignore: bool; allow_respond: bool; allow_edit: bool; allow_accept: boolclass HumanInterrupt(TypedDict): action_request: ActionRequest; config: HumanInterruptConfig; description: str | Noneclass HumanResponse(TypedDict): type: Literal["accept", "ignore", "response", "edit"] args: None | str | ActionRequesttype | args |
|---|---|
"accept" / "ignore" | None |
"response" | str (free-text feedback) |
"edit" | ActionRequest (human-modified action/args) |
from langgraph.types import interrupt, Commandtry: from langchain.agents.interrupt import ActionRequest, HumanInterrupt, HumanInterruptConfig, HumanResponseexcept ImportError: from langgraph.prebuilt.interrupt import ActionRequest, HumanInterrupt, HumanInterruptConfig, HumanResponse # deprecated fallback
def request_approval(state) -> dict: request: HumanInterrupt = { "action_request": {"action": "run_shell", "args": {"cmd": state["command"]}}, "config": {"allow_ignore": True, "allow_respond": True, "allow_edit": True, "allow_accept": True}, "description": f"Approve running `{state['command']}`?", } response: HumanResponse = interrupt(request) return {"approved": response["type"] == "accept"}
# Resume: graph.invoke(Command(resume=HumanResponse(type="accept", args=None)), config)Functional API
Section titled “Functional API”entrypoint, entrypoint.final
Section titled “entrypoint, entrypoint.final”Module: langgraph.func. @entrypoint(...) compiles a plain function into a
full Pregel graph — the decorated function must take exactly one positional
input parameter plus optional injectable keyword parameters (config,
previous, runtime). Generator functions raise NotImplementedError.
class entrypoint(Generic[ContextT]): def __init__(self, checkpointer: BaseCheckpointSaver | None = None, store: BaseStore | None = None, cache: BaseCache | None = None, context_schema: type[ContextT] | None = None, cache_policy: CachePolicy | None = None, retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None, timeout: float | timedelta | TimeoutPolicy | None = None) -> None: ...
class final(Generic[R, S]): def __init__(self, *, value: R, save: S) -> None: ...previousis populated from the last saved value on the samethread_id(via aLastValuechannel keyedPREVIOUS); on the first call it’sMISSINGand your function’s own default (typicallyNone) is used.context_schema(the modern name for the deprecatedconfig_schema) types theruntime.contextpassed via thecontext=kwarg toinvoke()/stream()— distinct fromconfig["configurable"], which only carries runnable/checkpoint settings likethread_id.entrypoint.final(value=..., save=...)decouples what the caller receives (value) from what gets written to thepreviouschannel for the next call (save) — e.g. return a human-readable summary while persisting the full raw history.
from typing import Optionalfrom langgraph.func import entrypointfrom langgraph.checkpoint.memory import InMemorySaver
@entrypoint(checkpointer=InMemorySaver())def counter(increment: int, *, previous: Optional[int] = None) -> entrypoint.final[str, int]: current = (previous or 0) + increment return entrypoint.final(value=f"Counter is now {current}", save=current)
config = {"configurable": {"thread_id": "cnt-1"}}print(counter.invoke(5, config)) # Counter is now 5print(counter.invoke(3, config)) # Counter is now 8 (previous=5 restored from checkpoint)task + SyncAsyncFuture
Section titled “task + SyncAsyncFuture”Module: langgraph.func. @task wraps a sync or async callable so it runs as
an independently tracked, checkpointed Pregel sub-task when called from inside an
entrypoint (or a StateGraph node).
def task(__func_or_none__=None, *, name: str | None = None, retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None, cache_policy: CachePolicy[Callable[..., str | bytes]] | None = None, timeout: float | timedelta | TimeoutPolicy | None = None) -> _TaskFunction: ...Calling the decorated function returns a SyncAsyncFuture[T] — a
concurrent.futures.Future subclass whose __await__ also makes it directly
await-able in async code. It supports .result() in sync contexts and
await fut individually in async contexts, but is not compatible with
asyncio.gather() (its __await__ yields a scheduler sentinel meant for
LangGraph’s own loop, which gather() rejects with RuntimeError: Task got bad yield) — collect multiple futures with [await f for f in futures] or
[f.result() for f in futures], not gather. Only async tasks support
timeout=; results are checkpointed, so resuming after an interrupt re-plays
already-completed task results instead of re-executing them.
from langgraph.func import entrypoint, taskfrom langgraph.checkpoint.memory import InMemorySaver
@taskdef square(n: int) -> int: return n * n
@entrypoint(checkpointer=InMemorySaver())def compute(numbers: list[int]) -> list[int]: futures = [square(n) for n in numbers] # dispatched in parallel return [f.result() for f in futures]
print(compute.invoke([1, 2, 3, 4], {"configurable": {"thread_id": "t1"}})) # [1, 4, 9, 16]call() + SyncAsyncFuture
Section titled “call() + SyncAsyncFuture”Modules: langgraph.pregel._call (implementation); public re-export
langgraph.types.call
The low-level primitive @task compiles down to: call(fn, *args, retry_policy=..., cache_policy=..., timeout=..., **kwargs) dispatches fn as a sub-task with its own
per-call policies (overriding the @task decorator’s defaults for that one
invocation) and returns a SyncAsyncFuture — usable with .result() or await. Only
works inside an active Pregel execution (an @entrypoint/@task/node) — calling it
elsewhere raises a KeyError. timeout is async-only; passing it with a sync func
raises NotImplementedError.
def call(func: Callable[..., T], *args, retry_policy: Sequence[RetryPolicy] | None = None, cache_policy: CachePolicy | None = None, timeout: float | timedelta | TimeoutPolicy | None = None, **kwargs) -> SyncAsyncFuture[T]from langgraph.types import call, RetryPolicyfrom langgraph.func import entrypointfrom langgraph.checkpoint.memory import InMemorySaverimport asyncio
@entrypoint(checkpointer=InMemorySaver())async def pipeline(prompts: list[str]) -> list[str]: futures = [call(call_llm, p, retry_policy=[RetryPolicy(max_attempts=3)], timeout=30.0) for p in prompts] return list(await asyncio.gather(*futures))Internals (private, unverified-stability): PregelScratchpad
(langgraph._internal._scratchpad) is the per-superstep execution context (step,
stop, call_counter, interrupt_counter, resume, subgraph_counter) that
IsLastStep/RemainingSteps and interrupt()’s resume matching read from;
FunctionNonLocals/NonLocals (langgraph.pregel._utils) do AST-based closure
analysis to detect which outer-scope names a @task/@entrypoint function captures;
identifier/get_runnable_for_task/get_runnable_for_entrypoint
(langgraph.pregel._call) resolve a callable’s stable module.qualname for caching —
lambdas have no stable identifier and are therefore rebuilt (not cached) on every
compile.
Section titled “Internals (private, unverified-stability): PregelScratchpad
(langgraph._internal._scratchpad) is the per-superstep execution context (step,
stop, call_counter, interrupt_counter, resume, subgraph_counter) that
IsLastStep/RemainingSteps and interrupt()’s resume matching read from;
FunctionNonLocals/NonLocals (langgraph.pregel._utils) do AST-based closure
analysis to detect which outer-scope names a @task/@entrypoint function captures;
identifier/get_runnable_for_task/get_runnable_for_entrypoint
(langgraph.pregel._call) resolve a callable’s stable module.qualname for caching —
lambdas have no stable identifier and are therefore rebuilt (not cached) on every
compile.”Runtime & Managed Values
Section titled “Runtime & Managed Values”Runtime, ExecutionInfo, ServerInfo, BaseUser, get_runtime()
Section titled “Runtime, ExecutionInfo, ServerInfo, BaseUser, get_runtime()”Module: langgraph.runtime. Runtime[ContextT] is the convenience bundle
injected into any node (not tools — use ToolRuntime there) that declares a
runtime: Runtime parameter.
@dataclass(frozen=True)class Runtime(Generic[ContextT]): context: ContextT | None store: BaseStore | None stream_writer: StreamWriter heartbeat: Callable[[], None] previous: Any execution_info: ExecutionInfo | None server_info: ServerInfo | None control: RunControl | None
def merge(self, other: "Runtime") -> "Runtime": ... # other's non-default values win def override(self, **overrides) -> "Runtime": ... # dataclasses.replace, for tests def patch_execution_info(self, **overrides) -> "Runtime": ... # raises if execution_info is None @property def drain_requested(self) -> bool: ... # delegates to self.control
@dataclass(frozen=True, slots=True)class ExecutionInfo: checkpoint_id: str; checkpoint_ns: str; task_id: str thread_id: str | None; run_id: str | None node_attempt: int # 1-indexed; increments per retry node_first_attempt_time: float | None # fixed at the first attempt; unchanged on retries def patch(self, **overrides) -> "ExecutionInfo": ...
@dataclass(frozen=True, slots=True)class ServerInfo: assistant_id: str; graph_id: str; user: BaseUser | None # all None on open-source/local runs
def get_runtime(context_schema=None) -> Runtime: ... # reads the active Runtime from the config ContextVarheartbeat resets the idle-timeout clock — safe to call unconditionally, a no-op
outside an idle-timed attempt. execution_info.task_id is stable across retries —
use it as an idempotency key for external calls. server_info (assistant_id,
graph_id, user: BaseUser | None) is populated only when running inside
LangGraph Platform — always None in open-source/local runs; guard with if runtime.server_info is not None. BaseUser is re-exported from
langgraph_sdk.auth.types (a protocol supporting both user.identity and
user["identity"] access), not defined in langgraph itself. get_runtime() is
an alternative to parameter injection for reading runtime state from a helper
function nested deep inside a node — functionally equivalent to injecting
runtime: Runtime directly, and only usable inside an active graph run.
from dataclasses import dataclassfrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.runtime import Runtime
@dataclassclass UserContext: user_id: str
class State(TypedDict): query: str; result: str
def node(state: State, runtime: Runtime[UserContext]) -> dict: info = runtime.execution_info return {"result": f"[{runtime.context.user_id}] attempt={info.node_attempt if info else 1}: {state['query']}"}
graph = StateGraph(State, context_schema=UserContext).add_node("n", node).add_edge(START, "n").add_edge("n", END).compile()result = graph.invoke({"query": "hi", "result": ""}, context=UserContext(user_id="alice"))RunControl
Section titled “RunControl”Module: langgraph.runtime
Cooperative graceful-shutdown signal (e.g. wired to SIGTERM). request_drain(reason)
sets a flag the loop checks at superstep boundaries; the run exits via GraphDrained
(see Error Handling) once the current superstep finishes — no abrupt cancellation, and
a checkpoint is saved so the run can resume later with the same thread_id.
class RunControl: def request_drain(self, reason: str = "shutdown") -> None: ... @property def drain_requested(self) -> bool: ...from langgraph.runtime import RunControlcontrol = RunControl()# elsewhere: control.request_drain(reason="SIGTERM")# graph.invoke(input, config, control=control) # raises GraphDrained at the next boundaryManagedValue + IsLastStep / RemainingSteps
Section titled “ManagedValue + IsLastStep / RemainingSteps”Modules: langgraph.managed.base (ManagedValue), langgraph.managed.is_last_step
(built-ins, also re-exported from langgraph.managed)
Managed values are scratchpad-derived fields the Pregel runtime injects into a node’s state every step, rather than storing them in a channel — a node cannot write to one, and they never appear in checkpoint blobs.
class ManagedValue(ABC, Generic[V]): @staticmethod @abstractmethod def get(scratchpad: PregelScratchpad) -> V: ... # must be a @staticmethod; the class itself is the "spec"
IsLastStep = Annotated[bool, IsLastStepManager] # True exactly when step == stop - 1RemainingSteps = Annotated[int, RemainingStepsManager] # stop - step, counting the current stepPregelScratchpad.step starts at 0 and increments per completed super-step;
.stop is the absolute step cutoff derived from recursion_limit (offset by any
prior checkpoint’s step count on resume) — always compute remaining budget as
stop - step, never from the raw config["recursion_limit"] directly, since
.stop already accounts for where a resumed run picked up. IsLastStep becomes
True exactly once, giving a one-step warning window before GraphRecursionError
would fire on the next step. Writing a custom managed value just means subclassing
ManagedValue[V] with a get(scratchpad) -> V static method.
from typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.managed.is_last_step import IsLastStep, RemainingSteps
class State(TypedDict): value: int; is_last: IsLastStep; remaining_steps: RemainingSteps
def guarded(state: State) -> dict: if state["is_last"]: return {"value": state["value"]} # bail out cleanly before the recursion limit hits return {"value": state["value"] + 1}
builder = StateGraph(State)builder.add_node("work", guarded); builder.add_edge(START, "work")builder.add_conditional_edges("work", lambda s: END if s["is_last"] else "work")Observability & Tracing
Section titled “Observability & Tracing”TracePolicy
Section titled “TracePolicy”Module: langgraph.types. A frozen dataclass, verified fields:
(process_inputs, process_outputs).
@dataclass(frozen=True)class TracePolicy: process_inputs: Callable[[Any], Any] | None = None process_outputs: Callable[[Any], Any] | None = NoneAttach via add_node(..., trace_policy=TracePolicy(...)) to transform what a
node’s own LangSmith trace span records — summarize a long message history,
redact a field — without affecting the actual data flowing through the graph.
Scope is limited to that node’s own run span; the root graph run and any child
runs created by the bound runnable still see the original payload. This is a
sanitization/summarization convenience, not a secrets-redaction guarantee — use
LangSmith’s hide_inputs/hide_outputs/anonymizer for that.
from typing import Anyfrom langgraph.types import TracePolicy
def redact(value: Any) -> Any: if isinstance(value, dict): return {k: ("***" if k == "api_key" else v) for k, v in value.items()} return value
# builder.add_node("fetch", fetch_data, trace_policy=TracePolicy(process_inputs=redact, process_outputs=redact))GraphCallbackHandler, GraphInterruptEvent, GraphResumeEvent
Section titled “GraphCallbackHandler, GraphInterruptEvent, GraphResumeEvent”Module: langgraph.callbacks. GraphCallbackHandler extends
langchain_core.callbacks.BaseCallbackHandler with two graph-only lifecycle
hooks that a generic LangChain callback handler cannot observe.
class GraphCallbackHandler(BaseCallbackHandler): def on_interrupt(self, event: GraphInterruptEvent) -> Any: ... # default no-op def on_resume(self, event: GraphResumeEvent) -> Any: ... # default no-op
@dataclass(frozen=True)class GraphInterruptEvent: run_id: UUID | None; status: GraphLifecycleStatus checkpoint_id: str; checkpoint_ns: tuple[str, ...] interrupts: tuple[Interrupt, ...]
@dataclass(frozen=True)class GraphResumeEvent: run_id: UUID | None; status: GraphLifecycleStatus checkpoint_id: str; checkpoint_ns: tuple[str, ...]
GraphLifecycleStatus = Literal["input", "pending", "done", "interrupt_before", "interrupt_after", "out_of_steps"]Register instances via config["callbacks"]; an internal manager
(_GraphCallbackManager / _AsyncGraphCallbackManager) filters the callback stack
down to GraphCallbackHandler subclasses before dispatching these two events, so a
plain BaseCallbackHandler is silently skipped. Both methods may be async def
for async runs. checkpoint_ns lets a single handler distinguish root-graph
pauses from subgraph pauses by namespace depth.
from langgraph.callbacks import GraphCallbackHandler, GraphInterruptEvent, GraphResumeEvent
class AuditHandler(GraphCallbackHandler): def on_interrupt(self, event: GraphInterruptEvent) -> None: print(f"paused at {event.checkpoint_id[:8]} ns={event.checkpoint_ns}") def on_resume(self, event: GraphResumeEvent) -> None: print(f"resumed at {event.checkpoint_id[:8]}")
# graph.invoke(inputs, config={"configurable": {...}, "callbacks": [AuditHandler()]})LangGraphDeprecationWarning + subclasses
Section titled “LangGraphDeprecationWarning + subclasses”Module: langgraph.warnings
The deprecation-warning hierarchy every deprecated LangGraph API (MessageGraph,
ValidationNode, AgentState, the langgraph.prebuilt.interrupt
re-exports, GraphOutput dict-access, …) emits — each subclass records since and
expected_removal as (major, minor) tuples, letting you filter by version range in
tests (pytest.warns(LangGraphDeprecatedSinceV10)) or promote them to errors
(warnings.filterwarnings("error", category=LangGraphDeprecatedSinceV10)) to assert a
codebase avoids a given deprecation window.
class LangGraphDeprecationWarning(DeprecationWarning): def __init__(self, message, *, since: tuple[int, int], expected_removal: tuple[int, int] | None = None): ...class LangGraphDeprecatedSinceV10(LangGraphDeprecationWarning): ... # since=(1,0), removal=(2,0)class LangGraphDeprecatedSinceV11(LangGraphDeprecationWarning): ... # since=(1,1), removal=(3,0)import warnings, pytestfrom langgraph.warnings import LangGraphDeprecatedSinceV10
def test_message_graph_emits_deprecation(): with pytest.warns(LangGraphDeprecatedSinceV10): from langgraph.graph.message import MessageGraph MessageGraph()Internals (private, unverified-stability): StreamMessagesHandler /
StreamMessagesHandlerV2 (langgraph.pregel._messages, also listed under
Streaming & Transformers) are the concrete callback handlers powering
stream_mode="messages"; _GraphCallbackManager / _AsyncGraphCallbackManager
(langgraph.callbacks) are what actually calls on_interrupt/on_resume on
registered GraphCallbackHandler instances, built from config["callbacks"] via
.configure().
Section titled “Internals (private, unverified-stability): StreamMessagesHandler /
StreamMessagesHandlerV2 (langgraph.pregel._messages, also listed under
Streaming & Transformers) are the concrete callback handlers powering
stream_mode="messages"; _GraphCallbackManager / _AsyncGraphCallbackManager
(langgraph.callbacks) are what actually calls on_interrupt/on_resume on
registered GraphCallbackHandler instances, built from config["callbacks"] via
.configure().”Prebuilt Nodes, Command & Send
Section titled “Prebuilt Nodes, Command & Send”Command
Section titled “Command”Module: langgraph.types
The universal node return type that simultaneously updates state, routes
(goto, replacing a static edge), resumes an interrupt (resume), and can
target the parent graph (graph=Command.PARENT, only valid from inside a
subgraph node — using it at the top level raises via GraphBubbleUp/
ParentCommand). goto accepts a single node name, a list mixing node names and
Send objects (parallel fan-out), or END.
@dataclassclass Command(Generic[N], ToolOutputMixin): PARENT: ClassVar[Literal["__parent__"]] = "__parent__" graph: str | None = None # None = this graph; Command.PARENT = parent update: Any | None = None resume: dict[str, Any] | Any | None = None goto: Send | Sequence[Send | N] | N = ()goto=is additive with any staticadd_edgealready leaving that node — both fire. For fully dynamic routing, don’t also declare a static outgoing edge from the node.graph=Command.PARENTsends the update/goto to the nearest enclosing parent graph — the standard way for a subgraph node to write into parent state or route the parent; using it at the root graph raises (there is no parent to target).updateaccepts a plain dict, a list of(key, value)pairs, or a Pydantic model/dataclass instance (annotated-key extraction) — the same shapes a normal node return value accepts.
from typing import Literalfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import Command, Sendfrom typing_extensions import TypedDict
class State(TypedDict): score: int; tier: str
def router(state: State) -> Command[Literal["premium", "standard"]]: tier = "premium" if state["score"] >= 90 else "standard" return Command(update={"tier": tier}, goto=tier)
def premium(state: State) -> dict: return {}def standard(state: State) -> dict: return {}
builder = StateGraph(State)builder.add_node("router", router); builder.add_node("premium", premium); builder.add_node("standard", standard)builder.add_edge(START, "router"); builder.add_edge("premium", END); builder.add_edge("standard", END)graph = builder.compile()
# Cross-subgraph: a node inside a subgraph escalating to its parentdef escalate(state: dict) -> Command: return Command(graph=Command.PARENT, update={"escalation_reason": "budget exceeded"}, goto="approval_node")
# Fan-out via Send objects in gotodef fan_out(state: dict) -> Command: return Command(goto=[Send("worker", {"item": i}) for i in state.get("items", [])])Module: langgraph.types
Routes execution to a named node with a specific input, bypassing shared state
— enables dynamic map-reduce fan-out where the branch count isn’t known at
graph-build time. Send also accepts a per-task timeout (coerced through
TimeoutPolicy.coerce()), overriding the target node’s default timeout for that
one fanned-out invocation.
class Send: __slots__ = ("node", "arg", "timeout") def __init__(self, node: str, arg: Any, *, timeout: float | timedelta | TimeoutPolicy | None = None) -> None: ...argbecomes the full input to the target node (dict, Pydantic model, anything the node accepts). Results merge back into the parent state via each field’s reducer — typicallyAnnotated[list[T], operator.add](orTopic(T)when each worker contributes a single scalar item rather than a list).Sendis hashable and compares structurally on(node, arg, timeout)only ifargis hashable — passing a plaindict(the common case) makes thatSendinstance unhashable; don’t put suchSendobjects in a set/dict key.- Per-
Sendtimeout=requires the target node to beasync, like all node timeouts.
import operatorfrom typing import Annotatedfrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import Send
class MapState(TypedDict): urls: list[str]; results: Annotated[list[str], operator.add]
def distribute(state: MapState) -> list[Send]: return [Send("scrape", {"url": u, "result": ""}, timeout=10.0) for u in state["urls"]]
def scrape(state: dict) -> dict: return {"results": [f"content of {state['url']}"]}
builder = StateGraph(MapState)builder.add_node("scrape", scrape)builder.add_conditional_edges(START, distribute)builder.add_edge("scrape", END)result = builder.compile().invoke({"urls": ["a.com", "b.com"], "results": []})RemoteGraph + RemoteException + get_client / get_sync_client
Section titled “RemoteGraph + RemoteException + get_client / get_sync_client”Module: langgraph.pregel.remote
A PregelProtocol implementation wrapping any LangGraph Server-compatible HTTP
API (LangSmith deployment, self-hosted langgraph-cli server, or another graph in
the same process via ASGI loopback) so it behaves exactly like a local compiled
graph — pass it to add_node() as a subgraph, or call
invoke/stream/get_state/update_state/get_state_history/get_graph
directly. api_key falls back to LANGGRAPH_API_KEY / LANGSMITH_API_KEY /
LANGCHAIN_API_KEY; at least one of url, client, or sync_client is
required. RemoteException is a plain Exception raised when the remote server
returns an error response — catch it to distinguish remote failures from local
ones.
class RemoteGraph(PregelProtocol): def __init__(self, assistant_id: str, /, *, url: str | None = None, api_key: str | None = None, headers: dict[str, str] | None = None, client=None, sync_client=None, config: RunnableConfig | None = None, name: str | None = None, distributed_tracing: bool = False): ...
def get_client(*, url: str | None = None, api_key: str | ... = NOT_PROVIDED, headers=None, timeout=None) -> LangGraphClient: ...def get_sync_client(*, url=None, ...) -> SyncLangGraphClient: ...get_client(url=None) attempts an in-process ASGI loopback (no network hop) when
no url is given; the api_key sentinel (NOT_PROVIDED, distinct from None)
triggers auto-loading from LANGGRAPH_API_KEY → LANGSMITH_API_KEY →
LANGCHAIN_API_KEY — pass api_key=None explicitly to skip that lookup entirely
(e.g. for local dev with no auth). distributed_tracing=True propagates LangSmith
x-parent-* headers so the remote run’s trace links back to the parent’s.
from langgraph.pregel.remote import RemoteGraph, RemoteException
remote = RemoteGraph("my_agent", url="http://localhost:2024", api_key="local-key")try: result = remote.invoke({"messages": [{"role": "user", "content": "hi"}]}, config={"configurable": {"thread_id": "t1"}})except RemoteException as e: print(f"remote graph failed: {e}")
# Embed a remote deployment as a node in a local orchestration graph:builder.add_node("research", remote)Internals (private, unverified-stability): PregelProtocol + StreamProtocol
(langgraph.pregel.protocol) are the abstract executor interface both Pregel and
RemoteGraph implement and the slim (modes, __call__) stream-mode-filter struct
behind them, respectively — annotate a parameter as PregelProtocol to write code
that works with local or remote graphs interchangeably. BackgroundExecutor /
AsyncBackgroundExecutor / Submit (langgraph.pregel._executor) run parallel node
execution (thread pool for sync, asyncio tasks for async); PregelRunner /
FuturesDict (langgraph.pregel._runner), SyncPregelLoop / AsyncPregelLoop /
DuplexStream (langgraph.pregel._loop), and WritesProtocol / PregelTaskWrites
(langgraph.pregel._algo) implement the per-superstep task-scheduling/write-commit
machinery underneath every invoke()/stream() call.
Corrections vs. older third-party write-ups
Section titled “Corrections vs. older third-party write-ups”A short list of symbols and signatures that differ from what circulates in older
blog posts, outdated docs, or earlier drafts of this reference — the installed
langgraph==1.2.11 source is the tiebreaker throughout this section:
ToolOutputMixinatlanggraph.prebuilt.tool_node— never actually lived there; it’slangchain_core.messages.tool.ToolOutputMixin, re-exported (unofficially) vialanggraph.types. Not removed, just mis-located in some older docs.NodeBuilder.with_retry_policy()/.with_cache_policy()/.with_timeout()/.with_tags()/.with_metadata()— these method names never existed in the installed version; the real API isadd_retry_policies(),add_cache_policy(),set_timeout(), and a combinedmeta(*tags, **metadata).add_node(..., retry=...)— several older examples use aretry=kwarg; the real parameter name has always beenretry_policy=.ToolNode(handle_tool_errors=True)as a literal default — the real default value is the function_default_handle_tool_errors, not the literalTrue(see Tools & Tool Calling above).from langgraph.prebuilt import AgentState— raisesImportErrorin 1.2.11; it was never re-exported from thelanggraph.prebuiltpackage__init__. Import fromlanggraph.prebuilt.chat_agent_executorinstead.SqliteSaver.from_conn_string(...)/PostgresSaver.from_conn_string(...)— these are context managers (with ... as saver:), not factories that return a saver directly.Interrupt.ns,Interrupt.when,Interrupt.resumable— removed; onlyInterrupt.valueandInterrupt.idremain on the dataclass.checkpoint_during=Falseoninvoke/stream— deprecated in favor ofdurability="exit"(or"sync"/"async").ShallowPostgresSaver— deprecated as oflanggraph-checkpoint-postgres2.0.20; usePostgresSaver+durability="exit".