Build a LangGraph Agent
Reading twelve modules without a file that actually pauses is how people think they know LangGraph. This file is the test.
A tiny clinic: front desk classifies, a doctor talks, a pharmacy looks up the order, a manager signs refunds, then the desk replies. You are wiring that clinic.
Visual Workflows
Start here — scroll inside each diagram frame to explore, then use + / − to zoom up to 200% if needed.
Overview
Scroll inside the frame to explore · use + / − to zoom up to 200%
Scroll inside the frame to explore · use + / − to zoom up to 200%
Key Takeaways
- 1.This is the only LangGraph module you run on your machine — follow the steps in order. You will build one support graph that uses every idea from this phase: state, nodes, edges, routing, tools, checkpoints, HITL, streaming, time travel, a billing subgraph.
- 2.Work in Terminal first, then paste files, then run, then resume the interrupt, then stream, then inspect state. Do not skip the interrupt. If it never pauses, you did not build the graph we drew.
- 3.SqliteSaver writes checkpoints.db so a new terminal can resume the same thread_id. The first run classifies, may call lookup_order, then interrupt()s before create_refund.
- 4.The second command resumes ticket-1. Then you stream and print get_state so time travel is not abstract.
Learn elsewhere
- →LangGraph Platform — Studio for the same graph
- →AG-UI
Real Example
Scenario
Type: Refund order 4411, it is damaged. The graph should pause. After you resume with approve, it should print that the refund ran.
What you would do
If it never pauses, refund_gate is not on the path — check tools_condition vs the gate edge. If resume starts over, thread_id changed. If import errors, you are not in the venv.
Build it step by step
Do these in order. Each step says where to work, what to install or edit, and when.
Step 1Check Python
Where · Your Mac Terminal — any folder.
Need Python 3.10+. Prefer 3.11 or 3.12.
Terminal1python3 --versionStep 2Create the folder and venv
Where · Documents is fine. This is a new folder, not inside another project.
venv keeps LangGraph off your system Python. Activate it before every later command in this module.
Terminal1mkdir -p ~/Documents/support_graph && cd ~/Documents/support_graph && python3 -m venv .venv && source .venv/bin/activateStep 3Install packages
Where · MUST be inside support_graph/ with the venv activated (your prompt should show .venv).
LangGraph runtime, a SQLite checkpointer so resume works in a new terminal, OpenAI chat model, dotenv for the key.
Terminal1pip install -U langgraph langgraph-checkpoint-sqlite langchain-openai langchain-core python-dotenvStep 4Add your OpenAI API key
Where · File: support_graph/.env — never commit this file.
No quotes. Save. The script loads it. You do not paste the key into Python.
.env1OPENAI_API_KEY=sk-your-real-key-hereStep 5Write the graph — every module in one file
Where · File: support_graph/graph.py — create it and paste the whole file.
Read the comments. They map to the modules you just finished: state, nodes, edges, routing, tools, checkpoints, interrupt, subgraph, stream, get_state.
graph.py1import argparse2import os3import sqlite34from typing import Annotated, Literal, TypedDict5 6from dotenv import load_dotenv7from langchain_core.messages import AnyMessage, HumanMessage8from langchain_core.tools import tool9from langchain_openai import ChatOpenAI10from langgraph.checkpoint.sqlite import SqliteSaver11from langgraph.graph import END, START, StateGraph12from langgraph.graph.message import add_messages13from langgraph.prebuilt import ToolNode14from langgraph.types import Command, interrupt15 16load_dotenv()17 18THREAD = {"configurable": {"thread_id": "ticket-1"}}19DB = "checkpoints.db"20 21 22class AgentState(TypedDict):23 messages: Annotated[list[AnyMessage], add_messages]24 ticket_type: str25 refund_approved: bool26 27 28@tool29def lookup_order(order_id: str) -> str:30 """Look up a fake order. Read-only."""31 return f"Order {order_id}: status=delivered, total=480, item=headphones"32 33 34@tool35def create_refund(order_id: str, amount: int) -> str:36 """Create a fake refund. Write — only call after a human approved."""37 return f"Refunded {amount} for order {order_id}"38 39 40tools = [lookup_order, create_refund]41model = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)42 43 44def classify(state: AgentState) -> dict:45 text = state["messages"][-1].content.lower()46 ticket_type = "billing" if "refund" in text or "charge" in text else "tech"47 return {"ticket_type": ticket_type}48 49 50def assistant(state: AgentState) -> dict:51 sys = (52 f"You are support. ticket_type={state.get('ticket_type')}. "53 "Use lookup_order before you promise facts. "54 "If the user wants a refund, call create_refund only after approval is mentioned in tools."55 )56 reply = model.invoke([{"role": "system", "content": sys}, *state["messages"]])57 return {"messages": [reply]}58 59 60def refund_gate(state: AgentState) -> dict:61 decision = interrupt(62 {63 "question": "Approve this refund?",64 "ticket_type": state.get("ticket_type"),65 }66 )67 return {"refund_approved": bool(decision)}68 69 70def reply(state: AgentState) -> dict:71 status = "approved" if state.get("refund_approved") else "closed"72 return {"messages": [HumanMessage(content=f"(desk) Ticket {status}.")]}73 74 75billing = StateGraph(AgentState)76billing.add_node("pay", ToolNode([create_refund]))77billing.add_edge(START, "pay")78billing.add_edge("pay", END)79billing_graph = billing.compile()80 81 82def route_after_assistant(state: AgentState) -> Literal["tools", "refund_gate", "reply"]:83 last = state["messages"][-1]84 if getattr(last, "tool_calls", None):85 names = [c["name"] for c in last.tool_calls]86 if "create_refund" in names:87 return "refund_gate"88 return "tools"89 return "reply"90 91 92def build():93 g = StateGraph(AgentState)94 g.add_node("classify", classify)95 g.add_node("assistant", assistant)96 g.add_node("tools", ToolNode([lookup_order]))97 g.add_node("refund_gate", refund_gate)98 g.add_node("billing", billing_graph)99 g.add_node("reply", reply)100 g.add_edge(START, "classify")101 g.add_edge("classify", "assistant")102 g.add_conditional_edges(103 "assistant",104 route_after_assistant,105 {"tools": "tools", "refund_gate": "refund_gate", "reply": "reply"},106 )107 g.add_edge("tools", "assistant")108 g.add_edge("refund_gate", "billing")109 g.add_edge("billing", "reply")110 g.add_edge("reply", END)111 conn = sqlite3.connect(DB, check_same_thread=False)112 return g.compile(checkpointer=SqliteSaver(conn))113 114 115def run_new(graph):116 try:117 result = graph.invoke(118 {"messages": [HumanMessage(content="Refund order 4411, it is damaged")]},119 THREAD,120 )121 print(result)122 if isinstance(result, dict) and result.get("__interrupt__"):123 print("Paused for HITL. Next: python graph.py --resume")124 except Exception as exc:125 print("Paused for HITL (this is expected):", exc)126 print("Next: python graph.py --resume")127 128 129def run_resume(graph):130 result = graph.invoke(Command(resume=True), THREAD)131 print(result)132 133 134def run_stream(graph):135 for event in graph.stream(136 {"messages": [HumanMessage(content="Where is order 4411?")]},137 {"configurable": {"thread_id": "ticket-2"}},138 stream_mode="updates",139 ):140 print(event)141 142 143def run_inspect(graph):144 snap = graph.get_state(THREAD)145 print(snap.values)146 print("--- history ---")147 for frame in graph.get_state_history(THREAD):148 print(frame.config, frame.next)149 150 151if __name__ == "__main__":152 if not os.getenv("OPENAI_API_KEY"):153 raise SystemExit("Set OPENAI_API_KEY in .env")154 p = argparse.ArgumentParser()155 p.add_argument("--resume", action="store_true")156 p.add_argument("--stream", action="store_true")157 p.add_argument("--inspect", action="store_true")158 args = p.parse_args()159 app = build()160 if args.resume:161 run_resume(app)162 elif args.stream:163 run_stream(app)164 elif args.inspect:165 run_inspect(app)166 else:167 run_new(app)Step 6Run until the interrupt
Where · Terminal, inside support_graph/, venv still activated.
First run should pause on refund_gate. That pause is success — you may see an interrupt payload or a GraphInterrupt message. Do not delete checkpoints.db. Then run --resume. If it prints a full reply and exits without pausing, the gate was skipped.
Terminal1cd ~/Documents/support_graph && source .venv/bin/activate && python graph.pyStep 7Resume the same thread
Where · Same Terminal, same folder, same venv. Do not change thread_id.
This is HITL + checkpoints. Command(resume=True) unblocks interrupt() using checkpoints.db. You should see create_refund run inside the billing subgraph, then the desk reply. If it starts a brand new ticket, you are in the wrong folder.
Terminal1python graph.py --resumeStep 8Stream a different ticket
Where · Same folder. This uses ticket-2 so you do not collide with the paused/resumed thread.
Streaming module: each printed dict is a node update. You should see classify, then assistant, then maybe tools.
Terminal1python graph.py --streamStep 9Inspect checkpoints (time travel)
Where · Same folder. Looks at ticket-1, the thread you refunded.
get_state is now. get_state_history is the CCTV. If history is empty, checkpoints.db was not written — you ran from a different folder or skipped the first invoke.
Terminal1python graph.py --inspect
Commands
Commands to Remember
cd ~/Documents/support_graph && source .venv/bin/activatepip install -U langgraph langgraph-checkpoint-sqlite langchain-openai langchain-core python-dotenvpython graph.py # runs until interruptpython graph.py --resume # same thread_idpython graph.py --streampython graph.py --inspect
Cheat Sheet
Quick recap
quick ref- •venv + pip first
- •graph.py is the whole clinic
- •First run pauses
- •python graph.py --resume uses checkpoints.db
- •--stream and --inspect after
Common Mistakes
- ✕Running python graph.py without activating the venv
- ✕Starting a new thread on resume by editing THREAD
- ✕Skipping the first run and only using --resume
- ✕Committing .env
- ✕Deleting checkpoints.db and then running --resume — there is nothing to resume
