Tutorials
Ren Okabe12 min read145 views

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.

Flat printed schematic on pale paper. A horizontal line across the top runs through two open squares and ends in a filled circle, the finished work. Below it, unconnected, three outlined dials with amber pointers are joined by a thin line that loops between them and returns.
Flat printed schematic on pale paper. A horizontal line across the top runs through two open squares and ends in a filled circle, the finished work. Below it, unconnected, three outlined dials with amber pointers are joined by a thin line that loops between them and returns.
On this page

Node.js TypeScript OpenTelemetry Vercel npm

Quick answer

As of September 2026, a standalone VoltAgent Agent keeps your Node process alive forever after the first model call. The call itself finishes in well under a second. Your last line of code runs. Then nothing happens, and the process sits there until something kills it.

Nothing is stuck or retrying. The first call lazily creates an observability subsystem, and that subsystem starts three setInterval timers that are never unref'd and never cleared. Two of them poll ten times a second, forever.

Three things are worth knowing before you go looking:

  • new Agent(...) is completely clean. The timers appear only after the first generateText() or streamText(), which is why this never shows up until you actually call a model.
  • The observability option on AgentOptions looks like the lifecycle handle you want. It is not the instance the agent uses, and passing one gives you six timers instead of three.
  • The supported fix is to wrap your agents in a VoltAgent instance and call await app.shutdown(). If you are using a bare Agent, the only thing that frees the process is a method TypeScript marks private.

Measured first-party on @voltagent/core 2.11.0, ai 6.0.293 and Node 24.8.0, against a local fake model server, with no API key of any kind.

Reproduce it in about a minute

You do not need a model provider account. A twelve line HTTP server is enough, because the question is about what happens in your process after the call, not about what the model says.

bash
mkdir voltagent-exit && cd voltagent-exit
npm init -y
npm install --save-exact @voltagent/core@2.11.0 ai@6.0.293 @ai-sdk/provider-utils@4.0.55 @ai-sdk/openai-compatible@2.0.79 @voltagent/logger@2.0.2 zod@4.6.5

A fake model that answers one OpenAI-shaped completion:

js
// fake-model.js
const http = require('http');

http.createServer(function (req, res) {
  let body = '';
  req.on('data', function (chunk) { body += chunk; });
  req.on('end', function () {
    const reply = {
      id: 'chatcmpl-1',
      object: 'chat.completion',
      created: 1,
      model: 'probe-model',
      choices: [
        { index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }
      ],
      usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
    };
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify(reply));
  });
}).listen(8899, function () { console.log('fake model on 8899'); });

Start it in one terminal with node fake-model.js. Then the agent:

js
// hang.mjs
import { Agent } from '@voltagent/core';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const provider = createOpenAICompatible({
  name: 'probe',
  baseURL: 'http://127.0.0.1:8899/v1',
  apiKey: 'not-a-real-key',
});

const agent = new Agent({
  name: 'billing-agent',
  instructions: 'You are a billing assistant.',
  model: provider('probe-model'),
});

const started = Date.now();
const result = await agent.generateText('hello');
console.log('reply:', JSON.stringify(result.text));
console.log('last line of the script, reached at +' + (Date.now() - started) + 'ms');

Run it under a timeout so you get a verdict instead of a hung terminal:

bash
timeout 20 node hang.mjs
echo "EXIT CODE: $?"
text
reply: "ok"
last line of the script, reached at +97ms
EXIT CODE: 124

Exit code 124 is timeout reporting that it had to kill the process. The work finished at 97 milliseconds. The process was still alive twenty seconds later. The exact millisecond figure moves a little between runs; the exit code does not.

Nothing is stuck. Some timers are just still running.

The temptation is to assume a request is hanging, or that a retry is pending, or that the stream was never closed. Node will tell you directly. process.getActiveResourcesInfo() lists what is currently holding the event loop open.

js
// handles.mjs
import { Agent } from '@voltagent/core';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

function timers() {
  return process.getActiveResourcesInfo().filter(function (r) { return r === 'Timeout'; }).length;
}

const provider = createOpenAICompatible({
  name: 'probe', baseURL: 'http://127.0.0.1:8899/v1', apiKey: 'k',
});

console.log('timers at startup                 :', timers());
const agent = new Agent({
  name: 'billing-agent',
  instructions: 'You are a billing assistant.',
  model: provider('probe-model'),
});
console.log('timers after new Agent()          :', timers());
await agent.generateText('hello');
console.log('timers after first generateText() :', timers());
process.exit(0);
text
timers at startup                 : 0
timers after new Agent()          : 0
timers after first generateText() : 3

Two things fall out of that immediately.

Constructing the agent is free. Zero timers. Whatever is happening is not a side effect of new Agent(...), which is why unit tests that build an agent and assert on its configuration will never catch this.

The first model call is what does it. Three timers, created lazily, and still there when your code has finished.

Note the last line of that script: process.exit(0). Without it, handles.mjs would hang too. That is the whole article in one detail.

Which three, and who started them

Patching setInterval before importing the library gives you the creation site of every recurring timer, and handle.hasRef() tells you whether a given timer is one of the ones keeping the process alive.

js
// who-made-them.mjs
const live = new Map();
const realSetInterval = global.setInterval;
const realClearInterval = global.clearInterval;

global.setInterval = function (fn, ms) {
  const handle = realSetInterval.apply(global, arguments);
  const frame = new Error('x').stack.split('\n')[2].trim();
  live.set(handle, { ms: ms, frame: frame });
  return handle;
};
global.clearInterval = function (handle) {
  live.delete(handle);
  return realClearInterval.apply(global, arguments);
};

const { Agent } = await import('@voltagent/core');
const { createOpenAICompatible } = await import('@ai-sdk/openai-compatible');

const provider = createOpenAICompatible({
  name: 'probe', baseURL: 'http://127.0.0.1:8899/v1', apiKey: 'k',
});
const agent = new Agent({
  name: 'billing-agent', instructions: 'You are a billing assistant.', model: provider('probe-model'),
});
await agent.generateText('hello');

for (const [handle, info] of live) {
  const keepsProcessAlive = typeof handle.hasRef === 'function' ? handle.hasRef() : null;
  console.log('every ' + String(info.ms).padStart(7) + 'ms  keeps process alive: ' + keepsProcessAlive);
  console.log('    ' + info.frame.replace(process.cwd(), '.'));
}
process.exit(0);
text
every   60000ms  keeps process alive: true
    at new InMemoryStorageAdapter (file://./node_modules/@voltagent/core/dist/index.mjs:6524:30)
every     100ms  keeps process alive: true
    at LazyRemoteExportProcessor.startInitializationCheck (file://./node_modules/@voltagent/core/dist/index.mjs:7119:30)
every     100ms  keeps process alive: true
    at RemoteLogProcessor.startInitializationCheck (file://./node_modules/@voltagent/core/dist/index.mjs:7002:30)

All three come from the same place. Walking further up the stack, they are created inside new VoltAgentObservability(...), which is created by createVoltAgentObservability(), which is called from Agent.getObservability(), which is called from Agent.createOperationContext(). That last one runs per operation, which is why the timers appear on the first call and not at construction.

Two of the three are initialisation polls. LazyRemoteExportProcessor and RemoteLogProcessor are the pieces that would ship spans and logs to a remote console. With no VoltOps credentials configured, there is nothing for them to finish initialising against, and the poll simply keeps going.

The control: is this VoltAgent, or the AI SDK underneath it?

VoltAgent is built on the Vercel AI SDK, and the request goes out through undici, so it is worth ruling both of those out before blaming anything. The same provider, the same fake server, the same model id, with the framework removed:

js
// control.mjs
import { generateText } from 'ai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const provider = createOpenAICompatible({
  name: 'probe', baseURL: 'http://127.0.0.1:8899/v1', apiKey: 'k',
});

const result = await generateText({
  model: provider('probe-model'),
  system: 'You are a billing assistant.',
  prompt: 'hello',
});
console.log('reply:', JSON.stringify(result.text));
console.log('last line of the control script');
text
reply: "ok"
last line of the control script
EXIT CODE: 0

Exit 0, no timeout, no process.exit() call. The AI SDK on its own releases the process cleanly. undici does create two timers of its own during the request, but both are unref'd, which means they do not hold the loop open. The behaviour belongs to VoltAgent.

Two of them wake up ten times a second, each

A 60 second interval is close to free. Two 100 millisecond intervals are a different matter, and it is worth confirming they actually keep firing rather than being one-shot.

js
// polling.mjs
let fires = 0;
const realSetInterval = global.setInterval;
global.setInterval = function (fn, ms) {
  if (ms === 100) {
    return realSetInterval.call(global, function () { fires = fires + 1; return fn(); }, ms);
  }
  return realSetInterval.apply(global, arguments);
};

const { Agent } = await import('@voltagent/core');
const { createOpenAICompatible } = await import('@ai-sdk/openai-compatible');
const provider = createOpenAICompatible({ name: 'p', baseURL: 'http://127.0.0.1:8899/v1', apiKey: 'k' });
const agent = new Agent({ name: 'a', instructions: 'i', model: provider('probe-model') });
await agent.generateText('hello');

const from = Date.now();
setTimeout(function () {
  const seconds = (Date.now() - from) / 1000;
  console.log('100ms-interval callbacks fired in ' + seconds.toFixed(1) + 's: ' + fires);
  console.log('wakeups per second: ' + (fires / seconds).toFixed(1));
  process.exit(0);
}, 5000);
text
100ms-interval callbacks fired in 5.0s: 100
wakeups per second: 20.0

Twenty wakeups a second, indefinitely, per agent.

It is worth being fair about what this is. The same library does know about unref(). If you extend the census in who-made-them.mjs by waiting a few seconds before printing, instead of printing immediately, a fourth interval shows up: a 30 minute one created at index.mjs:4545, and that one reports keeps process alive: false. It was unref'd on purpose.

So this is three timers that were not unref'd, in a codebase that unrefs elsewhere. That reads as an oversight rather than a design position, and it is the kind of thing that is trivial to fix upstream.

The typed option that looks like the fix, and is not

AgentOptions has a public, typed observability field. The obvious move is to construct your own instance, hand it to the agent, and shut it down when you are done. That gives you a handle on the lifecycle, and it compiles.

js
// ignored-option.mjs
import { Agent, NodeVoltAgentObservability } from '@voltagent/core';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

function timers() {
  return process.getActiveResourcesInfo().filter(function (r) { return r === 'Timeout'; }).length;
}

const provider = createOpenAICompatible({
  name: 'probe', baseURL: 'http://127.0.0.1:8899/v1', apiKey: 'k',
});

const mine = new NodeVoltAgentObservability();
console.log('after new NodeVoltAgentObservability() :', timers());

const agent = new Agent({
  name: 'billing-agent',
  instructions: 'You are a billing assistant.',
  model: provider('probe-model'),
  observability: mine,
});
await agent.generateText('hello');
console.log('after generateText()                   :', timers());

console.log('is the agent using the instance I passed?', agent.getObservability() === mine);

await mine.shutdown();
console.log('after shutting down MY instance        :', timers());

await agent.getObservability().shutdown();
console.log('after shutting down the agent instance :', timers());
text
after new NodeVoltAgentObservability() : 3
after generateText()                   : 6
is the agent using the instance I passed? false
after shutting down MY instance        : 3
after shutting down the agent instance : 0

Read that carefully, because it is the least obvious result here.

Constructing an observability instance starts three timers of its own. The agent then ignores it and builds a second one on the first call, which is why the count goes to six. Shutting down the instance you own clears yours and leaves the agent's three running, so the process still hangs. The identity check says it plainly: false.

Passing the observability option to a bare Agent does not give you control of its lifecycle. It doubles the timers.

What actually frees a bare Agent, and why TypeScript refuses

Exactly one thing takes the count to zero, and you can see it on the last line of that output: agent.getObservability().shutdown().

That method is not part of the Agent public surface. It is declared private:

ts
// tscheck.ts
import { Agent, VoltAgent } from '@voltagent/core';
declare const agent: Agent;
declare const app: VoltAgent;
const a = app.getObservability();
const b = agent.getObservability();
export { a, b };
text
tscheck.ts(6,17): error TS2341: Property 'getObservability' is private and only accessible within class 'Agent'.

One error, on one line. app.getObservability() on the VoltAgent instance compiles fine. agent.getObservability() on the Agent does not. In plain JavaScript it works, and it is the only thing that works, but you are reaching past a private marker to do it, and a library is free to rename a private method in a patch release.

The supported fix: wrap the agents in a VoltAgent instance

The documented lifecycle lives one level up. VoltAgent is the application object, and it has a real shutdown:

js
// fix-app.mjs
import { Agent, VoltAgent } from '@voltagent/core';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const provider = createOpenAICompatible({
  name: 'probe', baseURL: 'http://127.0.0.1:8899/v1', apiKey: 'k',
});

const agent = new Agent({
  name: 'billing-agent',
  instructions: 'You are a billing assistant.',
  model: provider('probe-model'),
});

const app = new VoltAgent({ agents: { agent } });

const result = await agent.generateText('hello');
console.log('reply:', JSON.stringify(result.text));

await app.shutdown();
console.log('shut down, the script can now end');
text
reply: "ok"
[2026-09-28T07:34:57.976Z] INFO {"component":"voltagent"}: [VoltAgent] Starting graceful shutdown...
[2026-09-28T07:34:57.976Z] INFO {"component":"voltagent"}: [VoltAgent] Suspending active workflows...
[2026-09-28T07:34:57.976Z] INFO {"component":"voltagent"}: [VoltAgent] Shutting down telemetry...
[2026-09-28T07:34:57.977Z] INFO {"component":"voltagent"}: [VoltAgent] Graceful shutdown complete
shut down, the script can now end
EXIT CODE: 0

Exit 0, with no process.exit() anywhere. The timestamps in those log lines will obviously differ on your run. Omitting server from the constructor means no HTTP server is started, so this works fine for a script.

The vendor documents this under a heading called Runtime Accessors and Shutdown, which shows app.getObservability() and await app.shutdown() side by side on the VoltAgent instance page. If you are writing a long lived server, the cancellation and shutdown guide covers wiring that to SIGINT and SIGTERM.

So the rule of thumb is short. If your code creates a bare Agent and nothing else, it will not exit. Add the VoltAgent wrapper and shut it down.

What the docs say about this, measured

It is easy to assert that something is undocumented. It is cheap enough to check properly, so here is the measurement rather than the assertion.

Every documentation URL in voltagent.dev/sitemap.xml was fetched and scanned as rendered text, with word boundaries so that hang does not match changing. That is 129 pages, 0 fetch failures.

Scroll to see more

TermPagesOccurrences
shutdown447
graceful816
SIGINT or SIGTERM214
getObservability11
process.exit11
"never exits" or "does not exit"00
hang, hangs, hanging00
setInterval00
unref00
"event loop"00

Shutdown is documented, and documented reasonably well, on the application object. The symptom is not documented anywhere: no page in the corpus mentions a process failing to exit, and no page mentions timers, unref or the event loop. The single getObservability occurrence is the VoltAgent accessor on the instance page, not the Agent one.

That is a fair summary of the gap. The fix exists and is written down. The failure mode that sends you looking for it is not.

What this actually costs you

Whether this matters depends entirely on the shape of your program, and it is worth being concrete rather than alarming.

A long running server: essentially nothing. Your process was going to stay up anyway. Two extra 10Hz timers is not a meaningful load, and you presumably want the observability pipeline running.

A script, a cron job, or a CI step: this is the whole problem. The job does its work in a hundred milliseconds and then never returns. A CI runner will sit on it until the step timeout. A cron wrapper that waits for the child will stack up processes.

A serverless function: it depends on your platform. Some runtimes freeze the container when the handler's promise resolves and never notice; others wait for the event loop to drain and will bill you for the wait or time out. Worth testing rather than assuming.

The cost scales with the number of agents, because each one builds its own. Adding a second agent to handles.mjs and calling it too takes the count from three to six, not from three to three. Ten agents is thirty ref'd timers and two hundred wakeups a second. Both generateText() and streamText() trigger it identically.

One more thing you will see in your terminal

Every VoltAgent run above prints this to stderr, and it is unrelated to the hang but people reasonably wonder about it:

The AI SDK warns, verbatim: "System messages in the prompt or messages fields can be a security risk because they may enable prompt injection attacks. Use the system option instead when possible. Set allowSystemInMessages to true to suppress this warning, or false to throw an error."

Adding one line to fake-model.js, a console.log(body) just before it writes the reply, shows why. Your instructions are placed in the messages array as a system-role message rather than passed through the AI SDK's dedicated system option:

json
{
  "model": "probe-model",
  "messages": [
    { "role": "system", "content": "You are a billing assistant." },
    { "role": "user", "content": "hello" }
  ]
}

On the wire an OpenAI-compatible request carries the system prompt in messages regardless, so the end result here is the same. The warning is about the SDK-level distinction, and it is the AI SDK's own guidance rather than a VoltAgent claim. It is noise in this context, not a finding, and it is mentioned only so you can stop wondering where it comes from.

What I could not establish

Three things I went looking for and did not find, recorded so you do not spend the same time.

The default prompt constants are not injected. @voltagent/core exports DEFAULT_INSTRUCTIONS (2,373 characters) and FEW_SHOT_EXAMPLES (2,898 characters), which together look exactly like a hidden system prompt waiting to be discovered. They are not added to a plain agent. The wire dump above is the whole request: your instructions, your message, nothing else.

I did not establish why the two initialisation polls never complete. With no VoltOps credentials configured they simply keep polling, which is consistent with waiting on an initialisation that cannot happen, but I did not read enough of the implementation to claim that as the mechanism.

I did not test a real serverless runtime. The reasoning about frozen containers versus drained event loops is general Node behaviour, not something I measured on a specific platform.

The short version

If you have a VoltAgent script that will not exit, you are not debugging a network call. You are looking at three setInterval timers that the observability subsystem creates on your first model call and never releases.

Wrap your agents in a VoltAgent instance and await app.shutdown(). If you are deliberately using a bare Agent, agent.getObservability().shutdown() is the only thing that frees the loop, and you should expect TypeScript to object.

All of the above is reproducible against a fake local server in a few minutes, with no provider account, on @voltagent/core 2.11.0. If you want to check whether a future release has fixed it, handles.mjs is a three line regression test: build an agent, make one call, and assert that process.getActiveResourcesInfo() contains no Timeout. Node documents that API and the unref semantics behind it in the timers and process references.

Ren Okabe

Written by

Ren Okabe

Frequently asked questions

Why does my VoltAgent script never exit?

Measured in September 2026 on @voltagent/core 2.11.0, the first generateText() or streamText() call on an Agent lazily creates an observability subsystem that starts three setInterval timers. None of them is unref'd and none is ever cleared, so they hold the Node event loop open indefinitely. The model call itself finishes in well under a second. Constructing the Agent creates no timers at all, which is why the problem only appears once you actually call a model.

How do I make a VoltAgent script exit cleanly?

Wrap your agents in a VoltAgent instance and call await app.shutdown() when you are done. That is the documented lifecycle and it releases the process, exit code 0. If you are deliberately using a bare Agent with no VoltAgent wrapper, the only thing that clears the timers is await agent.getObservability().shutdown(), and that method is declared private on Agent so TypeScript will report error TS2341.

Which timers are keeping the process alive?

Three, all created from Agent.getObservability() by way of createVoltAgentObservability(). One 60000ms interval from InMemoryStorageAdapter, and two 100ms initialisation polls from LazyRemoteExportProcessor and RemoteLogProcessor. The two 100ms ones were measured firing 100 times in 5.0 seconds, which is 20 wakeups per second in total, indefinitely. A fourth interval the library creates, a 30 minute one, is correctly unref'd and does not hold the loop open.

Is this a bug in the Vercel AI SDK rather than VoltAgent?

No. The same provider, the same fake model server and the same model id, called through the AI SDK's own generateText() with VoltAgent removed, exits with code 0 and no process.exit() call. undici creates two timers of its own during the request and both are unref'd, so neither holds the loop open. The behaviour belongs to VoltAgent.

Does passing the observability option to Agent fix it?

No, and it makes the count worse. AgentOptions has a public typed observability field, but the agent does not use the instance you pass. An identity check returns false. Constructing your own instance starts three timers, the agent then builds a second set on the first call for six in total, and shutting down the instance you own leaves the agent's three running, so the process still hangs.

Does this affect streamText as well as generateText?

Yes. Both trigger it identically, because the timers are created in Agent.createOperationContext() which runs per operation regardless of which method you called. The cost also scales with the number of agents, because each agent builds its own observability instance: two agents in one process produce six ref'd timers rather than three.

Is this documented by VoltAgent?

The fix is, the symptom is not. Every one of the 129 documentation URLs in voltagent.dev/sitemap.xml was fetched and scanned as rendered text with word boundaries, with zero fetch failures. Shutdown appears on 4 pages and SIGINT or SIGTERM on 2, both on the application object. The phrases never exits and does not exit appear on 0 pages, and hang, setInterval, unref and event loop each appear on 0 pages.

Does it actually matter for my app?

It depends on the shape of the program. For a long running server it is close to irrelevant, since the process was staying up anyway. For a script, a cron job or a CI step it is the whole problem, because the job finishes its work in milliseconds and then never returns. For a serverless function it depends on whether your platform freezes the container on handler resolution or waits for the event loop to drain, which is worth testing rather than assuming.

Tutorials

Your Zod .refine() never reaches the model

Measured on ai 7.0.118 and zod 4.6.5: a .refine() on a tool input schema is stripped before the schema reaches the model, with no warning. z.string().startsWith('acct_') arrives as a pattern; the same rule written as a refinement arrives as nothing.

11 min read161
Tutorials

Your MCP stdio server keeps running after the client dies (Node)

An MCP stdio server that holds a timer or a pool open does not exit when its client dies. Measured on SDK 1.30.0 and Node 24.8.0: it is reparented to init and keeps running. StdioServerTransport registers no stdin end-of-file listener. Here is the reproduction and the four-line fix.

9 min read139
Tutorials

generateText defaults to one step, so your agent returns an empty string

On ai 7.0.116, generateText and streamText run exactly one step by default. Pass tools without stopWhen and the model calls a tool, your tool runs, and you get an empty string with no error. The same package defaults ToolLoopAgent to twenty. Measured on the wire, with the bundle lines that cause it.

9 min read159