Agent frameworks
Ren Okabe11 min read129 views

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.

Flat printed schematic on off-white paper. Three horizontal navy rails. The top rail is broken by a gap and stops there. The middle rail runs unbroken. The bottom rail runs unbroken, carries a hollow navy square above it and a solid amber square below it, and ends in a filled navy dot.
Flat printed schematic on off-white paper. Three horizontal navy rails. The top rail is broken by a gap and stops there. The middle rail runs unbroken. The bottom rail runs unbroken, carries a hollow navy square above it and a solid amber square below it, and ends in a filled navy dot.
On this page

TypeScript Node.js Zod SQLite npm

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

workflowMemoryresume in the same processresume in a new process
not configuredthrows Workflow state not foundthrows Workflow state not found
InMemoryStorageAdapterworks, single-digit msthrows Workflow state not found
LibSQLMemoryAdapter on a fileworks, single-digit msworks

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

bash
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.

javascript
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.

javascript
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.

javascript
// 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.

javascript
// 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);
})();
javascript
// 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

bash
#!/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
text
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.

bash
#!/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"
text
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

javascript
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.

  1. Suspend a run and write down the execution id.
  2. Exit the process. Actually exit it, do not just await something else.
  3. Start a new process, boot the same wiring, and resume by id.
  4. 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/postgres or @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() or restartAllActive(), which the docs describe for runs in the running state after a crash. My runs were suspended, which is a different state, and I used resumeSuspendedWorkflow.
  • 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.

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.

Ren Okabe

Written by

Ren Okabe

Ren 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.

Tutorials

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.

12 min read146