AI Frameworks · LangGraph 1.x

LangGraph: Complete Interactive Notes

⏱ 25 min read🎯 Intermediate🖼 2 diagrams🧪 2 live demos🐍 Python code

LangChain chains run in a straight line. Real agents need loops (try again), branches (if risky, ask a human), memory (resume tomorrow) and control (never pay without approval). LangGraph gives you all of that by modelling your app as a graph. These notes build up every feature with small, runnable examples.

In this session

  • Why graphs: chains vs agents vs workflows
  • State, nodes, edges, START and END
  • Conditional edges, reducers and cycles (step-by-step demo)
  • Checkpointers: memory, threads and time travel
  • Human-in-the-loop with interrupt
  • Streaming, Send for parallel work and Command handoffs
  • A ReAct tool-calling agent from scratch

01Why LangGraph?

STARTscorenodelow riskhigh riskapprovenodehuman reviewinterrupt()ENDshared STATE {amount, risk, decision}: read and updated by every node
A LangGraph app is a graph: nodes are functions, edges decide what runs next, and one shared state flows through it.
Chain (LCEL)Agent (create_agent)Graph (LangGraph)
Stepsfixed, in a linedecided by the LLMyou design them; the LLM can choose some
Loopsnoyes (tool loop)any loop you want
Human approvalnovia middlewarebuilt in (interrupt)
Best forsimple pipelinesopen-ended tool usebusiness workflows, multi-agent systems
🚆

Like a railway map. Stations are nodes (do some work), tracks are edges (where to go next), and junctions are conditional edges (switch tracks based on a signal). The train carries the passengers and luggage: that is the state.

Good to know: LangChain’s create_agent is itself built on LangGraph. Use create_agent for a standard tool-calling agent; drop down to LangGraph when you need custom control flow.

02State, nodes and edges

Three ideas: the state is a typed dict shared by everyone; a node is a function that reads the state and returns the keys it wants to change; edges connect nodes. compile() turns the graph into a runnable app.

lg1_basics.py
from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):                     # 1. the shared state every node reads and updates
    amount: int
    risk: str
    decision: str

def score(state: State):                    # 2. a node = a plain function: state in, partial update out
    return {"risk": "high" if state["amount"] > 50_000 else "low"}

def approve(state: State):
    return {"decision": f"approved ₹{state['amount']:,} ({state['risk']} risk)"}

g = StateGraph(State)
g.add_node("score", score)
g.add_node("approve", approve)
g.add_edge(START, "score")                  # 3. edges = the order of steps
g.add_edge("score", "approve")
g.add_edge("approve", END)
app = g.compile()                           # 4. compile -> a runnable app (invoke, stream, batch)

print(app.invoke({"amount": 20_000}))
print([l.strip() for l in app.get_graph().draw_mermaid().splitlines() if "-->" in l])   # diagram as Mermaid text
{'amount': 20000, 'risk': 'low', 'decision': 'approved ₹20,000 (low risk)'} ['__start__ --> score;', 'score --> approve;', 'approve --> __end__;']

03Conditional edges

A router function looks at the state and returns the name of the next node. Attach it with add_conditional_edges:

lg2_conditional.py
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    amount: int
    risk: str
    decision: str

def score(s): return {"risk": "high" if s["amount"] > 50_000 else "low"}
def approve(s): return {"decision": "auto-approved"}
def review(s): return {"decision": "sent to a human reviewer"}

def route(s) -> Literal["approve", "review"]:   # a router returns the NAME of the next node
    return "review" if s["risk"] == "high" else "approve"

g = StateGraph(State)
for name, fn in [("score", score), ("approve", approve), ("review", review)]: g.add_node(name, fn)
g.add_edge(START, "score")
g.add_conditional_edges("score", route)        # branch!
g.add_edge("approve", END); g.add_edge("review", END)
app = g.compile()

for amt in [20_000, 80_000]:
    print(f"₹{amt:,} ->", app.invoke({"amount": amt})["decision"])
₹20,000 -> auto-approved ₹80,000 -> sent to a human reviewer
Type the router’s return value with Literal["approve", "review"]: LangGraph uses it to draw the diagram and catch typos.

04Reducers and cycles

By default a node’s update overwrites a key. A reducer changes that: Annotated[list, operator.add] means “append”. Combined with an edge that points backwards, you get a cycle: search, and if nothing is found, rewrite the query and search again.

lg3_reducers_loop.py
import operator
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    query: str
    tries: int
    log: Annotated[list[str], operator.add]     # reducer: new items are APPENDED, not overwritten

KB = {"fixed deposit penalty": "Breaking an FD early reduces interest by 1%."}

def search(s):
    hit = KB.get(s["query"])
    return {"tries": s["tries"] + 1, "log": [f"try {s['tries'] + 1}: search '{s['query']}' -> {'found' if hit else 'nothing'}"]}

def rewrite(s):
    return {"query": s["query"].replace("FD", "fixed deposit"), "log": ["rewrote the query"]}

def answer(s):
    return {"log": ["answer: " + KB.get(s["query"], "Sorry, I could not find it.")]}

def next_step(s):
    if s["query"] in KB: return "answer"
    return "rewrite" if s["tries"] < 3 else "answer"

g = StateGraph(State)
for n, f in [("search", search), ("rewrite", rewrite), ("answer", answer)]: g.add_node(n, f)
g.add_edge(START, "search")
g.add_conditional_edges("search", next_step)
g.add_edge("rewrite", "search")                 # a CYCLE: go back and try again
g.add_edge("answer", END)
app = g.compile()

for line in app.invoke({"query": "FD penalty", "tries": 0, "log": []})["log"]: print(line)
try 1: search 'FD penalty' -> nothing rewrote the query try 2: search 'fixed deposit penalty' -> found answer: Breaking an FD early reduces interest by 1%.
Demo · Watch the state change at every step
Always add an exit. Here the router stops after 3 tries. Also set a safety limit in the config, e.g. app.invoke(inputs, {"recursion_limit": 25}): after 25 steps LangGraph raises GraphRecursionError instead of looping. Recent versions default to a very high limit, so do not rely on the default.

For chat apps, use the built-in MessagesState: its messages key uses the add_messages reducer, which appends new messages and updates existing ones by ID.

05Checkpointers: memory, threads and time travel

Compile with a checkpointer and LangGraph saves the state after every step, per thread_id. That gives you conversation memory, crash recovery and the full history of every run:

lg4_memory.py
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver   # use SqliteSaver / PostgresSaver in production

class State(TypedDict):
    items: Annotated[list[str], operator.add]

def add_item(s): return {}                               # the input itself is the update here
g = StateGraph(State); g.add_node("add", add_item); g.add_edge(START, "add"); g.add_edge("add", END)
app = g.compile(checkpointer=InMemorySaver())

cart = {"configurable": {"thread_id": "cart-42"}}
app.invoke({"items": ["gold loan form"]}, cart)
app.invoke({"items": ["KYC update"]}, cart)              # same thread: state continues
print("current state:", app.get_state(cart).values)

history = list(app.get_state_history(cart))              # every checkpoint is kept: "time travel"
print("checkpoints saved:", len(history))
print("oldest with items:", [h.values for h in history if h.values.get("items")][-1])
current state: {'items': ['gold loan form', 'KYC update']} checkpoints saved: 6 oldest with items: {'items': ['gold loan form']}
CheckpointerPackageUse
InMemorySaverlanggraphtests and demos (lost on restart)
SqliteSaverlanggraph-checkpoint-sqlitesingle-machine apps
PostgresSaverlanggraph-checkpoint-postgresproduction

06Human-in-the-loop with interrupt

Some steps must never run without a person saying yes: payments, emails to customers, deleting data. Call interrupt() inside a node: the graph pauses, saves its state, and returns the question. Later, resume with Command(resume=...):

lg5_interrupt.py
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command

class State(TypedDict):
    amount: int
    status: str

def ask_human(s):
    answer = interrupt({"question": f"Approve transfer of ₹{s['amount']:,}?"})   # pause here
    return {"status": "approved" if answer == "yes" else "rejected"}

def execute(s):
    return {"status": s["status"] + (" and sent" if s["status"] == "approved" else "")}

g = StateGraph(State)
g.add_node("ask_human", ask_human); g.add_node("execute", execute)
g.add_edge(START, "ask_human"); g.add_edge("ask_human", "execute"); g.add_edge("execute", END)
app = g.compile(checkpointer=InMemorySaver())             # interrupts need a checkpointer

cfg = {"configurable": {"thread_id": "tx-9"}}
first = app.invoke({"amount": 75_000, "status": ""}, cfg)
print("paused, asking:", first["__interrupt__"][0].value)
print("next node waiting:", app.get_state(cfg).next)

final = app.invoke(Command(resume="yes"), cfg)            # the human replies; the graph continues
print("result:", final["status"])
paused, asking: {'question': 'Approve transfer of ₹75,000?'} next node waiting: ('ask_human',) result: approved and sent
Rununtil interrupt()Pausestate saved, question returnedHumanapproves in your UIResumeCommand(resume="yes")Continueexecute → END
The pause can last seconds or days: the checkpointer keeps everything.
Interrupts need a checkpointer and a thread_id, because the graph must remember where it stopped.

07Streaming, Send and Command

Streaming shows progress as each node finishes. Send starts one node per item, in parallel, and the reducer collects the results (map-reduce):

lg6_stream_send.py
import operator
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send

class State(TypedDict):
    docs: list[str]
    summaries: Annotated[list[str], operator.add]

class DocState(TypedDict):
    doc: str

def fan_out(s):                                  # Send = run one node per item, in parallel (map-reduce)
    return [Send("summarise", {"doc": d}) for d in s["docs"]]

def summarise(s: DocState):
    return {"summaries": [s["doc"].split(":")[0] + " ✔"]}

def combine(s):
    return {"summaries": [f"TOTAL {len(s['summaries'])} documents summarised"]}

g = StateGraph(State)
g.add_node("summarise", summarise); g.add_node("combine", combine)
g.add_conditional_edges(START, fan_out, ["summarise"])
g.add_edge("summarise", "combine"); g.add_edge("combine", END)
app = g.compile()

docs = ["KYC policy: ...", "FD policy: ...", "Loan policy: ..."]
for update in app.stream({"docs": docs, "summaries": []}, stream_mode="updates"):   # watch each step finish
    print(update)
{'summarise': {'summaries': ['KYC policy ✔']}} {'summarise': {'summaries': ['Loan policy ✔']}} {'summarise': {'summaries': ['FD policy ✔']}} {'combine': {'summaries': ['TOTAL 3 documents summarised']}}

The three “summarise” updates ran in parallel, so their order can change from run to run; the reducer collects all of them before “combine”.

stream_modeYou get
"values"the full state after every step
"updates"only what each node changed (shown above)
"messages"LLM tokens as they are generated, for chat UIs
"custom"your own progress events from inside nodes

Command lets a node update the state and choose the next node in one return value. It is the cleanest way to build a supervisor that hands work to specialist agents:

lg7_handoff.py
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command

class State(TypedDict):
    question: str
    trail: list[str]
    answer: str

def supervisor(s) -> Command[Literal["cards_agent", "loans_agent"]]:
    target = "cards_agent" if "card" in s["question"] else "loans_agent"
    return Command(goto=target, update={"trail": s["trail"] + [f"supervisor -> {target}"]})  # route AND update

def cards_agent(s):
    return {"trail": s["trail"] + ["cards_agent answered"], "answer": "Card blocked. A new one arrives in 5 days."}

def loans_agent(s):
    return {"trail": s["trail"] + ["loans_agent answered"], "answer": "Your EMI is due on the 5th."}

g = StateGraph(State)
for n, f in [("supervisor", supervisor), ("cards_agent", cards_agent), ("loans_agent", loans_agent)]: g.add_node(n, f)
g.add_edge(START, "supervisor"); g.add_edge("cards_agent", END); g.add_edge("loans_agent", END)
app = g.compile()

for q in ["Please block my debit card", "When is my home loan EMI due?"]:
    r = app.invoke({"question": q, "trail": [], "answer": ""})
    print(q, "\n   ", " | ".join(r["trail"]), "\n   ", r["answer"])
Please block my debit card supervisor -> cards_agent | cards_agent answered Card blocked. A new one arrives in 5 days. When is my home loan EMI due? supervisor -> loans_agent | loans_agent answered Your EMI is due on the 5th.
Demo · Which multi-agent pattern?

08A ReAct agent from scratch

Now everything together: a model node, a tool node and a loop between them. This is the classic ReAct (reason + act) agent, written in 15 lines:

lg8_messages.py
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_core.tools import tool
from langchain_core.messages import AIMessage
from fake_llm import ScriptedModel

@tool
def fd_rate(years: int) -> str:
    """Return the fixed-deposit interest rate for a tenure in years."""
    return {1: "6.8%", 3: "7.1%", 5: "7.0%"}.get(years, "not offered")

model = ScriptedModel([AIMessage("", tool_calls=[{"name": "fd_rate", "args": {"years": 3}, "id": "t1"}]),
                       "The 3-year FD rate is 7.1%."]).bind_tools([fd_rate])

def call_model(state: MessagesState):              # MessagesState = {"messages": [...]} with add_messages
    return {"messages": [model.invoke(state["messages"])]}

g = StateGraph(MessagesState)
g.add_node("model", call_model)
g.add_node("tools", ToolNode([fd_rate]))           # runs whatever tools the model asked for
g.add_edge(START, "model")
g.add_conditional_edges("model", tools_condition)  # tool calls? -> "tools", else -> END
g.add_edge("tools", "model")                       # the ReAct loop
app = g.compile()

for m in app.invoke({"messages": [("user", "What is the 3-year FD rate?")]})["messages"]:
    print(f"{m.type:5s}", m.tool_calls if getattr(m, "tool_calls", None) else m.content)
human What is the 3-year FD rate? ai [{'name': 'fd_rate', 'args': {'years': 3}, 'id': 't1', 'type': 'tool_call'}] tool 7.1% ai The 3-year FD rate is 7.1%.
MistakeFix
Returning the whole state from a nodereturn only the keys you change
Lists overwritten instead of appendedadd a reducer: Annotated[list, operator.add]
Loop never endscount tries in the state and route to an exit; pass {"recursion_limit": 25}
Interrupt does nothingcompile with a checkpointer and pass a thread_id
Using InMemorySaver in productionuse the Postgres or SQLite checkpointer

09Summary

10Quick quiz

1. What does a LangGraph node return?

2. How do you make a list key append instead of overwrite?

3. What is required for interrupt() to work?

4. Which feature runs one node per item in parallel?

5. What does Command(goto="cards_agent", update={...}) do?

11References

Created by Ajeetkumar