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.pyprints paused on interrupt ... - state saved, exiting and returns to the shell.- The interrupt waits in the inbox; nothing is running.
webhook.py(orrecover.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_KEYLeave 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/vigilatorRegister 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 waitingHow 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:
@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 saverrun_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:
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:
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()- orcreate_agent/create_deep_agentwithinterrupt_on- works withrun_threadunchanged. - In production, give
PostgresSavera connection pool and prune old checkpoints on a schedule. - Keep the
vigilator_runstable in your own database if you already have one; it is three columns and four queries.