Skip to content
PERMISSION/PROTOCOL

Tutorial

LangGraph Human-in-the-Loop: Interrupt, Approve and Resume

Permission Protocol8 min readSources checked

The takeaway

Build a runnable LangGraph approval example with SQLite checkpoints. Test rejection, process restarts and retries, then separate native behavior from application safeguards.

In this article

To add human-in-the-loop approval in LangGraph, call interrupt() before the action, compile the graph with a checkpointer, and resume the same thread with Command(resume=...). The resume value becomes the return value of the interrupt. Your application decides who may submit that value.

This tutorial builds a small refund workflow that pauses, accepts approval or rejection, and survives a process exit. The refund is synthetic: execution inserts a row in a local SQLite file. No model, payment service, API key or Permission Protocol integration is required.

Tested scope: LangGraph 1.2.16, SQLite checkpointer 3.1.2 and Python 3.13.7. The accompanying lab ran on Windows and Linux on October 23, 2026. Its results distinguish native SDK behavior from the safeguards added by the example application.

1. Install the pinned example

Download the Python script and locked requirements into an empty working directory. The requirements pin the SDK and its transitive dependencies. Keep this environment separate from your application.

On macOS or Linux:

python3 -m venv .venv
.venv/bin/python -m pip install -r langgraph-tutorial-requirements.txt
.venv/bin/python langgraph-human-in-the-loop.py start --store approved-demo

On Windows PowerShell:

python -m venv .venv
.venv/Scripts/python.exe -m pip install -r langgraph-tutorial-requirements.txt
.venv/Scripts/python.exe langgraph-human-in-the-loop.py start --store approved-demo

The script reports pending, an action for synthetic-order-42 with an amount of 12500 cents in USD, and an empty mock_effects list. Nothing has executed. Package installation needs internet access; the example itself uses local files.

2. Put the interrupt before the side effect

The review node in the downloadable script is:

def review(state):
    approved = interrupt({"action": state["action"], "question": "Approve this mock refund?"})
    if approved is not True:
        return {"status": "rejected"}
    # This insertion is the entire mock effect. Never put it before interrupt().
    with effects:
        effects.execute("INSERT INTO effects VALUES (?)", (json.dumps(state["action"]),))
    return {"status": "mock_recorded"}

The payload gives the reviewer the proposed action. Only the boolean True takes the approval branch; rejection returns without inserting an effect. This checks the decision value, not the identity or authority of the person submitting it.

When a node resumes, LangGraph runs it again from the beginning. Code before the interrupt therefore runs again. Keep a non-idempotent refund, message or deployment after the approval boundary, or move it into a separate task with appropriate idempotency. Do not hide the interrupt inside a broad exception handler. These are native execution rules described in the LangGraph interrupt documentation.

The example compiles the graph with SqliteSaver and uses a fixed synthetic thread_id. The file-backed checkpoint persists beyond the Python process. An in-memory checkpointer would not survive a restart. SQLite is useful for this local tutorial; choose a production storage design for your deployment, as described in LangGraph persistence.

3. Approve or reject in a new process

The start command has already exited. Run the following commands in the same directory. On Windows, replace .venv/bin/python with .venv/Scripts/python.exe.

.venv/bin/python langgraph-human-in-the-loop.py status --store approved-demo
.venv/bin/python langgraph-human-in-the-loop.py approve --store approved-demo

The new process recreates the graph, opens the same checkpoint database, and passes Command(resume=True) with the same thread configuration. The result is mock_recorded with one matching entry in mock_effects.

Exercise rejection in a fresh store so you are deciding a new request:

.venv/bin/python langgraph-human-in-the-loop.py start --store rejected-demo
.venv/bin/python langgraph-human-in-the-loop.py reject --store rejected-demo

This returns rejected and no mock effects. The script refuses to start a second run in an existing store, so a rerun does not silently replace your experiment. Use another directory for a new scenario. Both the approval and rejection paths are tested in separate OS processes.

Read the complete downloadable script
"""Native LangGraph approval tutorial. Synthetic action, local files, no API key.

Tested with langgraph==1.2.16 and langgraph-checkpoint-sqlite==3.1.2.
This demonstrates pause/resume, not authenticated approval or signed evidence.
"""

import argparse
import json
import os
from pathlib import Path
import socket
import sqlite3
from typing import TypedDict

os.environ["LANGSMITH_TRACING"] = "false"
os.environ["LANGCHAIN_TRACING_V2"] = "false"


def no_network(*args, **kwargs):
    raise RuntimeError("This local tutorial does not use the network.")


socket.socket.connect = no_network
socket.create_connection = no_network

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt


class State(TypedDict):
    action: dict
    status: str


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("command", choices=("start", "approve", "reject", "status"))
    parser.add_argument("--store", default="langgraph-demo")
    args = parser.parse_args()
    store = Path(args.store)
    store.mkdir(parents=True, exist_ok=True)
    config = {"configurable": {"thread_id": "synthetic-refund-001"}}

    with sqlite3.connect(store / "mock-effects.sqlite") as effects:
        effects.execute("CREATE TABLE IF NOT EXISTS effects (action TEXT NOT NULL)")
        effects.commit()

        def review(state):
            approved = interrupt({"action": state["action"], "question": "Approve this mock refund?"})
            if approved is not True:
                return {"status": "rejected"}
            # This insertion is the entire mock effect. Never put it before interrupt().
            with effects:
                effects.execute("INSERT INTO effects VALUES (?)", (json.dumps(state["action"]),))
            return {"status": "mock_recorded"}

        with SqliteSaver.from_conn_string(str(store / "checkpoints.sqlite")) as saver:
            graph = (StateGraph(State).add_node("review", review)
                     .add_edge(START, "review").add_edge("review", END)
                     .compile(checkpointer=saver))
            saved = graph.get_state(config)
            if args.command == "start":
                if saved.values:
                    parser.error("This store already contains a run. Choose a new --store directory.")
                graph.invoke({"action": {"order_id": "synthetic-order-42", "amount_cents": 12500,
                                         "currency": "USD"}, "status": "new"}, config)
            elif args.command != "status":
                if not saved.values:
                    parser.error("Start the run before submitting a decision.")
                graph.invoke(Command(resume=args.command == "approve"), config)
            saved = graph.get_state(config)
            records = [json.loads(row[0]) for row in effects.execute("SELECT action FROM effects")]
            print(json.dumps({"status": "pending" if saved.next else saved.values.get("status", "not_started"),
                              "action": saved.values.get("action"), "mock_effects": records}, indent=2))


if __name__ == "__main__":
    main()

4. Distinguish a retry from historical replay

Repeat the approve command against approved-demo. In this tested version, invoking resume against the latest completed graph state returns without another effect. That is different from selecting a historical checkpoint that was still waiting for approval.

In the lab, we deliberately retained the paused checkpoint configuration and resumed it again after a completed execution. That explicit historical replay produced a second mock effect. Two processes resuming the same retained paused checkpoint also produced two effects when no application claim or backend idempotency was installed.

This is a result for a specific checkpoint-replay experiment, not a claim that every LangGraph retry duplicates work. The tutorial CLI uses the latest state and does not expose a historical replay command. Use the lab to reproduce that separate case.

What the eight-scenario lab actually observed

The following observations come from the versioned lab result. Five native LangGraph cases and all eight application cases were exercised. The other three native cases are explicitly untested. A passed experiment means its stated observation was reproduced; it does not certify a production control.

October 23, 2026: native SDK behavior and example application safeguards
ScenarioNative LangGraphExample application
ApprovalObserved

Native approval resumed and executed once.

Observed

Stored exact-action approval was consumed and the SDK recorded one mock effect.

RejectionObserved

Native rejection resumed without a mock effect.

Observed

Rejection persisted; dispatch was denied before SDK resume.

Changed actionNot tested

Native post-approval argument mutation is not exercised in v1; application test changes the dispatch candidate after the stored approval.

Observed

After approval of 12500 cents, dispatch of 25000 cents was denied before SDK resume.

Expired approvalNot tested

No native expiry policy configured; tested at the application dispatch boundary only.

Observed

An approval made before expiry was denied at dispatch at the exact expiry boundary.

Duplicate resume or replayObserved

Latest completed-state retry returned without a new effect; explicit replay of the retained paused snapshot/checkpoint produced a second effect.

Observed

Second application dispatch was denied by the durable consumed status.

Process restartObserved

A fresh OS process restored the persisted pause and executed once.

Observed

Decision process exited; a fresh dispatch process loaded the ledger and persisted SDK state, then executed once.

Concurrent approvalsObserved

Two processes resumed the same retained paused snapshot/checkpoint and recorded two mock effects. No application claim or idempotency was installed.

Observed

Two released processes competed to approve and dispatch one request; one resumed the SDK, the other was denied as already decided.

Wrong approverNot tested

No native identity provider configured; application test uses a simulated authenticated principal, not a login system.

Observed

Simulated wrong principal could not approve; later dispatch was denied.

The application cases use a stored action fingerprint, designated synthetic reviewer, deadline, transactionally consumed approval and a mock backend that deduplicates an operation ID. Those checks are implemented in lab.py, not attributed to LangGraph and not shipped as a Permission Protocol adapter.

Download and extract the versioned lab source archive and follow its platform-specific environment setup. It contains only the educational source, dependency locks, methodology and results. No account or repository access is needed. The optional --framework langgraph flag selects only this framework:

cd agent-approval-lab
# Follow README.md to create .venv and install the locked dependencies.
.venv/bin/python run_lab.py --framework langgraph

The archive's README.md explains the native/application split. The CSV observations are also ungated. These downloads preserve the recorded v1 snapshot, including dependency versions and source hashes.

Before you connect a real tool

A checkpoint remembers where execution paused. It does not, by itself, establish who was allowed to approve the action. Start with the authorization foundation and make these application responsibilities explicit:

  • Reviewer identity: authenticate the reviewer and authorize access to the stored request. A thread ID or submitted name is not proof of authority.
  • Exact action: show and bind the parameters that will execute. A changed amount, recipient or deployment artifact needs a new decision.
  • Expiry and consumption: check the deadline at dispatch and coordinate competing requests. The lab uses injected time, not an external trusted-time service.
  • Crash recovery: the lab consumes approval before resuming the SDK. A crash in between needs reconciliation. There is no exactly-once guarantee for a remote action.
  • Evidence: distinguish the decision from the execution result. These SQLite rows are unsigned local records, not independent signed receipts.

The approval design checklist and its free worksheet help turn those questions into a test plan. If native controls already meet your requirements, use them. If a control remains unresolved, describe that concrete workflow when you contact us.

Troubleshooting the first run

  • It cannot find the paused run: use the same --store path and working directory. The example keeps a fixed thread ID inside that store.
  • You expected an in-memory graph to survive a restart: the process lost that memory. This example uses a file-backed checkpointer.
  • A side effect runs again: inspect code before the interrupt and whether you selected a historical checkpoint. Then check backend idempotency.
  • The store already contains a run: inspect it with status or choose a new directory. Do not discard a pending decision to get a clean-looking result.

Sources and package versions were checked October 23, 2026. Hosted LangGraph services, streaming, nested tools, distributed failover and native post-approval argument mutation were outside this test. The broader human-approval guide covers how to choose the review boundary for other workflows.

Put it into practice

Plan your approval boundary

Use the ungated checklist and worksheet to identify your reviewer, action parameters, expiry and retry rules before connecting a real tool.

Keep reading