Examples

Durable resume

Keep the agent's state in a database, exit when it pauses, and resume it from any process when the decision arrives.

The LangGraph refund agent again, with one production-shaped difference: the run's state lives in a database, not in the process. The script that starts a run exits the moment the agent pauses. Whichever process receives the reviewer's decision - a webhook server, a cron job, a CLI - rebuilds the graph from the database and carries on. Source: Vigilator/examples/langgraph-durable-resume.

What you'll see

  • run.py prints paused on interrupt ... - state saved, exiting and returns to the shell.
  • The interrupt waits in the inbox; nothing is running.
  • webhook.py (or recover.py) resumes the run from the database and prints the agent's final answer.

Run it

Install and configure

git clone https://github.com/Vigilator/examples
cd examples/langgraph-durable-resume
uv sync
cp .env.example .env   # VIGILATOR_API_KEY and ANTHROPIC_API_KEY

Leave DATABASE_URL unset to use a local SQLite file, or point it at PostgreSQL.

Start a run - and watch it exit

uv run run.py
uv run recover.py --list     # thread_7c1e4b2a  waiting on 8f14e45f-...

Resume from the webhook

uv run fastapi dev webhook.py
svix listen http://localhost:8000/webhooks/vigilator

Register the relay's URL for interrupt.answered and put the signing secret in .env. Decide the interrupt in the inbox: the receiver claims the paused thread, rebuilds the graph and finishes the run. You can start the server after deciding, or restart it in between - there was nothing in memory to lose.

Or resume without a webhook

uv run recover.py          # resumes every waiting run whose interrupt is answered
uv run recover.py --wait   # keeps polling until nothing is waiting

How it works

The checkpointer is the state. open_checkpointer() yields a SqliteSaver or a PostgresSaver; build_graph(checkpointer) compiles the same graph every time. The only thing that identifies a run is its thread_id:

db.py
@contextmanager
def open_checkpointer():
    url = os.environ.get("DATABASE_URL")
    if url:
        with PostgresSaver.from_conn_string(url) as saver:
            saver.setup()  # creates the tables on first use
            yield saver
    else:
        with SqliteSaver.from_conn_string("checkpoints.db") as saver:
            yield saver

run_thread never waits. It runs one segment - until the graph finishes or hits interrupt() - then either marks the thread done or opens the Vigilator interrupt, records which interrupt the thread is waiting on, and returns:

runner.py
def run_thread(thread_id, input_):
    with open_checkpointer() as checkpointer:
        graph = build_graph(checkpointer)          # nothing in memory: state comes from the database
        pending = run_segment(graph, input_, config, live)
        transcript = graph.get_state(config).values["messages"]
    if pending is None:
        mark_done(thread_id)
        return
    created = client.create_interrupt(to_vigilator_interrupt(pending, thread_id, transcript, ARGS_SCHEMAS))
    remember_pending(thread_id, created.id)      # (thread_id, interrupt_id, "waiting")

externalId routes the decision back, and claims are atomic. The interrupt is opened with externalId = "langgraph-durable-resume:<thread_id>", and every interrupt.answered event echoes it. The receiver flips the thread from waiting to resuming in a single UPDATE ... WHERE status = 'waiting'; if the row count is zero the event is a redelivery, or recover.py got there first, and it is ignored:

webhook.py
if isinstance(event, InterruptAnsweredEvent):
    thread_id = thread_id_from(event.data.external_id)
    if thread_id and take_pending(thread_id, event.data.id):
        background.add_task(run_thread, thread_id, to_langgraph_resume(decisions_from_event(event)))

recover.py walks the waiting rows, calls get_interrupt for each, and resumes the answered ones through the same claim - so a missed webhook never strands a run, and running both at once is safe.

Adapting it

  • Any graph compiled with a checkpointer and paused with interrupt() - or create_agent / create_deep_agent with interrupt_on - works with run_thread unchanged.
  • In production, give PostgresSaver a connection pool and prune old checkpoints on a schedule.
  • Keep the vigilator_runs table in your own database if you already have one; it is three columns and four queries.

On this page