Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Build an AI Agent with LangGraph

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.

What you'll learn

  • Define agent state explicitly with a TypedDict instead of letting a framework infer it
  • Wire nodes and edges with StateGraph, including a conditional edge that branches on a human decision
  • Pause a running graph mid-execution with interrupt() and resume it later with Command(resume=...)
  • Attach a checkpointer so the graph survives a process restart between the pause and the resume
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Advanced10 min read45-60 minPython 3.10+, langgraph, Anthropic API key, any OSLast updated November 2026RapidDev Engineering Team
TL;DR

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.

Quick facts about this guide
FactValue
ToolLangGraph
DifficultyAdvanced
Time required45-60 min
CompatibilityPython 3.10+, langgraph, Anthropic API key, any OS
Last updatedNovember 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

1

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.

typescript
1pip install langgraph anthropic
2export ANTHROPIC_API_KEY="sk-ant-..."
3
4from typing import Optional
5from typing_extensions import TypedDict
6
7class State(TypedDict):
8 topic: str
9 plan: str
10 search_results: str
11 draft: str
12 decision: Optional[str]
13 feedback: Optional[str]

Expected result: A State TypedDict with six fields, none populated yet.

2

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.

typescript
1import anthropic
2client = anthropic.Anthropic()
3
4def 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}
10
11def search_node(state: State) -> dict:
12 # Stub: swap for a real search API call in production
13 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.

3

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.

typescript
1from langgraph.types import interrupt
2
3def 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}
10
11def 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.

4

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.

typescript
1from langgraph.graph import StateGraph, START, END
2from langgraph.checkpoint.memory import InMemorySaver
3
4def finalize_node(state: State) -> dict:
5 return {"draft": state["draft"] + "\n\n[Approved and finalized]"}
6
7def route_after_review(state: State) -> str:
8 return "finalize" if state["decision"] == "approve" else "synthesize"
9
10builder = 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)
22
23graph = builder.compile(checkpointer=InMemorySaver())

Expected result: graph is a compiled, runnable StateGraph with a loop from review back to synthesize on rejection.

5

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().

typescript
1import uuid
2
3config = {"configurable": {"thread_id": str(uuid.uuid4())}}
4result = graph.invoke({"topic": "edge AI chips", "feedback": None}, config)
5
6if "__interrupt__" in result:
7 payload = result["__interrupt__"][0].value
8 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.

6

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.

typescript
1from langgraph.types import Command
2
3# Human requests a change
4result = 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"])
7
8# Human approves the next draft
9final = 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

research_agent_graph.py
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 uuid
5from typing import Optional
6
7import anthropic
8from langgraph.checkpoint.memory import InMemorySaver
9from langgraph.graph import END, START, StateGraph
10from langgraph.types import Command, interrupt
11from typing_extensions import TypedDict
12
13client = anthropic.Anthropic()
14
15
16class State(TypedDict):
17 topic: str
18 plan: str
19 search_results: str
20 draft: str
21 decision: Optional[str]
22 feedback: Optional[str]
23
24
25def 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}
32
33
34def search_node(state: State) -> dict:
35 # Stub: swap for a real search API call (Tavily, Exa, Brave Search) in production
36 return {"search_results": f"Findings for '{state['topic']}' based on plan:\n{state['plan']}"}
37
38
39def 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}
47
48
49def 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", "")}
54
55
56def finalize_node(state: State) -> dict:
57 return {"draft": state["draft"] + "\n\n[Approved and finalized]"}
58
59
60def route_after_review(state: State) -> str:
61 return "finalize" if state["decision"] == "approve" else "synthesize"
62
63
64builder = 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)
76
77graph = builder.compile(checkpointer=InMemorySaver())
78
79
80if __name__ == "__main__":
81 config = {"configurable": {"thread_id": str(uuid.uuid4())}}
82 result = graph.invoke({"topic": "edge AI chips", "feedback": None}, config)
83
84 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 )
90
91 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)
94
95 print("\nFinal:\n", result["draft"])
96

Common 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.

ChatGPT Prompt

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]

Build AI Agents Prompt

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.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.