How to make a suspended agent workflow resumable after a restart
A suspended workflow can only resume if its state was written somewhere. On @voltagent/core 2.11.0 there are three states, not two: no workflowMemory fails even in the same process, the in-memory adapter dies at process exit, and only a durable adapter survives a restart.
On this page
Quick answer (October 2026). A suspended agent workflow can only be resumed if its state was written somewhere. On @voltagent/core 2.11.0 there are three distinct states, not two. With no workflowMemory configured, resume throws Workflow state not found straight away, in the same process, milliseconds after the suspend. With an explicit InMemoryStorageAdapter, resume works in process and the run is gone the moment that process exits. Only a durable adapter such as LibSQLMemoryAdapter survives a restart. The test that separates the three is the only one a quickstart never runs: suspend in one process, exit it, and resume from a new one.
The three states
Measured on @voltagent/core 2.11.0, @voltagent/libsql 2.1.2, zod 4.1.13, Node 24.8.0. Every cell below comes from the run at the end of this article.
Scroll to see more
workflowMemory | resume in the same process | resume in a new process |
|---|---|---|
| not configured | throws Workflow state not found | throws Workflow state not found |
InMemoryStorageAdapter | works, single-digit ms | throws Workflow state not found |
LibSQLMemoryAdapter on a file | works, single-digit ms | works |
The middle row is the one that costs people a weekend. It passes every check you are likely to write while developing, because while developing you resume in the process that suspended. It fails in production for the single reason production exists: the process restarts.
Install
npm install --save-exact @voltagent/core@2.11.0 @voltagent/libsql@2.1.2 \
@voltagent/logger@2.0.2 ai@6.0.293 @ai-sdk/provider-utils@4.0.55 zod@4.1.13
@voltagent/core peer-requires ai version 6, so pin it. Installing the current ai version 7 line will not resolve.
The workflow under test
One step. If it has resume data it approves the invoice. If it does not, it suspends and waits for a human. That is the whole shape of every approval workflow, with the business logic removed so nothing else can explain the result.
const { createWorkflowChain } = require('@voltagent/core');
const { z } = require('zod');
const approvalWorkflow = createWorkflowChain({
id: 'invoice-approval',
name: 'Invoice approval',
input: z.object({ amount: z.number() }),
suspendSchema: z.object({ reason: z.string() }),
resumeSchema: z.object({ approvedBy: z.string() }),
result: z.object({ status: z.string(), approvedBy: z.string() })
}).andThen({
id: 'await-approval',
execute: async function (ctx) {
if (ctx.resumeData) {
return { status: 'approved', approvedBy: ctx.resumeData.approvedBy };
}
await ctx.suspend('needs a human');
return { status: 'unreachable', approvedBy: 'unreachable' };
}
});
module.exports = { approvalWorkflow };
Note the step is written with function rather than an arrow, and the chain declares a suspendSchema and a resumeSchema. Those two schemas are what make resumeData typed on the way back in.
The wiring, with one variable
Three arms, one line of difference between them. Nothing else in the harness changes, so anything that differs between the arms is attributable to storage and to nothing else.
const {
VoltAgent, Memory, WorkflowRegistry, InMemoryStorageAdapter
} = require('@voltagent/core');
const { approvalWorkflow } = require('./wf');
// arm 'none' : no workflowMemory at all
// arm 'inmem' : the in-memory adapter, explicitly
// arm 'libsql' : a file on disk
function boot(arm) {
const options = { workflows: { 'invoice-approval': approvalWorkflow } };
if (arm === 'inmem') {
options.workflowMemory = new Memory({ storage: new InMemoryStorageAdapter() });
}
if (arm === 'libsql') {
const { LibSQLMemoryAdapter } = require('@voltagent/libsql');
options.workflowMemory = new Memory({
storage: new LibSQLMemoryAdapter({ url: 'file:./approval.db' })
});
}
new VoltAgent(options);
return WorkflowRegistry.getInstance();
}
module.exports = { boot };
Test 1: resume inside the same process
run() resolves as soon as the workflow suspends. It hands back a result object whose status is suspended and which carries a resume method. Everything about that object looks healthy, which is exactly the problem.
// Suspend and resume inside ONE process, using the handle run() returned.
const { boot } = require('./boot');
const arm = process.argv[2];
(async function () {
boot(arm);
const { approvalWorkflow } = require('./wf');
const run = await approvalWorkflow.run({ amount: 4200 });
console.log('arm=' + arm + ' pid=' + process.pid + ' status=' + run.status);
const started = Date.now();
try {
const out = await run.resume({ approvedBy: 'dana' });
console.log(' same-process resume OK in ' + (Date.now() - started) +
'ms result=' + JSON.stringify(out.result));
} catch (err) {
console.log(' same-process resume THREW ' + err.message);
}
process.exit(0);
})();
Test 2: resume from a brand new process
Process A starts the run and exits. Process B boots the same wiring and asks the registry to resume by id. Two separate node invocations, so nothing can be smuggled across in a module-level variable.
// Process A: start the workflow, let it suspend, print the execution id, exit.
const { boot } = require('./boot');
const arm = process.argv[2];
(async function () {
boot(arm);
const { approvalWorkflow } = require('./wf');
const run = await approvalWorkflow.run({ amount: 4200 });
console.log('arm=' + arm + ' pid=' + process.pid + ' status=' + run.status);
console.log('EXECUTION_ID=' + run.executionId);
process.exit(0);
})();
// Process B: a brand new process. Resume by id through the registry.
const { boot } = require('./boot');
const arm = process.argv[2];
const executionId = process.argv[3];
(async function () {
const registry = boot(arm);
console.log('arm=' + arm + ' pid=' + process.pid + ' resuming ' + executionId);
try {
const out = await registry.resumeSuspendedWorkflow(
'invoice-approval', executionId, { approvedBy: 'dana' }
);
console.log(' fresh-process resume OK result=' + JSON.stringify(out.result));
} catch (err) {
console.log(' fresh-process resume THREW ' + err.message);
}
process.exit(0);
})();
The full matrix
#!/bin/bash
# Three storage arms. For each: resume in the same process, then resume in a new one.
rm -f approval.db
for arm in none inmem libsql; do
node inproc.js "$arm"
id=$(node start.js "$arm" | sed -n 's/^EXECUTION_ID=//p')
node resume.js "$arm" "$id"
echo
done
arm=none pid=600836 status=suspended
[2026-10-02T07:57:29.754Z] ERROR {"component":"workflow-registry"}: Workflow state not found: e76c47b6-dec3-480b-b5dc-fbbb4cd1b29d
same-process resume THREW Failed to resume workflow: Workflow state not found: e76c47b6-dec3-480b-b5dc-fbbb4cd1b29d
arm=none pid=600860 resuming ff714988-b000-4fa0-aaf6-93dd9bd5f0b9
[2026-10-02T07:57:30.913Z] ERROR {"component":"workflow-registry"}: Workflow state not found: ff714988-b000-4fa0-aaf6-93dd9bd5f0b9
fresh-process resume THREW Workflow state not found: ff714988-b000-4fa0-aaf6-93dd9bd5f0b9
arm=inmem pid=600867 status=suspended
same-process resume OK in 3ms result={"status":"approved","approvedBy":"dana"}
arm=inmem pid=600915 resuming 253dcf7a-a072-40ac-a901-7dc7d6c541a5
[2026-10-02T07:57:32.679Z] ERROR {"component":"workflow-registry"}: Workflow state not found: 253dcf7a-a072-40ac-a901-7dc7d6c541a5
fresh-process resume THREW Workflow state not found: 253dcf7a-a072-40ac-a901-7dc7d6c541a5
arm=libsql pid=600922 status=suspended
same-process resume OK in 7ms result={"status":"approved","approvedBy":"dana"}
arm=libsql pid=600958 resuming cd254b1a-7685-4a16-8706-259cdde29412
fresh-process resume OK result={"status":"approved","approvedBy":"dana"}
Read the none arm again, because it is the one the documentation does not prepare you for. The same process that suspended the workflow could not resume it. The id was still in a local variable. The resume method was still sitting on the object. Three milliseconds had passed. And the registry had nowhere to look the run up, because nothing had been written.
The control: delete the file
The libsql arm resumed across a restart. That is only attributable to the file if removing the file breaks it. Otherwise some process-global could be doing the work and the file could be incidental.
#!/bin/bash
# Control: if the file is really the carrier, deleting it must break the libsql arm.
rm -f approval.db
id=$(node start.js libsql | sed -n 's/^EXECUTION_ID=//p')
echo "db present, bytes: $(stat -c %s approval.db)"
node resume.js libsql "$id"
rm -f approval.db
id=$(node start.js libsql | sed -n 's/^EXECUTION_ID=//p')
rm -f approval.db
echo "db deleted before resume"
node resume.js libsql "$id"
db present, bytes: 4096
arm=libsql pid=601078 resuming 4230eb75-5fde-4532-bd36-aa6c535db4ef
fresh-process resume OK result={"status":"approved","approvedBy":"dana"}
db deleted before resume
arm=libsql pid=601116 resuming de0d6645-e1bd-40c3-8407-568fb3ac6190
[2026-10-02T07:57:45.029Z] ERROR {"component":"workflow-registry"}: Workflow state not found: de0d6645-e1bd-40c3-8407-568fb3ac6190
fresh-process resume THREW Workflow state not found: de0d6645-e1bd-40c3-8407-568fb3ac6190
With the file present, resume works. Delete the file between the suspend and the resume and the libsql arm fails with the identical error the unconfigured arm gave. The file is the carrier.
You can look inside it. The adapter creates a table called voltagent_memory_workflow_states, and after one suspend it holds exactly one row, whose suspension column carries the checkpoint: the step index, the step input, and the workflow state as of the pause.
What the documentation says, and where
This is not an undocumented feature. It is a documented feature whose prerequisite is documented on a different page from the feature.
The suspend and resume page has a section headed Restart and Crash Recovery, which says, verbatim:
VoltAgent suspend and resume docs: "If a process crashes while a workflow is still running, you can restart that execution from the latest persisted checkpoint."
It also says "VoltAgent restores checkpointed workflow data". Across the whole of that page, with navigation and sidebar stripped, the words Memory, storage, adapter and LibSQL each occur zero times. The page that promises recovery from a persisted checkpoint never says what makes the checkpoint persist.
The workflows overview page is where that lives:
VoltAgent workflows overview: "By default, VoltAgent uses InMemoryStorage for in-memory storage during development."
and, of that default provider, "The default provider, a non-persistent store ideal for development and stateless deployments." It frames the choice as being about execution history for debugging, and introduces a durable adapter with the words "This is useful when moving to production".
Two things are worth separating there. The framing treats persistence as an observability and production nicety rather than as the thing that makes resume work at all. And the measured default is not the documented default: a plain new VoltAgent({ workflows }) with no workflowMemory does not behave like the InMemoryStorageAdapter arm, it behaves like nothing at all. Configure the in-memory adapter explicitly and resume starts working in process. That is the gap between the first and second rows of the table.
The fix
const { Memory, VoltAgent } = require('@voltagent/core');
const { LibSQLMemoryAdapter } = require('@voltagent/libsql');
new VoltAgent({
workflows: { "invoice-approval": approvalWorkflow },
workflowMemory: new Memory({
storage: new LibSQLMemoryAdapter({ url: "file:./.voltagent/workflows.db" })
})
});
The overview page lists @voltagent/postgres and @voltagent/supabase as further adapters if a file is not where you want this to live. I did not run either, so see the section below on what is not measured here.
The test worth keeping
The durable part of this is not the adapter name. It is the shape of the test, and it transfers to any framework that offers suspend and resume, durable execution or checkpointing.
- Suspend a run and write down the execution id.
- Exit the process. Actually exit it, do not just await something else.
- Start a new process, boot the same wiring, and resume by id.
- Delete the backing store and run step 3 again. It must fail.
Step 4 is the one people skip, and it is the one that tells you whether you tested persistence or tested a cache. If deleting the store changes nothing, the store was not what you were reading from.
What I did not measure
Worth stating plainly, because a tutorial that only reports what worked is hard to trust.
- I did not test
@voltagent/postgresor@voltagent/supabase. The documentation lists them as adapters alongside the one I used, and I would expect the same result, but expecting is not measuring. - I did not test
restart()orrestartAllActive(), which the docs describe for runs in therunningstate after a crash. My runs weresuspended, which is a different state, and I usedresumeSuspendedWorkflow. - I did not test what happens when the schema of a workflow changes between the suspend and the resume. That is the obvious next failure mode and it is not covered here.
- I did not compare this against how other frameworks behave with no storage configured. Mastra documents its own suspend and resume and I have not run it, so nothing here is a claim about it.
Related
If you want the same idea in a different framework, the corpus has a walkthrough of the LangGraph checkpointer, which is the equivalent persistence layer there.
Primary sources used here: the VoltAgent suspend and resume documentation and the VoltAgent workflows overview. If you want to understand why a single file is a defensible place to keep run state, SQLite documents how it makes a commit atomic.
Written by
Ren OkabeRen builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.
Frequently asked questions
Why does resume fail in the same process, milliseconds after the suspend?
Because with no workflowMemory configured there is nowhere for the checkpoint to be written. The resume path looks the run up by execution id in the workflow memory, finds nothing, and throws Workflow state not found. The id being in a local variable does not help, because the registry does not read your variables.
Is InMemoryStorageAdapter the default?
The documentation describes InMemoryStorage as the default provider, but measured on @voltagent/core 2.11.0 a plain new VoltAgent with no workflowMemory does not behave like the InMemoryStorageAdapter arm. It fails to resume even in process. Configuring the in-memory adapter explicitly makes in-process resume work. Those are two different states.
What exactly gets persisted when a workflow suspends?
The LibSQL adapter creates a table called voltagent_memory_workflow_states. After one suspend it holds a single row whose suspension column carries the checkpoint: the suspended step index, that step's input, and the workflow state as of the pause.
How do I prove the file is really what makes resume work?
Delete it. Start a run, let it suspend, remove the database file, then resume from a new process. If the file is the carrier the resume fails with the same Workflow state not found error the unconfigured arm gives. If it still succeeds, something else is holding the state and you have not tested persistence.
Does this mean VoltAgent suspend and resume is broken?
No. It works correctly once a durable adapter is configured, including across a full process restart. The gap is that the suspend and resume documentation page promises restart and crash recovery from a persisted checkpoint without mentioning Memory, storage or an adapter anywhere on it. The prerequisite is documented on the workflows overview page instead.
Which adapter should I use?
Anything that writes outside the process. This article measured LibSQLMemoryAdapter against a local file, which is enough for a single machine. The overview page also lists adapters for PostgreSQL and Supabase, which were not tested here.
Does the same test apply to other agent frameworks?
The shape of the test does. Suspend, exit the process, resume from a new one, then delete the backing store and confirm the resume now fails. Any framework offering suspend and resume, durable execution or checkpointing can be checked that way. This article makes no claim about how any other framework behaves, because it did not run them.
Why did my script hang instead of printing an error?
An unhandled rejection from resume leaves the process sitting on VoltAgent's observability timers, which keep the event loop alive. Wrap the resume call in try and catch and call process.exit explicitly, as the scripts here do, or the failure looks like a hang rather than an error.
Related tutorials
LangGraph Checkpointer: Persist and Resume Agent State (2026)
Make a LangGraph agent durable with a checkpointer: attach SqliteSaver in one line, crash the graph on purpose, and resume from disk without re-running the Claude call.
Your VoltAgent script never exits
Measured on @voltagent/core 2.11.0: the first agent call lazily starts three setInterval timers that are never unref'd, so your Node process stays alive forever. Two of them poll 20 times a second. Here is the reproduction, the cause and the fix.
LangGraph Human-in-the-Loop (2026): Pause an Agent for Approval with interrupt()
A runnable LangGraph human-in-the-loop tutorial for 2026: pause an agent mid-run with interrupt(), review the proposed action, then approve, edit, or reject it with Command(resume=...). Includes the checkpointer requirement and the re-run-from-the-top gotcha that breaks most tutorials.