LangGraph builds an agent as an explicit graph of nodes and edges instead of one implicit loop, with a checkpointer that makes every step resumable. This guide builds a research agent graph that plans, searches, drafts a report, pauses on interrupt() for a human to approve or request changes, then finalizes. Stack: Python, langgraph, an Anthropic API key. Budget 45-60 minutes.
| Fact | Value |
|---|---|
| Tool | LangGraph |
| Difficulty | Advanced |
| Time required | 45-60 min |
| Compatibility | Python 3.10+, langgraph, Anthropic API key, any OS |
| Last updated | November 2026 |
What you'll build
You'll build a research agent that turns a topic into a short report. It plans what to look into, runs a search step, drafts the report, then stops and hands the draft to a human: approve it and the graph finalizes, ask for changes and it loops back to drafting with that feedback before pausing again.
LangGraph makes every one of those steps a node in a StateGraph, connected by edges you add explicitly. State is a plain TypedDict that flows through every node, each node returns a partial update to merge into it, and add_conditional_edges lets a routing function decide which node runs next based on that state. The interrupt() function, called from inside a node, is what actually pauses the graph: it raises internally, the graph halts with the interrupt's value surfaced to your calling code, and a later call with Command(resume=your_value) picks that exact node back up as if interrupt() had just returned your_value. None of this works without a checkpointer attached at compile() time, since pausing and resuming both rely on LangGraph's persistence layer to remember where the graph was.
Choose LangGraph when you want to see and control every step of a multi-step agent explicitly, especially when some of those steps need to survive a pause of minutes or days waiting on a human. If you want the same tool-calling agent shape with less graph-building and don't need that level of control, the LangChain guide's create_agent is the faster path to a working agent.
Prerequisites
- Python 3.10+ and a terminal
- An Anthropic API key (ANTHROPIC_API_KEY)
- pip install langgraph anthropic
- Comfort with Python TypedDict and basic graph/DAG thinking
Step-by-step guide
Install LangGraph and define the state
State is a TypedDict carrying everything that flows between nodes: the topic, the plan, raw search results, the current draft, and the human's decision once it's set. Every node you write will take this State in and return a dict with only the keys it changed; LangGraph merges that into the running state.
1pip install langgraph anthropic2export ANTHROPIC_API_KEY="sk-ant-..."34from typing import Optional5from typing_extensions import TypedDict67class State(TypedDict):8 topic: str9 plan: str10 search_results: str11 draft: str12 decision: Optional[str]13 feedback: Optional[str]Expected result: A State TypedDict with six fields, none populated yet.
Write the plan and search nodes
plan_node calls Claude once to turn the topic into a short bullet plan of what to look into. search_node is stubbed here as a function returning canned findings; swap it for a real call to Tavily, Exa, or Brave Search in production. Each node is a plain function taking State and returning a partial dict.
1import anthropic2client = anthropic.Anthropic()34def plan_node(state: State) -> dict:5 resp = client.messages.create(6 model="claude-sonnet-5-5", max_tokens=300,7 messages=[{"role": "user", "content": f"List 3 bullet points to research for a short report on: {state['topic']}"}],8 )9 return {"plan": resp.content[0].text}1011def search_node(state: State) -> dict:12 # Stub: swap for a real search API call in production13 return {"search_results": f"Findings for '{state['topic']}' based on plan:\n{state['plan']}"}Expected result: Calling plan_node({'topic': 'edge AI chips'}) returns a dict with a 'plan' key full of bullet points.
Write the synthesize and review nodes
synthesize_node turns the plan and search results into a draft, folding in any feedback from a previous review round if this is a second pass. review_node is where interrupt() pauses the graph: it surfaces the current draft to whoever is reviewing and waits. The value interrupt() returns is whatever gets passed to Command(resume=...) later.
1from langgraph.types import interrupt23def synthesize_node(state: State) -> dict:4 feedback_note = f"\nIncorporate this feedback: {state['feedback']}" if state.get("feedback") else ""5 resp = client.messages.create(6 model="claude-sonnet-5-5", max_tokens=600,7 messages=[{"role": "user", "content": f"Write a short report from these findings.{feedback_note}\n\n{state['search_results']}"}],8 )9 return {"draft": resp.content[0].text}1011def review_node(state: State) -> dict:12 decision = interrupt({"draft": state["draft"], "ask": "approve or request_changes?"})13 if decision.get("action") == "approve":14 return {"decision": "approve"}15 return {"decision": "request_changes", "feedback": decision.get("feedback", "")}Expected result: Running the graph to review_node pauses execution; no code after interrupt() runs until the graph is resumed.
Wire the graph with a conditional edge back to synthesize
route_after_review reads state['decision'] (set by review_node once it resumes) and sends the graph to finalize_node on approval or back to synthesize_node on a change request, closing the loop. add_conditional_edges takes the routing function and a dict mapping its possible return values to real node names.
1from langgraph.graph import StateGraph, START, END2from langgraph.checkpoint.memory import InMemorySaver34def finalize_node(state: State) -> dict:5 return {"draft": state["draft"] + "\n\n[Approved and finalized]"}67def route_after_review(state: State) -> str:8 return "finalize" if state["decision"] == "approve" else "synthesize"910builder = StateGraph(State)11builder.add_node("plan", plan_node)12builder.add_node("search", search_node)13builder.add_node("synthesize", synthesize_node)14builder.add_node("review", review_node)15builder.add_node("finalize", finalize_node)16builder.add_edge(START, "plan")17builder.add_edge("plan", "search")18builder.add_edge("search", "synthesize")19builder.add_edge("synthesize", "review")20builder.add_conditional_edges("review", route_after_review, {"finalize": "finalize", "synthesize": "synthesize"})21builder.add_edge("finalize", END)2223graph = builder.compile(checkpointer=InMemorySaver())Expected result: graph is a compiled, runnable StateGraph with a loop from review back to synthesize on rejection.
Run the graph to the first interrupt
Every invoke/stream call needs a thread_id in the config; LangGraph uses it to find the right checkpoint to resume later. Call graph.invoke with the starting state and that config, then check the result for the '__interrupt__' key, which holds the value review_node passed to interrupt().
1import uuid23config = {"configurable": {"thread_id": str(uuid.uuid4())}}4result = graph.invoke({"topic": "edge AI chips", "feedback": None}, config)56if "__interrupt__" in result:7 payload = result["__interrupt__"][0].value8 print("Draft for review:\n", payload["draft"])Expected result: The graph runs plan -> search -> synthesize -> review and stops, printing the draft for review instead of finishing.
Resume with a decision and harden for production
Resume the same thread_id with Command(resume=...), passing exactly the shape review_node expects. A request_changes decision routes back to synthesize and pauses at review again with a new draft; an approve decision finishes the graph. For production, swap InMemorySaver for a persistent checkpointer (Postgres or SQLite) so a pause that outlives your process (a human reviewing overnight) still resumes correctly, and cap how many review loops are allowed before escalating instead of looping forever.
1from langgraph.types import Command23# Human requests a change4result = graph.invoke(Command(resume={"action": "request_changes", "feedback": "Add a cost comparison section."}), config)5if "__interrupt__" in result:6 print("New draft for review:\n", result["__interrupt__"][0].value["draft"])78# Human approves the next draft9final = graph.invoke(Command(resume={"action": "approve"}), config)10print(final["draft"])Expected result: After one revision round, the second invoke with action=approve returns a final state with the finalized draft, no further interrupt.
Complete working example
1"""LangGraph research agent: plan -> search -> synthesize -> human review loop -> finalize.2Run: python research_agent_graph.py (pip install langgraph anthropic; set ANTHROPIC_API_KEY)3"""4import uuid5from typing import Optional67import anthropic8from langgraph.checkpoint.memory import InMemorySaver9from langgraph.graph import END, START, StateGraph10from langgraph.types import Command, interrupt11from typing_extensions import TypedDict1213client = anthropic.Anthropic()141516class State(TypedDict):17 topic: str18 plan: str19 search_results: str20 draft: str21 decision: Optional[str]22 feedback: Optional[str]232425def plan_node(state: State) -> dict:26 resp = client.messages.create(27 model="claude-sonnet-5-5",28 max_tokens=300,29 messages=[{"role": "user", "content": f"List 3 bullet points to research for a short report on: {state['topic']}"}],30 )31 return {"plan": resp.content[0].text}323334def search_node(state: State) -> dict:35 # Stub: swap for a real search API call (Tavily, Exa, Brave Search) in production36 return {"search_results": f"Findings for '{state['topic']}' based on plan:\n{state['plan']}"}373839def synthesize_node(state: State) -> dict:40 feedback_note = f"\nIncorporate this feedback: {state['feedback']}" if state.get("feedback") else ""41 resp = client.messages.create(42 model="claude-sonnet-5-5",43 max_tokens=600,44 messages=[{"role": "user", "content": f"Write a short report from these findings.{feedback_note}\n\n{state['search_results']}"}],45 )46 return {"draft": resp.content[0].text}474849def review_node(state: State) -> dict:50 decision = interrupt({"draft": state["draft"], "ask": "approve or request_changes?"})51 if decision.get("action") == "approve":52 return {"decision": "approve"}53 return {"decision": "request_changes", "feedback": decision.get("feedback", "")}545556def finalize_node(state: State) -> dict:57 return {"draft": state["draft"] + "\n\n[Approved and finalized]"}585960def route_after_review(state: State) -> str:61 return "finalize" if state["decision"] == "approve" else "synthesize"626364builder = StateGraph(State)65builder.add_node("plan", plan_node)66builder.add_node("search", search_node)67builder.add_node("synthesize", synthesize_node)68builder.add_node("review", review_node)69builder.add_node("finalize", finalize_node)70builder.add_edge(START, "plan")71builder.add_edge("plan", "search")72builder.add_edge("search", "synthesize")73builder.add_edge("synthesize", "review")74builder.add_conditional_edges("review", route_after_review, {"finalize": "finalize", "synthesize": "synthesize"})75builder.add_edge("finalize", END)7677graph = builder.compile(checkpointer=InMemorySaver())787980if __name__ == "__main__":81 config = {"configurable": {"thread_id": str(uuid.uuid4())}}82 result = graph.invoke({"topic": "edge AI chips", "feedback": None}, config)8384 if "__interrupt__" in result:85 print("Draft for review:\n", result["__interrupt__"][0].value["draft"])86 result = graph.invoke(87 Command(resume={"action": "request_changes", "feedback": "Add a cost comparison section."}),88 config,89 )9091 if "__interrupt__" in result:92 print("\nNew draft for review:\n", result["__interrupt__"][0].value["draft"])93 result = graph.invoke(Command(resume={"action": "approve"}), config)9495 print("\nFinal:\n", result["draft"])96Common mistakes
Why it's a problem: Compiling the graph without a checkpointer and then calling interrupt() inside a node
How to avoid: interrupt() has nowhere to persist state without one. Always pass compile(checkpointer=...), even InMemorySaver(), when any node interrupts.
Why it's a problem: Reusing the same thread_id across unrelated requests
How to avoid: Each thread_id is one resumable conversation/run. Generate a new one per independent task, like per research request, or you'll resume into the wrong context.
Why it's a problem: Putting expensive, non-idempotent work (an actual API call with side effects) right before an interrupt() in the same node
How to avoid: LangGraph re-executes a node's logic from the top when resuming past an earlier interrupt in a multi-interrupt node. Keep side-effecting calls out of nodes that also interrupt, or make them idempotent.
Why it's a problem: Forgetting that add_conditional_edges needs every possible return value mapped
How to avoid: If route_after_review ever returns a string not in the mapping dict, LangGraph raises at runtime. Keep the routing function's possible outputs and the mapping in sync.
Why it's a problem: Not capping how many times review can send the graph back to synthesize
How to avoid: A reviewer who keeps requesting changes forever (or a bug that always routes back) loops indefinitely. Track a revision count in state and escalate after a limit.
Best practices
- Keep state a flat TypedDict of primitives and short strings; large blobs belong in external storage referenced by an ID.
- Use a persistent checkpointer (Postgres, SQLite) in production, not InMemorySaver, since pauses can outlive your process.
- Name nodes after what they do, not generic step1/step2, since those names appear in __interrupt__ debugging and conditional edge maps.
- Return only the keys a node actually changed from each node function, relying on LangGraph's merge rather than rebuilding the full state.
- Give every loop a hard cap (revision count, retry count) so a stuck condition fails loudly instead of running forever.
- Log the full state at each checkpoint during development; it's the fastest way to see exactly what a node received and returned.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a LangGraph StateGraph with plan, search, synthesize, review, and finalize nodes, where review calls interrupt() and a conditional edge routes back to synthesize on request_changes (code below). Add a revision_count field to State and route to an escalate node after 3 rejected rounds instead of looping forever. [paste complete_code]
In Claude Code, open research_agent_graph.py and ask: 'Replace the search_node stub with a real Tavily search call, and add a new validate node after search that reruns search_node once if search_results is shorter than 100 characters.'
Frequently asked questions
Do I need LangChain installed to use LangGraph?
No. langgraph is a standalone package; this guide's graph calls the Anthropic SDK directly inside node functions with no LangChain model wrapper involved. LangChain's create_agent happens to be built on LangGraph internally, but using LangGraph directly doesn't require it.
What exactly does interrupt() do under the hood?
On its first call in a node during a given run, it raises an internal exception that halts the graph and surfaces the value you passed as part of the result's __interrupt__ key. A later invoke with Command(resume=your_value) re-executes that node from the start, and the interrupt() call returns your_value instead of pausing again.
Why does the node re-execute from the top when resuming?
LangGraph replays the node function up to the point of the matching interrupt() call rather than resuming mid-function, which is why any code before interrupt() in a node should be cheap or idempotent, especially if a node contains more than one interrupt() call.
Can multiple nodes run in parallel in a graph like this?
Yes, when a node has multiple outgoing edges to independent nodes with no data dependency between them, LangGraph runs them concurrently. This guide's graph is intentionally linear with one branch point, but fan-out is a core LangGraph feature for, say, running several search queries at once.
How is this different from just using a while loop and some if statements?
A while loop doesn't give you free persistence, replayable state, or a visual/debuggable structure. The checkpointer means this exact graph survives a process restart mid-review, which a plain Python loop would need its own persistence layer to replicate.
Can RapidDev build a LangGraph agent like this for our team?
Yes. RapidDev builds stateful, human-reviewed agent graphs on LangGraph (and the equivalent on Claude Agent SDK or OpenAI Agents SDK), including production checkpointer setup. A free 30-minute consultation is the fastest way to scope yours.
Talk to an Expert
Our team has built 1,000+ apps. Get personalized help with your project.
Book a free consultation