Agentic AI Engineering with LangChain / Lab 08
Module 08 · LangGraph advanced

Lab 08 · Durable refund workflow

Build a refund workflow that pauses for a manager, survives a restart through a SQLite checkpoint, pays out exactly once, and can be re-decided with time travel.

45–60 minIntermediateNo API key neededinterruptCommand(resume)SqliteSaveridempotencytime travel

What you will build

A refund workflow for Northwind written as a LangGraph graph. Small refunds are paid automatically. Larger ones pause with interrupt() until a manager answers. The pause is saved to a SQLite file, so a brand-new process can pick the request up and finish it. Finally you fork the saved history to replay the decision the other way.

What you will learn

  • How interrupt() pauses a graph and Command(resume=...) continues it
  • Why a checkpointer is required for pausing, and how SqliteSaver stores it on disk
  • Why a node re-runs from its first line on resume, and how to keep side effects idempotent
  • How thread_id identifies one conversation or workflow run
  • How to inspect get_state_history and fork an earlier checkpoint (time travel)
1

Check your prerequisites

You need three things on your machine and three values from your instructor.

  • Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
  • A code editor. VS Code with the Python extension works well.
  • A terminal. PowerShell on Windows, Terminal on macOS or Linux.
PowerShell
python --version
✓

This lab uses no model, so it needs no API key, no .env and no network. Every node is plain Python. The only moving part is a SQLite file on your disk.

2

Create the project folder and virtual environment

Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.

PowerShell
mkdir C:\labs\lab-08-durable-refunds
cd C:\labs\lab-08-durable-refunds
python -m venv .venv
.venv\Scripts\Activate.ps1
✓

Keep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.

When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.

!

PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.

This is what your folder will look like at the end of the lab:

lab-08-durable-refunds/ ├── refund_lab.db (generated) ├── refund_workflow.py └── requirements.txt

Files marked as generated are created when you run the code. You do not create them yourself.

3

Install the libraries

Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.

requirements.txt
langgraph==1.2.11
langgraph-checkpoint==4.2.0
langgraph-checkpoint-sqlite==3.1.1

langgraph-checkpoint-sqlite provides SqliteSaver, which stores every checkpoint in a SQLite file. SQLite ships with Python, so there is no database server to install.

Then install everything into your virtual environment. It takes a minute or two.

PowerShell
python -m pip install --upgrade pip
pip install -r requirements.txt
4

Write the refund workflow

The workflow has four nodes:

  • validate checks the amount. At or below 100 it is approved automatically.
  • await_approval pauses and waits for a manager.
  • apply_refund pays out.
  • notify_customer emails the result.

The flow is START → validate → (apply_refund | await_approval), then await_approval → (apply_refund | notify_customer), and apply_refund → notify_customer → END.

After every step LangGraph saves a checkpoint: a snapshot of the state plus what runs next. That is what makes pausing, resuming and time travel possible.

Create an empty file named refund_workflow.py in your lab folder. Then copy each block below into it, in order, one after another.

Block 1Imports

  • interrupt pauses the graph inside a node; Command(resume=...) continues it.
  • SqliteSaver is a checkpointer that writes checkpoints to a SQLite file.
  • Counter counts how often node bodies run, so you can see re-runs.
refund_workflow.py (add to the end of the file)
"""Lab 08: a refund workflow that pauses for a manager and survives a restart.

Plain-Python nodes on a SQLite checkpointer: no model, no API key.
Run:  python refund_workflow.py
"""

import operator
from collections import Counter
from pathlib import Path
from typing import Annotated, Any, Literal

from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph
from langgraph.graph.state import CompiledStateGraph
from langgraph.types import Command, interrupt
from typing_extensions import TypedDict

Block 2Settings and pretend external systems

DB_PATH puts the checkpoint file next to the code. HOLDS and LEDGER stand in for real systems (a payments API and a ledger) that the workflow changes. NODE_CALLS records how many times each node body ran.

They are plain dictionaries so you can print them. In production they would be outside your process, which is exactly why writing to them twice would be a real bug.

refund_workflow.py (add to the end of the file)
DB_PATH = Path(__file__).resolve().parent / "refund_lab.db"
APPROVAL_THRESHOLD = 100.0
HOLDS: dict[str, str] = {}  # stands in for the payments API
LEDGER: dict[str, str] = {}  # stands in for the ledger
NODE_CALLS: Counter = Counter()  # how often each node BODY ran

Block 3The workflow state

One refund request. decision moves from "" to auto, approved or rejected. audit has an operator.add reducer, so each node appends one line and you get a full audit trail at the end.

refund_workflow.py (add to the end of the file)
class RefundState(TypedDict):
    """One refund request moving through the workflow."""

    request_id: str
    customer: str
    amount: float
    decision: str  # "" | "auto" | "approved" | "rejected"
    receipt_id: str
    audit: Annotated[list[str], operator.add]

Block 4Node 1: validate

A pure function: it only reads state and returns an update. Pure nodes are safe to replay any number of times, which matters once resume and time travel start re-running steps.

refund_workflow.py (add to the end of the file)
def validate(state: RefundState) -> dict[str, Any]:
    """Pure, so replaying it costs nothing. Small refunds skip the manager."""
    auto = state["amount"] <= APPROVAL_THRESHOLD
    return {"decision": "auto" if auto else "",
            "audit": [f"validated {state['request_id']} for {state['amount']:.2f}"]}

Block 5Node 2: pause for a manager

interrupt(payload) saves a checkpoint and stops the graph. The payload is what the manager sees; keep it to JSON types (strings, numbers, dicts).

The key gotcha: when you resume, LangGraph runs this node again from its first line. This time interrupt() returns the manager's answer instead of pausing. So anything above interrupt() runs twice.

That is why the hold uses HOLDS.setdefault(request_id, ...): the second run finds the existing hold and does nothing. With HOLDS[...] = ... or a list append against a real payments API, you would place two holds.

Never wrap interrupt() in a broad try/except: it works by raising an exception, and catching it turns the pause into a silent decision.

refund_workflow.py (add to the end of the file)
def await_approval(state: RefundState) -> dict[str, Any]:
    """Pause for a manager. The hold is keyed because this body re-runs from line 1."""
    NODE_CALLS["await_approval"] += 1
    HOLDS.setdefault(state["request_id"], f"HOLD-{state['request_id']}")
    answer = interrupt({"type": "refund_approval", "request_id": state["request_id"],
                        "customer": state["customer"], "amount": state["amount"]})
    decision = "approved" if answer["approved"] else "rejected"
    return {"decision": decision, "audit": [f"{answer['approver']} {decision} the refund"]}

Block 6Nodes 3 and 4: pay and notify

apply_refund is idempotent: keyed by request_id, so running it twice still leaves one ledger entry and returns the same receipt. Resumes, retries and time travel can all re-run a node, so any node that moves money must be safe to repeat.

refund_workflow.py (add to the end of the file)
def apply_refund(state: RefundState) -> dict[str, Any]:
    """Move the money. Keyed by request id, so a resume or a replay pays out once."""
    NODE_CALLS["apply_refund"] += 1
    receipt = LEDGER.setdefault(state["request_id"], f"RCPT-{state['request_id']}")
    return {"receipt_id": receipt, "audit": [f"paid {state['amount']:.2f} as {receipt}"]}


def notify_customer(state: RefundState) -> dict[str, Any]:
    """Tell the customer the outcome."""
    return {"audit": [f"emailed {state['customer']}: {state['decision']}"]}

Block 7Routing functions

Two conditional edges. The Literal[...] return types list the possible next nodes, which LangGraph uses to validate the graph and draw it.

refund_workflow.py (add to the end of the file)
def route_after_validation(state: RefundState) -> Literal["await_approval", "apply_refund"]:
    """Small refunds are paid straight away; the rest wait for a manager."""
    return "apply_refund" if state["decision"] == "auto" else "await_approval"


def route_after_approval(state: RefundState) -> Literal["apply_refund", "notify_customer"]:
    """Approved refunds are paid; rejected ones only notify the customer."""
    return "apply_refund" if state["decision"] == "approved" else "notify_customer"

Block 8Wire the graph with a checkpointer

compile(checkpointer=...) is essential. Without a checkpointer interrupt() has nowhere to store the pause, and the graph cannot resume.

refund_workflow.py (add to the end of the file)
def build_graph(checkpointer: BaseCheckpointSaver | None = None) -> CompiledStateGraph:
    """Compile the workflow. Without a checkpointer, interrupt() cannot pause."""
    builder = StateGraph(RefundState)
    builder.add_node("validate", validate)
    builder.add_node("await_approval", await_approval)
    builder.add_node("apply_refund", apply_refund)
    builder.add_node("notify_customer", notify_customer)
    builder.add_edge(START, "validate")
    builder.add_conditional_edges("validate", route_after_validation,
                                  ["await_approval", "apply_refund"])
    builder.add_conditional_edges("await_approval", route_after_approval,
                                  ["apply_refund", "notify_customer"])
    builder.add_edge("apply_refund", "notify_customer")
    builder.add_edge("notify_customer", END)
    return builder.compile(checkpointer=checkpointer)

Block 9Process 1: start and pause

SqliteSaver.from_conn_string(path) is a context manager: it opens the file, creates the tables on first use, and closes the connection at the end of the with block.

The config carries a thread_id. Every checkpoint of this request is saved under it, and you use the same id to resume.

When the graph pauses, invoke returns the state with an __interrupt__ key that holds the payload. The code uses .get() so it prints a clear message instead of crashing if the amount was too small to need approval.

refund_workflow.py (add to the end of the file)
def first_process(request: RefundState, config: dict[str, Any]) -> None:
    """Start the refund and stop at the manager gate, then close the database."""
    with SqliteSaver.from_conn_string(str(DB_PATH)) as saver:
        graph = build_graph(saver)
        paused = graph.invoke(request, config)
        interrupts = paused.get("__interrupt__", [])
        if not interrupts:
            print("The workflow did not pause. Is the amount above APPROVAL_THRESHOLD?")
            return
        print("paused with:", interrupts[0].value)
        print("hold placed before the pause:", HOLDS)

Block 10Process 2: recover, resume and fork

This with block opens a new saver on the same file. It never saw the first run, yet get_state(config).next shows the thread waiting at await_approval: the pause was read from disk.

  • invoke(Command(resume={...}), config) continues the thread with the manager's answer.
  • get_state_history(config) lists every checkpoint, newest first. The code picks the one taken just before the decision.
  • update_state(before.config, None) creates a fork from that checkpoint. Resuming the fork with a rejection writes a new branch; the approved branch stays in history.

Do not simply re-invoke with the old checkpoint's config: the earlier answer is stored with it and would be replayed, so the decision would not change.

refund_workflow.py (add to the end of the file)
def second_process(config: dict[str, Any]) -> None:
    """Open the same database with a new saver, resume with approval, then fork a rejection."""
    with SqliteSaver.from_conn_string(str(DB_PATH)) as saver:
        graph = build_graph(saver)
        print("recovered, next:", graph.get_state(config).next)
        final = graph.invoke(Command(resume={"approved": True, "approver": "m.chen"}), config)
        print("decision:", final["decision"], "| receipt:", final["receipt_id"])
        print("await_approval ran", NODE_CALLS["await_approval"], "times | HOLDS:", HOLDS)
        print("audit:", final["audit"])

        # Time travel: fork the checkpoint taken just before the manager decided.
        before = next(s for s in graph.get_state_history(config) if s.next == ("await_approval",))
        fork_config = graph.update_state(before.config, None)
        other = graph.invoke(Command(resume={"approved": False, "approver": "director"}), fork_config)
        print("forked ->", other["decision"], "| ledger still:", LEDGER)
        print("history now holds", len(list(graph.get_state_history(config))), "checkpoints")

Block 11Run both processes

The demo deletes any old database, then runs the two with blocks one after the other. The two blocks stand in for two processes, for example a web server that is redeployed while the refund waits. They share nothing except the SQLite file (and the pretend external systems).

refund_workflow.py (add to the end of the file)
if __name__ == "__main__":
    DB_PATH.unlink(missing_ok=True)  # start from an empty checkpoint store
    request: RefundState = {"request_id": "R-2", "customer": "grace@example.com", "amount": 640.0,
                            "decision": "", "receipt_id": "", "audit": []}
    config = {"configurable": {"thread_id": "R-2"}}

    first_process(request, config)
    print("--- the process exits; the SQLite file keeps the paused thread ---")
    second_process(config)
5

Run the workflow

PowerShell
python refund_workflow.py

Your output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.

Expected output
paused with: {'type': 'refund_approval', 'request_id': 'R-2', 'customer': 'grace@example.com', 'amount': 640.0}
hold placed before the pause: {'R-2': 'HOLD-R-2'}
--- the process exits; the SQLite file keeps the paused thread ---
recovered, next: ('await_approval',)
decision: approved | receipt: RCPT-R-2
await_approval ran 2 times | HOLDS: {'R-2': 'HOLD-R-2'}
audit: ['validated R-2 for 640.00', 'm.chen approved the refund', 'paid 640.00 as RCPT-R-2', 'emailed grace@example.com: approved']
forked -> rejected | ledger still: {'R-2': 'RCPT-R-2'}
history now holds 9 checkpoints

Follow the story in the output:

  • paused with shows the interrupt payload. The hold was placed once.
  • After the first with block closes, the second one reads the thread from refund_lab.db and sees next: ('await_approval',).
  • After the approval, await_approval ran 2 times (once to pause, once to resume), yet HOLDS still has one entry. That is the setdefault at work.
  • The audit trail lists validate, approval, payment and email in order.
  • The fork takes the other decision (rejected), and the ledger still shows one payment: the fork changed the graph's state, not the money already moved.

That last point is the lesson of time travel: it rewrites state, not the outside world. The approval email already went out on the original branch.

6

Check your work

You are done when

  • The output shows recovered, next: ('await_approval',) from the second saver.
  • await_approval ran 2 times, but HOLDS has exactly one entry.
  • LEDGER has exactly one entry, R-2: RCPT-R-2.
  • The fork prints forked -> rejected.
  • A refund_lab.db file exists next to refund_workflow.py.

Troubleshooting

ValueError: No checkpointer set, or the graph never pauses

Pass the saver to build_graph(saver) so compile(checkpointer=...) receives it.

KeyError: '__interrupt__'

The request did not pause. Refunds at or below APPROVAL_THRESHOLD (100) are approved automatically; use an amount above it.

KeyError: 'checkpoint_ns' when forking

Pass the full snapshot.config to update_state. A hand-built config with only thread_id and checkpoint_id is missing fields.

The fork prints approved again

You re-invoked the old checkpoint's config directly. Fork first with update_state(before.config, None) and resume on the config it returns.

ModuleNotFoundError: No module named 'langgraph.checkpoint.sqlite'

Install the requirements again inside the activated virtual environment: pip install -r requirements.txt.

'python' is not recognized, or the wrong Python runs

Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.

Go further

  • Change the amount to 100.0 and confirm the refund is paid without a pause; then try 100.01.
  • Open refund_lab.db in a SQLite browser between the two with blocks to see the checkpoints on disk.
  • Add a finance_gate node for refunds over 1,000 that runs after await_approval. One interrupt per node keeps resume values unambiguous.
  • Make apply_refund fail twice before succeeding and give it a RetryPolicy. Check that LEDGER still holds one entry.
  • Decide where a compensating correction email belongs after a fork turns an approval into a rejection.

Stuck?

Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.