← Back to list

AI Agents That Never Forget: LangGraph + PostgreSQL Checkpointing

Build agents that pause, remember, and resume

Sanjana Dubey · 2026-05-19 12:20 · 1 claps · 14.3 min read
#ai-agent #langgraph #checkpointing #human-in-loop #postgres-checkpointing
Open on Medium ↗
Wiki topics: AGT · AI Agents

AI Agents That Never Forget: LangGraph + PostgreSQL Checkpointing

Build agents that pause, remember, and resume

25 min read · Beginner–Intermediate · Python 3.10+

Imagine you build an AI agent that helps users plan a vacation. It searches for flights, compares hotels, checks the weather, and drafts an itinerary. It’s doing great — and then your server crashes after step 4.

All that work is gone. The agent has to start over from scratch.

Or picture a different problem: your agent is about to book a non-refundable hotel for ₹15,000. Wouldn’t it be nice if it paused and asked you first before hitting confirm?

These are the two problems this blog solves:

  • Checkpointing — automatically save an agent’s state to PostgreSQL so it can survive crashes, resume mid-task, and remember past conversations across sessions.
  • Interrupts — pause the agent at a specific step, let a human review what’s about to happen, then resume exactly where it left off.

For this demo, we’ll use Ollama to run the LLM completely offline on your local machine — so there’s no need for an OpenAI account, external APIs, or paid credits.

💡 Who is this for? Python developers who’ve heard of LangGraph but haven’t used checkpointing or interrupts yet. We’ll go from zero to a fully working trip-planner agent.

Table of Contents

  1. Core Concepts in 5 Minutes
  2. Setup: Ollama + LangGraph + PostgreSQL
  3. PostgreSQL Checkpointing: Your Agent’s Long-Term Memory
  4. Interrupts: Putting Humans Back in the Loop
  5. Full Example: Trip Planner Agent
  6. Wrap-Up & What’s Next

1. Core Concepts in 5 Minutes

Before we write a single line of code, let’s build a mental model. LangGraph works like a flowchart that thinks. Here are the four things you need to understand.

Fig 1

Fig 1

State — the shared notebook

State is a Python dictionary (defined as a TypedDict) that every node in your graph can read and write to. Think of it as a shared Google Doc that all your agent steps collaborate on. One node writes the hotel list. The next node reads it and picks the best one.

from typing import TypedDict, List, Annotated, Optional
from langchain_core.messages import BaseMessage
import operator
class TripState(TypedDict):
    messages:     Annotated[List[BaseMessage], operator.add]  # chat history
    destination:  str          # e.g. "Goa"
    budget:       str          # "budget" | "mid-range" | "luxury"
    hotels:       List[str]    # found hotel options
    chosen_hotel: Optional[str]
    itinerary:    Optional[str]
    approved:     bool         # human gave the go-ahead

The Annotated[List[BaseMessage], operator.add] part just means "when messages are updated, append them instead of replacing them." Every other field is a simple overwrite.

Nodes — the workers

Each node is a Python function. It receives the current state, does some work (calls the LLM, queries an API, runs a calculation), and returns a dictionary of only the values it wants to update. The rest of the spytate stays untouched.

def search_hotels(state: TripState) -> dict:
    destination = state["destination"]
    # call an API, ask the LLM, whatever you need
    hotels = ["Hotel Sunset Goa", "Beach Resort Goa", "Alila Diwa Goa"]
    return {"hotels": hotels}  # only update the hotels key

Edges — the decision arrows

Edges connect nodes. A normal edge always goes A → B. A conditional edge is a function that looks at the current state and returns the name of the next node to visit — this is how you branch your logic.

def should_book(state: TripState) -> str:
    if state.get("approved"):
        return "book_hotel"   # go to booking node
    return "__end__"          # stop the graph

Graph — the assembled machine

The graph ties everything together: you add nodes, connect them with edges, set the entry point, and compile it. Here’s the skeleton:

from langgraph.graph import StateGraph, END

builder = StateGraph(TripState)
builder.add_node("greet_user",    greet_user)
builder.add_node("search_hotels", search_hotels)
builder.add_node("book_hotel",    book_hotel)
builder.set_entry_point("greet_user")
builder.add_edge("greet_user", "search_hotels")
builder.add_conditional_edges(
    "search_hotels",
    should_book,
    {"book_hotel": "book_hotel", END: END}
)
builder.add_edge("book_hotel", END)
graph = builder.compile()

Fig 2— A simple LangGraph: nodes do work, a conditional edge routes based on human approval.

Fig 2— A simple LangGraph: nodes do work, a conditional edge routes based on human approval.

2. Setup: Ollama + LangGraph + PostgreSQL

Step 1 — Install Ollama and pull a model

Ollama lets you run LLMs locally in one command. We’ll use llama3.2:3b — small enough to run on most laptops (8 GB RAM or more recommended).

# Install Ollama (Linux/macOS)
curl -fsSL https://ollama.com/install.sh | sh
# On Windows: download from https://ollama.com/download
# Pull the model (downloads ~2 GB)
ollama pull llama3.2:3b
# Test it works
ollama run llama3.2:3b "Say hello in one sentence"

💡 Model options: llama3.2:3b (2 GB) — fast, perfect for this tutorial. llama3.1:8b (4.7 GB) — better quality, needs more RAM. mistral:7b (4.1 GB) — solid alternative if llama3 doesn't work for you.

Step 2 — Install Python dependencies

pip install langgraph langchain-ollama langchain-core psycopg2-binary

Step 3 — Start PostgreSQL

The easiest way is Docker. If you already have Postgres running locally, skip this and just create a database called checkpoints.

# Start a Postgres container
docker run \
  --name langgraph-pg \
  -e POSTGRES_USER=langgraph \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=checkpoints \
  -p 5432:5432 \
  -d postgres:16
# Verify it's running
docker ps | grep langgraph-pg

We’ll use this connection string throughout the tutorial:

postgresql://langgraph:secret@localhost:5432/checkpoints

Step 4 — Verify everything works together

Run this quick sanity check before going further:

# verify_setup.py
from langchain_ollama import ChatOllama
import psycopg2
# 1. Test Ollama
llm = ChatOllama(model="llama3.2:3b")
response = llm.invoke("Say 'LangGraph rocks!' and nothing else.")
print("Ollama ✓", response.content)
# 2. Test PostgreSQL
conn = psycopg2.connect(
    "postgresql://langgraph:secret@localhost:5432/checkpoints"
)
print("PostgreSQL ✓ connected")
conn.close()

Expected output:

Ollama ✓ LangGraph rocks!
PostgreSQL ✓ connected

If both lines print, you’re good to go.

3. PostgreSQL Checkpointing: Your Agent’s Long-Term Memory

The video game analogy

Remember playing an RPG where if you forgot to save, you’d lose hours of progress? LangGraph checkpointing is your save button. After every node runs, LangGraph automatically serializes the entire state and writes it to Postgres. If anything crashes — or if you just want to continue a conversation days later — you pass the same thread_id and the agent picks up exactly where it stopped.

Fig 3— After each node runs, state is saved to Postgres. A crash mid-way is recoverable — restart with the same thread_id and the agent resumes from Node C.

Fig 3— After each node runs, state is saved to Postgres. A crash mid-way is recoverable — restart with the same thread_id and the agent resumes from Node C.

Wiring up the PostgreSQL checkpointer

# checkpointer_setup.py
import psycopg2
from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = "postgresql://langgraph:secret@localhost:5432/checkpoints"
def get_checkpointer():
    conn = psycopg2.connect(DB_URI)
    checkpointer = PostgresSaver(conn)
    # Creates required tables on first run - safe to call multiple times
    checkpointer.setup()
    return checkpointer

📌 What does .setup() create? It creates two tables in your database: checkpoints (stores the full serialized state) and checkpoint_writes (stores pending writes). You never need to touch these directly — LangGraph manages them for you.

Compiling the graph with a checkpointer

The only change from a regular graph compile is one argument:

graph = builder.compile(checkpointer=get_checkpointer())

That’s it. LangGraph takes care of everything else automatically.

The magic: thread_id

Every invocation must pass a thread_id inside a configurable dictionary. This is the unique key LangGraph uses to find and restore the right checkpoint. Same key = same conversation, resumed from the last saved state.

from langchain_core.messages import HumanMessage

config = {"configurable": {"thread_id": "trip-001"}}
# First run - agent processes this and saves state
graph.invoke(
    {
        "messages":    [HumanMessage(content="Plan a trip to Goa")],
        "destination": "Goa",
    },
    config
)
# Days later - same thread_id loads previous state from DB automatically
graph.invoke(
    {"messages": [HumanMessage(content="Also add a beach day on Saturday")]},
    config
)

💡 Real-world tip: Use meaningful thread_id values like "user-42-session-7" or "booking-{uuid}". In a web app, this maps naturally to a user's conversation or session ID.

4. Interrupts: Putting Humans Back in the Loop

Checkpointing keeps the agent’s memory alive. Interrupts let you pause the agent mid-execution so a human can review, approve, or modify what’s about to happen before the agent continues.

The classic use case: your agent is about to spend money, send an email, or delete data. You want a human to see the plan first and say “go ahead” — or “stop, change this.”

Fig 4— interrupt_before=["book_hotel"] pauses the agent. The state is already saved (via checkpointing). The human reviews, then calls graph.invoke(None, config) to resume.

Fig 4— interrupt_before=["book_hotel"] pauses the agent. The state is already saved (via checkpointing). The human reviews, then calls graph.invoke(None, config) to resume.

Setting up an interrupt

Adding an interrupt takes one extra argument at compile time:

graph = builder.compile(
    checkpointer=get_checkpointer(),
    interrupt_before=["book_hotel"]  # pause before this node runs
)

You can also use interrupt_after if you want the node to run first and then pause for review.

The full interrupt lifecycle

Here’s the complete pattern — run, pause, inspect, resume:

config = {"configurable": {"thread_id": "trip-001"}}

# ── STEP 1: Run until the interrupt ──────────────────────
graph.invoke(
    {
        "messages":    [HumanMessage(content="Plan a trip to Goa")],
        "destination": "Goa",
        "budget":      "mid-range",
        "hotels":      [],
        "approved":    False,
        "chosen_hotel": None,
        "itinerary":   None,
    },
    config
)
# The agent STOPS before book_hotel. State is already saved to Postgres.
# ── STEP 2: Inspect the state ────────────────────────────
snapshot = graph.get_state(config)
print("Hotels found:", snapshot.values["hotels"])
# Hotels found: ['Hotel Sunset Goa', 'Beach Resort Goa', 'Alila Diwa Goa']
print("Next node:", snapshot.next)
# Next node: ('book_hotel',)
# ── STEP 3a: Approve and resume (no changes) ─────────────
graph.invoke(None, config)   # None = "continue as is"
# ── STEP 3b: Or update state, then resume ────────────────
graph.update_state(
    config,
    {"approved": True, "chosen_hotel": "Alila Diwa Goa"}
)
graph.invoke(None, config)   # continues from book_hotel with your changes

⚠️ Important: graph.invoke(None, config) only works if a checkpointer is attached. Without one, LangGraph has no idea where to resume from. Checkpointing and interrupts are a team — they need each other.

5. Full Example: Trip Planner Agent

Now let’s put it all together. Here’s a complete, working trip-planner agent that uses everything we’ve covered:

  1. Takes a destination from the user
  2. Uses Ollama (llama3.2:3b) to find and describe hotels
  3. Pauses for human approval (interrupt)
  4. Books the chosen hotel and generates a 3-day itinerary
  5. Persists the entire conversation in PostgreSQL

Save the following as trip_agent.py and run it with python trip_agent.py.

# trip_agent.py
# Full working trip planner — LangGraph + PostgreSQL + Ollama
#
# Install:  pip install langgraph langchain-ollama langchain-core psycopg2-binary
# Start PG: docker run --name langgraph-pg -e POSTGRES_USER=langgraph \
#             -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=checkpoints \
#             -p 5432:5432 -d postgres:16
# Run:      python trip_agent.py

import operator
import os
import sys
from typing import TypedDict, List, Annotated, Optional

from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_ollama import ChatOllama
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres import PostgresSaver
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.rule import Rule
from rich.prompt import Prompt, Confirm

DB_URI = "postgresql://langgraph:secret@localhost:5432/checkpoints"
MODEL_NAME  = "llama3.2:3b"

BUDGETS     = ["budget", "mid-range", "luxury"]
DAY_OPTIONS = ["2", "3", "4", "5", "7"]

console = Console()
llm     = ChatOllama(model=MODEL_NAME, temperature=0.7)

# ─────────────────────────────────────────────────────────
# STATE
# ─────────────────────────────────────────────────────────
class TripState(TypedDict):
    messages:      Annotated[List[BaseMessage], operator.add]
    user_id:       str
    destination:   str
    budget:        str
    days:          int
    hotels:        List[str]
    chosen_hotel:  Optional[str]
    itinerary:     Optional[str]
    approved:      bool
    past_bookings: List[dict]   # persisted across sessions in the checkpoint

# ─────────────────────────────────────────────────────────
# DISPLAY HELPERS
# ─────────────────────────────────────────────────────────
def rule(title: str, color: str = "white") -> None:
    console.print()
    console.print(Rule(f"[bold {color}]{title}[/bold {color}]", style=color))
    console.print()

def checkpoint_note(thread_id: str) -> None:
    console.print(f"  [dim]💾 Checkpoint saved → thread_id: [italic]{thread_id}[/italic][/dim]")

def show_past_bookings(bookings: List[dict]) -> None:
    if not bookings:
        console.print("  [dim]No previous bookings found.[/dim]")
        return
    t = Table(show_header=True, header_style="bold green", box=None, padding=(0, 2))
    t.add_column("#",           style="dim",   width=4)
    t.add_column("Destination", style="green")
    t.add_column("Hotel",       style="white")
    t.add_column("Budget",      style="cyan")
    t.add_column("Days",        style="cyan",  width=5)
    for i, b in enumerate(bookings, 1):
        t.add_row(str(i), b.get("destination","—"), b.get("hotel","—"),
                  b.get("budget","—"), str(b.get("days","—")))
    console.print(t)

def show_pending_state(values: dict) -> None:
    t = Table(show_header=False, box=None, padding=(0, 2))
    t.add_column("Key",   style="yellow", no_wrap=True, min_width=16)
    t.add_column("Value", style="white")
    for k in ("destination", "budget", "days", "hotels", "chosen_hotel", "approved"):
        v = values.get(k)
        if isinstance(v, list):
            display = ("\n".join(f"  • {i}" for i in v)) if v else "[dim][ ][/dim]"
        else:
            display = "[dim]None[/dim]" if v is None else str(v)
        t.add_row(k, display)
    console.print(t)

# ─────────────────────────────────────────────────────────
# NODES
# ─────────────────────────────────────────────────────────
def greet_user(state: TripState) -> dict:
    text = (
        f"Hi {state['user_id']}! Finding {state['budget']} hotels in "
        f"{state['destination']} for {state['days']} days…"
    )
    console.print(Panel(text, title="[green]greet_user[/green]", border_style="green"))
    return {"messages": [AIMessage(content=text)]}

def search_hotels(state: TripState) -> dict:
    console.print(f"\n  [green]→ search_hotels — querying {MODEL_NAME}…[/green]")
    prompt = (
        f"List exactly 3 real {state['budget']} hotels in {state['destination']}, India. "
        "For each: name, price per night in INR, one standout feature. "
        "Use a simple numbered list like:\n1. HotelName – ₹XXXX/night – feature\n"
        "No sub-bullets, no extra lines."
    )
    full_text = ""
    console.print("  ", end="")
    for chunk in llm.stream(prompt):
        token = chunk.content
        full_text += token
        console.print(token, end="", highlight=False)
    console.print("\n")

    # robust parsing: any line whose first non-space char is a digit
    hotels = []
    for line in full_text.split("\n"):
        stripped = line.strip()
        if stripped and stripped[0].isdigit() and len(hotels) < 3:
            # clean leading "1. " / "1) " etc.
            hotels.append(stripped)
    if not hotels:
        hotels = [full_text.strip()]

    t = Table(title=f"Hotels in {state['destination']}",
              header_style="bold green", box=None, padding=(0, 1))
    t.add_column("#", width=3, style="dim")
    t.add_column("Hotel", style="green")
    for i, h in enumerate(hotels, 1):
        t.add_row(str(i), h)
    console.print(t)
    checkpoint_note(state["user_id"])

    return {
        "messages": [AIMessage(content="Hotels:\n" + "\n".join(hotels))],
        "hotels":   hotels,
    }

def book_hotel(state: TripState) -> dict:
    chosen = state.get("chosen_hotel") or state["hotels"][0]
    console.print(f"\n  [green]→ book_hotel — generating itinerary for {chosen}…[/green]")

    prompt = (
        f"Travel planner: staying at '{chosen}' in {state['destination']}. "
        f"Write an enthusiastic {state['days']}-day itinerary (morning/afternoon/evening). "
        "Under 250 words."
    )
    itinerary = ""
    console.print("  ", end="")
    for chunk in llm.stream(prompt):
        token = chunk.content
        itinerary += token
        console.print(token, end="", highlight=False)
    console.print("\n")

    # ── append to persistent booking history ─────────────
    past = list(state.get("past_bookings") or [])
    past.append({
        "destination": state["destination"],
        "hotel":       chosen,
        "budget":      state["budget"],
        "days":        state["days"],
    })

    text = f"✅ Confirmed: {chosen}\n\n📅 {state['days']}-day itinerary:\n{itinerary}"
    console.print(Panel(text, title="[green]Booking Confirmed[/green]", border_style="green"))
    checkpoint_note(state["user_id"])

    return {
        "messages":      [AIMessage(content=text)],
        "chosen_hotel":  chosen,
        "itinerary":     itinerary,
        "past_bookings": past,       # ← saved into checkpoint here
    }

# ─────────────────────────────────────────────────────────
# CONDITIONAL EDGE
# ─────────────────────────────────────────────────────────
def route_after_search(state: TripState) -> str:
    return "book_hotel" if state.get("approved") else END

# ─────────────────────────────────────────────────────────
# GRAPH
# ─────────────────────────────────────────────────────────
def build_graph(checkpointer):
    b = StateGraph(TripState)
    b.add_node("greet_user",    greet_user)
    b.add_node("search_hotels", search_hotels)
    b.add_node("book_hotel",    book_hotel)
    b.set_entry_point("greet_user")
    b.add_edge("greet_user", "search_hotels")
    b.add_conditional_edges("search_hotels", route_after_search,
                            {"book_hotel": "book_hotel", END: END})
    b.add_edge("book_hotel", END)
    return b.compile(
        checkpointer=checkpointer,
        interrupt_before=["book_hotel"],
    )

# ─────────────────────────────────────────────────────────
# APPROVAL HELPER  (shared by new booking + resume)
# ─────────────────────────────────────────────────────────
def run_approval_and_book(graph, user_id: str, config: dict) -> bool:
    """
    Called after search_hotels has run and the graph is paused at book_hotel.
    Asks the user to pick a hotel, updates state, and resumes.
    Returns True if booking completed, False if cancelled.
    """
    snapshot = graph.get_state(config)
    hotels   = snapshot.values.get("hotels", [])

    if not hotels:
        console.print("[red]No hotels found in checkpoint — cannot proceed.[/red]")
        return False

    console.print(Panel(
        "[bold yellow]⏸  Agent paused — waiting for your approval[/bold yellow]\n\n"
        "[dim]State is saved. You can quit now and approve next time you log in.[/dim]",
        border_style="yellow",
    ))
    console.print("\nHotels found:")
    for i, h in enumerate(hotels, 1):
        console.print(f"  [bold]{i}.[/bold] {h}")

    if not Confirm.ask("\nApprove booking?"):
        console.print(
            "[dim]Left as pending. Log in again to resume — "
            "the agent will pick up right here.[/dim]"
        )
        return False

    pick   = Prompt.ask(f"Choose hotel [1-{len(hotels)}]", default="1")
    idx    = (int(pick) - 1) if pick.isdigit() and 1 <= int(pick) <= len(hotels) else 0
    chosen = hotels[idx]

    console.print(f"\n  [dim]→ update_state: approved=True, chosen_hotel={chosen}[/dim]")
    graph.update_state(config, {"approved": True, "chosen_hotel": chosen})

    console.print(f"  [dim]→ stream(None, config) — resuming from checkpoint…[/dim]\n")
    for _ in graph.stream(None, config, stream_mode="values"):
        pass

    return True

# ─────────────────────────────────────────────────────────
# LOGIN
# ─────────────────────────────────────────────────────────
def login(graph) -> tuple:
    """
    Returns (user_id, config, has_pending_task: bool).
    Prints booking history and pending state on login.
    """
    rule("Login", "cyan")
    user_id = Prompt.ask("[bold cyan]Enter your user ID[/bold cyan]").strip()
    if not user_id:
        console.print("[red]User ID cannot be empty.[/red]")
        sys.exit(1)

    config   = {"configurable": {"thread_id": user_id}}
    snapshot = graph.get_state(config)

    console.print(Panel.fit(
        f"[bold]Welcome, [cyan]{user_id}[/cyan]![/bold]\n"
        f"[dim]thread_id → {user_id}[/dim]",
        border_style="cyan",
    ))

    # ── show booking history (always) ───────────────────
    rule("Your Previous Bookings", "green")
    past = snapshot.values.get("past_bookings", []) if snapshot.values else []
    show_past_bookings(past)

    # ── detect pending (paused at book_hotel) ────────────
    has_pending = bool(snapshot.next and "book_hotel" in snapshot.next)
    if has_pending:
        rule("Pending Trip — Paused at Approval", "yellow")
        console.print(Panel(
            "[bold yellow]⏸  You have a trip waiting for your approval![/bold yellow]\n\n"
            "[dim]Hotels were already searched and saved.\n"
            "The agent will resume from that exact checkpoint — nothing re-runs.[/dim]",
            border_style="yellow",
        ))
        show_pending_state(snapshot.values)

    return user_id, config, has_pending

# ─────────────────────────────────────────────────────────
# NEW BOOKING
# ─────────────────────────────────────────────────────────
def new_booking(graph, user_id: str, config: dict) -> None:
    rule("Plan a New Trip", "cyan")

    destination = Prompt.ask("[bold]Destination[/bold] (city name)").strip()
    if not destination:
        console.print("[red]Destination cannot be empty.[/red]")
        return

    console.print("Budget: " + "  ".join(f"[bold]{i+1}.[/bold] {b}" for i, b in enumerate(BUDGETS)))
    b_pick = Prompt.ask("Choose budget [1-3]", default="2")
    budget = BUDGETS[(int(b_pick) - 1) if b_pick.isdigit() and 1 <= int(b_pick) <= 3 else 1]

    console.print("Days: " + "  ".join(f"[bold]{d}[/bold]" for d in DAY_OPTIONS))
    d_pick = Prompt.ask("How many days?", default="3")
    days   = int(d_pick) if d_pick.isdigit() and d_pick in DAY_OPTIONS else 3

    console.print(Panel(
        f"Destination : [bold]{destination}[/bold]\n"
        f"Budget      : [bold]{budget}[/bold]\n"
        f"Days        : [bold]{days}[/bold]",
        title="[cyan]New Trip[/cyan]", border_style="cyan",
    ))
    if not Confirm.ask("Start planning?"):
        return

    # carry forward existing past_bookings so history isn't wiped
    existing_state = graph.get_state(config)
    past = list(existing_state.values.get("past_bookings", []) if existing_state.values else [])

    # run greet_user → search_hotels (stops at interrupt before book_hotel)
    for _ in graph.stream(
        {
            "messages":      [HumanMessage(content=f"Plan a {days}-day trip to {destination}")],
            "user_id":       user_id,
            "destination":   destination,
            "budget":        budget,
            "days":          days,
            "hotels":        [],
            "approved":      False,
            "chosen_hotel":  None,
            "itinerary":     None,
            "past_bookings": past,
        },
        config,
        stream_mode="values",
    ):
        pass

    # now the graph is paused — run the approval step
    run_approval_and_book(graph, user_id, config)

# ─────────────────────────────────────────────────────────
# MAIN
# ─────────────────────────────────────────────────────────
def main():
    console.print(Panel.fit(
        "[bold white]🧠  AI Agents That Never Forget[/bold white]\n"
        f"[dim]Model: {MODEL_NAME}  |  DB: {DB_URI}[/dim]",
        border_style="white",
    ))

    with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
        checkpointer.setup()
        graph = build_graph(checkpointer)

        while True:
            user_id, config, has_pending = login(graph)

            if has_pending:
                # resume the paused trip directly
                run_approval_and_book(graph, user_id, config)
            else:
                new_booking(graph, user_id, config)

            # show updated history after any action
            rule("Updated Booking History", "green")
            final = graph.get_state(config)
            show_past_bookings(final.values.get("past_bookings", []) if final.values else [])

            console.print()
            if not Confirm.ask("Plan another trip or switch user?"):
                console.print("\n[dim]Bye! All your data is saved in PostgreSQL.[/dim]\n")
                break

if __name__ == "__main__":
    main()

What the output looks like

(venv) K:\Projects\My_Blogs\Blog_1\code>python "trip_agent.py"
╭────────────────────────────────────────────────────────────────────────────────────╮
│ 🧠  AI Agents That Never Forget                                                    │
│ Model: llama3.2:1b  |  DB: postgresql://langgraph:secret@localhost:5432/checkpoints │
╰────────────────────────────────────────────────────────────────────────────────────╯

──────────────────────────────────────────────────────────────────────────── Login ─────────────────────────────────────────────────────────────────────────────

Enter your user ID: user1
╭───────────────────╮
│ Welcome, user1!   │
│ thread_id → user1 │
╰───────────────────╯

──────────────────────────────────────────────────────────────────── Your Previous Bookings ────────────────────────────────────────────────────────────────────

  #       Destination    Hotel                                                       Budget       Days   
  1       haryana        3. Le Meridien Puttapuli - ₹4,200/night - Fitness center    mid-range    5      

─────────────────────────────────────────────────────────────────────── Plan a New Trip ────────────────────────────────────────────────────────────────────────

Destination (city name): goa
Budget: 1. budget  2. mid-range  3. luxury
Choose budget [1-3] (2): 3
Days: 2  3  4  5  7
How many days? (3): 5
╭────────────────────────────────────────────────────────────────────────── New Trip ──────────────────────────────────────────────────────────────────────────╮
│ Destination : goa                                                                                                                                            │
│ Budget      : luxury                                                                                                                                         │
│ Days        : 5                                                                                                                                              │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Start planning? [y/n]: y
╭───────────────────────────────────────────────────────────────────────── greet_user ─────────────────────────────────────────────────────────────────────────╮
│ Hi user1! Finding luxury hotels in goa for 5 days…                                                                                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

  → search_hotels — querying llama3.2:1b…
  Here are three luxury hotels in Goa:

1. The Leela Paliholm Goa Resort & Spa - ₹6,000/night - Private Beach
2. The Connaught Beach Club - ₹7,500/night - Luxury Poolside Villas
3. The Four Seasons Resort Baga Goa - ₹9,500/night - Private Beach

                               Hotels in goa                                
 #    Hotel                                                                 
 1    1. The Leela Paliholm Goa Resort & Spa - ₹6,000/night - Private Beach 
 2    2. The Connaught Beach Club - ₹7,500/night - Luxury Poolside Villas   
 3    3. The Four Seasons Resort Baga Goa - ₹9,500/night - Private Beach    
  💾 Checkpoint saved → thread_id: user1
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⏸  Agent paused — waiting for your approval                                                                                                                  │
│                                                                                                                                                              │
│ State is saved. You can quit now and approve next time you log in.                                                                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Hotels found:
  1. 1. The Leela Paliholm Goa Resort & Spa - ₹6,000/night - Private Beach
  2. 2. The Connaught Beach Club - ₹7,500/night - Luxury Poolside Villas
  3. 3. The Four Seasons Resort Baga Goa - ₹9,500/night - Private Beach

Approve booking? [y/n]: n
Left as pending. Log in again to resume — the agent will pick up right here.

─────────────────────────────────────────────────────────────────── Updated Booking History ────────────────────────────────────────────────────────────────────

  #       Destination    Hotel                                                       Budget       Days   
  1       haryana        3. Le Meridien Puttapuli - ₹4,200/night - Fitness center    mid-range    5      

Plan another trip or switch user? [y/n]: 

*💡 *What’s happening under the hood? When build_graph() is called again with the same thread_id, LangGraph automatically loads the latest checkpoint from PostgreSQL and reconstructs the entire TripState — destination, hotel options, selected hotel, itinerary, message history, approval status, and even previous bookings.

Instead of restarting from the beginning, the agent resumes execution from the exact interrupted node (book_hotel in this case), making the conversation feel completely seamless — as if the crash or shutdown never happened.

6. Wrap-Up & What’s Next

Let’s take a step back and appreciate what you actually built here.

Not just a chatbot. Not a glorified autocomplete wrapper. You built an agent that survives the real world — server crashes, second thoughts, multi-day workflows, and the very human need to say “wait, let me think about this” before spending money.

You started with four deceptively simple ideas. State is the shared notebook every node reads and writes — in our case, a TripState carrying everything from the destination and budget to the full booking history. Nodes are the workers that transform that state, one focused task at a time. The PostgreSQL checkpointer is the silent archivist, snapshotting everything after every step so that a crash mid-workflow is an inconvenience, not a catastrophe. And interrupts are the moment of humanity built into the machine — the pause before anything consequential happens, the space where you get to say "yes, go ahead" or "actually, no."

The mental model worth keeping: think of a LangGraph agent as a relay race. Each node is a runner — it picks up the baton, does its leg, and hands it off. PostgreSQL is the stadium itself, recording every split time so that if a runner trips, the race doesn’t have to restart from scratch. And interrupts are the coach standing trackside, with the authority to pause everything and make a call before the next leg begins.

Without checkpointing, your agents are sprinters with amnesia. Without interrupts, they’re sprinters with no coach. You now have both.

🙏 Found this useful? Leave a clap and drop a comment — I’d love to see what you build with this. If you hit any issues running the code, paste your error in the comments and I’ll help you debug it.


메타데이터
post_id
ff00c3d41514
slug
ai-agents-that-never-forget-langgraph-postgresql-checkpointing-ff00c3d41514
url
https://medium.com/@dubeysanjana23/ai-agents-that-never-forget-langgraph-postgresql-checkpointing-ff00c3d41514
canonical_url
https://medium.com/@dubeysanjana23/ai-agents-that-never-forget-langgraph-postgresql-checkpointing-ff00c3d41514
author_url
https://medium.com/@dubeysanjana23
status
ok
fetched_at
2026-07-21 14:13:00