all posts

Long-Running Sandboxes for AI Agents

Ajay Kumar··10 min read

Most sandbox platforms are built around one verb: run. You hand them a snippet, they spin up an environment, execute it, return stdout, and tear the whole thing down. It's a beautiful model for a single tool call — a code interpreter answering one math question, a function evaluating one expression. It's also completely the wrong shape for a real AI agent. A coding agent doesn't run once; it clones a repo, edits a file, runs the tests, reads the failure, edits again, and does that forty times over twenty minutes. A research agent keeps a browser session open across a dozen turns. These workloads don't fit in a run-and-die box, and forcing them into one is how you end up re-cloning the same repo on every single tool call.

I'm Ajay — I build PandaStack, an open-source Firecracker microVM platform — so I spend an unreasonable amount of my life thinking about the lifecycle of a sandbox: when it's born, how long it lives, what happens to it while nobody's looking, and when it's finally safe to kill. This is the pillar post on long-running sandboxes: why one-shot execution breaks for agents, the two honest ways to stay alive (keep the VM running vs. snapshot-and-hibernate), how to not go bankrupt on idle machines, and the TTL/reaper semantics that keep the whole thing from leaking VMs. If you want the agent-side framing first, I wrote a companion piece on why an LLM agent's long-running task wants a microVM — this post is the infrastructure underneath it.

Why one-shot sandboxes break for agents

The one-shot model has an implicit assumption baked into it: the unit of work is a single, stateless execution. Input in, output out, no memory of before, no expectation of after. That assumption holds for a lot of things — evaluate this cell, lint this diff, render this chart. It falls apart the instant the unit of work is a conversation with an environment rather than a single command against it.

Think about what a coding agent actually does across a task. Turn one: `git clone`, `npm install` — two minutes of setup that produces a `node_modules` directory and a working tree. Turn two: run the failing test, read the traceback. Turn three: edit `src/auth.ts`. Turn four: re-run the test against the edited tree. Turn five: the test passes, now run the linter. Every one of those turns depends on the filesystem state the previous turns built up. If each turn is a fresh one-shot sandbox, you either re-run the two-minute setup every single turn — which is absurd — or you have no shared state and the agent is editing a file in a machine that will forget the edit before it can test it.

The tell that you've outgrown one-shot sandboxes: your agent framework is re-cloning the repo, re-installing dependencies, or re-uploading a working directory on every tool call. That's not a workflow — that's paying the setup tax N times to fake the persistence you actually need.

There's a second problem, subtler than state: some sessions are inherently long-lived and can't be decomposed into independent shots at all. A browser automation agent holds a live Chromium process with cookies, an authenticated session, and a page's JS heap — you cannot serialize that into stdout, kill the machine, and reconstruct it next turn. A REPL-driven data-analysis agent has a Python kernel with 8GB of DataFrames loaded; the whole point is that the next command operates on what the last command produced. For these, the sandbox isn't a place you visit once. It's a workspace the agent lives in for the duration of the task.

So the real requirement is: a sandbox that stays alive and stateful across many turns, for minutes to hours, that the agent can talk to repeatedly. The two honest ways to build that are keeping the VM running and hibernating it between bursts — and the interesting engineering is entirely in the second one.

The two ways to be long-running

There are exactly two mechanisms for a sandbox to survive across turns, and every platform's "long-running" story is some combination of them. It's worth being precise about the difference, because they have very different cost profiles.

Option 1: keep the VM alive (persistent)

The simplest thing that works: create the VM, don't destroy it, and route every turn to the same machine. The filesystem is right there, the running processes are right there, the browser session is right there, because it's literally the same VM the whole time. On PandaStack this is the `persistent: true` flag — it exempts the sandbox from the idle reaper (the background loop that would otherwise reclaim quiet VMs) and pins it to its host, so the agent can come back to it turn after turn and find everything exactly as it left it.

This is the right default for an active session. When an agent is mid-task and turns are seconds apart, a persistent VM gives you the fastest possible turn latency — there's nothing to restore, the process is already running, you just send the next command over the existing connection. The catch is the flip side of "always alive": a persistent VM consumes its memory and CPU allocation whether the agent is hammering it or the user stepped away for lunch. Alive means billed. That's fine for the minutes an agent is actively working; it's wasteful for the hours a workspace sits idle between bursts.

Option 2: hibernate, then wake on demand

The second mechanism trades a little wake latency for a lot of idle savings. Instead of keeping the VM running while nobody's using it, you snapshot its full state — memory and disk — pause it, and let it stop consuming compute. When the next turn arrives, you wake it back up from that snapshot. The agent sees a continuous, stateful workspace; underneath, the machine wasn't actually burning cycles the whole time it was quiet.

On PandaStack, hibernate is precisely "snapshot plus pause" and wake is "resume." Because Firecracker's snapshot captures the entire guest — RAM contents, process state, open file descriptors — a woken VM resumes exactly where it froze: the Python kernel still has its DataFrames, the dev server is still listening, the working tree is untouched. This is the same snapshot machinery that makes every create fast in the first place; I've written up how the snapshot/restore path and warm starts work if you want the mechanics. Hibernate/wake is that mechanism pointed at a live session instead of a fresh template.

Persistent and hibernate aren't rivals — they're layers. Persistent keeps a VM instantly ready for an active session. Hibernate parks a session between bursts so idle time trends toward free. The strong pattern is: persistent while the agent is working, hibernate when it goes quiet, wake when it comes back.

The idle-cost problem (and scale-to-zero)

Here's the economic trap that makes long-running sandboxes hard. Agent workloads are bursty and spiky in the worst possible way for a keep-it-alive model. A coding agent works furiously for ninety seconds, then waits four minutes for the human to review a diff and reply. A user opens a hosted notebook, runs three cells, then goes to a meeting for an hour. If you're keeping the VM alive across all of that, you're paying full freight for a machine that's doing nothing the overwhelming majority of the wall-clock time. Multiply by every user, and your idle spend dwarfs your active spend.

The naive fix — just kill idle VMs and recreate them — throws away exactly the state that made the session long-running in the first place. The agent comes back to a fresh machine with no `node_modules`, no loaded kernel, no browser session. You've solved the cost problem by reintroducing the one-shot problem. Not a fix.

The real answer is scale-to-zero: get the idle cost as close to nothing as possible while preserving the session so a returning agent finds it intact. Hibernate is the front door to this. A hibernated VM is paused — it isn't spending CPU. But you can push further. Because the hibernated state is just a snapshot, you can delete the paused VM entirely on hibernate and stream it back from a GCS-backed seed on wake. Now an idle session holds no live machine at all — just bytes in object storage — and the compute footprint of an idle sandbox is genuinely zero. The wake path reconstructs the VM from that seed when the next turn arrives.

The honest trade is wake latency, and it depends entirely on which path you're on. A warm resume — the VM was paused but still present on the host — is fast: you're just un-pausing a machine that's already there. A full cold wake, where the VM was deleted and has to be re-booted and streamed back from the seed in object storage, is on the order of tens of seconds, because you're paying to reconstruct the machine from scratch. That's the dial: the more aggressively you scale idle cost toward zero, the more you pay to bring the session back. Pick per workload — an interactive notebook a user pokes at all day wants warm resumes; a workspace that might sit dormant for days wants full scale-to-zero and can eat the cold wake.

Don't invent a single wake number for your users. "Wake" is a family of paths with wildly different latencies — a warm resume is near-instant, a full cold wake that re-boots and re-downloads is tens of seconds. Which one you get depends on whether the VM was merely paused or fully evicted. Set expectations per path, not per feature.

TTL and reaper semantics: the safety net

The moment you have long-lived sandboxes, you have a new failure mode: leaked VMs. An agent crashes mid-task and never calls destroy. An orchestration process gets OOM-killed and forgets the twelve sandboxes it was juggling. A user closes the tab and the frontend never fires the teardown. Without a backstop, every one of those becomes a machine that runs — and bills — forever. The backstop is TTL and the reaper.

The `ttl_seconds` you set on create is a hard deadline, not an idle timer. It says "reap this VM this many seconds after creation, no matter what." It's the last line of defense against leaks: even if every other cleanup path fails, the reaper — a background loop that sweeps for expired sandboxes — will eventually reclaim the machine. For a genuinely long-running agent you set this generously (an hour, several hours) or, for a truly persistent workspace, you lean on `persistent: true` to exempt the VM from the reaper entirely and take on the responsibility of tearing it down yourself. The two are deliberately different tools: TTL is "reap this eventually no matter what"; persistent is "never auto-reap this, I'll manage its life."

  • `ttl_seconds` (backstop reaper) — a hard, absolute deadline from creation. The VM is reclaimed when it expires, full stop, whether or not anyone's using it. Set it long enough to cover the worst-case task duration, and treat it as a safety net for leaks, not as your primary teardown path.
  • Idle reaper — a background loop that reclaims sandboxes that have gone quiet, so a forgotten VM doesn't linger. `persistent: true` exempts a sandbox from it; a persistent workspace won't be reaped for being idle.
  • `persistent: true` — opt out of idle reaping and pin the VM to its host. Now the sandbox lives until you explicitly destroy it (or its TTL, if set, still fires). This is the flag for a workspace you intend to keep across a long session.
  • Explicit destroy — the primary, deterministic teardown. Call it when the task ends. TTL and the reaper exist for when you can't, not as a substitute for cleaning up.

The mental model that keeps you out of trouble: destroy is your job, TTL is the insurance. Design your agent loop to tear down its sandbox when the task completes — in a `finally`, on session close, wherever cleanup belongs. Then set a TTL as the backstop for the day your cleanup code doesn't run, because eventually it won't. A leaked persistent VM with no TTL is a bill that grows until a human notices.

A long-lived sandbox across many turns

Here's the shape in the Python SDK. This is the key departure from the one-shot pattern: the sandbox is created once, outlives any single request, and is talked to repeatedly across turns before being explicitly torn down. Note the generous `ttl_seconds` acting purely as a backstop, the `metadata` tagging the VM to a task and user for audit and billing, and the explicit teardown in a `finally` so a mid-task exception can't leak the machine.

from pandastack import Sandbox

# ONE microVM that lives across the whole agent task, not one per turn.
# Created via snapshot-restore (~49ms restore; ~179ms p50 / ~203ms p99
# end-to-end), so "keep it warm across turns" starts cheap and STAYS warm.
sbx = Sandbox.create(
    template="agent",
    persistent=True,               # exempt from the idle reaper: it won't be
                                   # reclaimed just for going quiet between turns
    ttl_seconds=3600,              # BACKSTOP only: if our cleanup never runs,
                                   # the reaper reclaims this VM after an hour
    metadata={                     # attribute the VM for audit + billing
        "task_id": "refactor-auth-module",
        "user_id": "u_8821",
    },
)

try:
    # Turn 1 — one-time setup. This state PERSISTS for every later turn;
    # we never re-clone or re-install across the task.
    sbx.exec("git clone https://github.com/acme/api /workspace/api")
    sbx.exec("cd /workspace/api && npm install")   # node_modules survives

    # Turn 2 — the agent runs the failing test and reads the traceback.
    failing = sbx.exec("cd /workspace/api && npm test", timeout_seconds=300)
    # ... feed failing.stdout / failing.stderr back to the model ...

    # Turn 3 — the model edits a file. The edit lands in the SAME working tree
    # the test just ran against, because it's the same VM.
    sbx.filesystem.write("/workspace/api/src/auth.ts", model_rewrote_this)

    # Turn 4 — re-run the test against the edited tree. No re-clone, no
    # re-install; the machine remembers everything from turns 1-3.
    passing = sbx.exec("cd /workspace/api && npm test", timeout_seconds=300)

    # ...many more turns: lint, build, commit... all against one live workspace.

finally:
    # DESTROY is your job; TTL is only the insurance. Tear down explicitly the
    # moment the task is done so an idle persistent VM doesn't bill forever.
    sbx.destroy()

# The VM outlived every single request above. That's the whole point: an
# agent lives IN a sandbox for a task, it doesn't visit a fresh one per call.

The load-bearing details, in order of how often they bite people. First, the sandbox is created before the loop and destroyed after it — its lifetime is the task's, not a request's. Second, `persistent=True` is what stops the idle reaper from reclaiming the VM during the four-minute pause while a human reviews a diff. Third, `ttl_seconds` is set generously and exists only for the crash case — it is not how you normally clean up. Fourth, the `finally` block is the real teardown; if you take one habit from this post, make it "destroy in a `finally`." And if this workspace goes quiet for long stretches, this is exactly the sandbox you'd hibernate between bursts rather than keep hot — same VM, same state, just paused.

One-shot vs. long-running, side by side

Both models are correct — for different workloads. The mistake is using one where the other belongs: paying setup cost N times because you forced a stateful task into one-shot sandboxes, or paying idle cost forever because you kept a machine alive for a workload that only needed it once.

  • Lifetime — One-shot sandbox: born per execution, dies when the command returns. Long-running sandbox: born per task or session, lives across many turns for minutes to hours.
  • State between turns — One-shot sandbox: none; every run starts from the template. Long-running sandbox: full filesystem, process, and memory state carries forward, so setup happens once.
  • Best-fit workload — One-shot sandbox: a single tool call, a code-interpreter answer, a stateless function eval. Long-running sandbox: an iterating coding agent, a live browser session, a REPL-driven analysis, anything conversational with its environment.
  • Idle cost — One-shot sandbox: zero, because there's nothing to sit idle. Long-running sandbox: real if kept alive — solved with hibernate/wake and scale-to-zero so paused sessions trend toward free.
  • Teardown — One-shot sandbox: automatic and immediate when the command returns. Long-running sandbox: explicit `destroy()` is the primary path, with `ttl_seconds` and the reaper as the leak backstop.
  • Failure blast radius — One-shot sandbox: a leak is impossible; nothing outlives the call. Long-running sandbox: a forgotten VM bills forever without a TTL, so the reaper is a load-bearing safety net, not an afterthought.

When to pick long-lived vs. fresh-per-run

The decision is mostly about state and turn cadence. If a task is genuinely one execution — no dependence on prior filesystem state, no live session to preserve — reach for fresh-per-run. Create the VM via snapshot-restore (p50 179ms, so it's cheap), run the command, destroy it. Nothing survives, nothing leaks, idle cost is structurally zero because there's no idle. This is the right home for the long tail of one-shot tool calls, scheduled jobs, and stateless function evaluations, and it's why snapshot-restore matters so much — it makes a fresh VM cheap enough to be the default whenever you can get away with it.

Reach for long-lived the moment the task is a session rather than a shot: an agent that iterates on a working tree across turns, a hosted notebook a user returns to, a browser or REPL session that can't be reconstructed from stdout. Here a fresh VM per turn is both too slow (you'd re-run setup every time) and semantically wrong (you'd lose the state that is the point). Keep one VM per task or per user, mark it `persistent`, route the turns to it, and destroy it when the session ends. For the bursty middle — long-lived but idle in stretches — layer hibernate/wake on top so the workspace survives without billing for the quiet hours.

  • Fresh-per-run — no cross-turn state, independent executions, cost-sensitive at rest. Create, run, destroy. The default for one-shot tool calls and scheduled jobs; idle cost is zero because there's no idle.
  • Long-lived persistent — stateful, multi-turn, latency-sensitive session. One VM per task or user, `persistent=True`, explicit teardown. The default for iterating agents and interactive sessions.
  • Long-lived + hibernate — stateful but bursty, with quiet stretches between bursts. Keep the session, hibernate it when idle, wake on the next turn. The default when warmth is the product but you refuse to pay for idle.
Capacity is rarely what forces the choice. A PandaStack agent pre-allocates 16,384 /30 subnets, so networking isn't the ceiling — host memory and CPU are. That's exactly why hibernate and scale-to-zero matter: they free the memory and CPU an idle session was holding, so you can keep many more live-but-dormant workspaces per host than you could if every one had to stay resident.

Putting it together

Real AI agents don't run once; they live in an environment for the length of a task, and the sandbox has to live with them. One-shot execution is perfect for a single tool call and wrong for an iterating agent — the moment you catch your framework re-cloning a repo every turn, you've outgrown it. Stay long-running one of two ways: keep the VM alive with `persistent: true` for active sessions, or hibernate (snapshot plus pause) and wake it between bursts, pushing all the way to scale-to-zero — delete on hibernate, stream back from a GCS seed on wake — when idle cost is the enemy. Let snapshot-restore keep fresh creates cheap so "long-lived" is a deliberate choice and not a tax. And whichever path you pick, make destroy your job and TTL your insurance: the reaper is the reason a crashed agent doesn't leave a machine billing forever. Give the agent a place to live for the task, then make sure it's genuinely gone when the task is done.

Frequently asked questions

Why can't I just use a one-shot sandbox for my AI agent?

Because a one-shot sandbox is stateless by design — it runs a command, returns stdout, and dies — while a real agent task is a conversation with an environment across many turns. A coding agent clones a repo and installs dependencies on turn one, then edits and re-tests across the next dozen turns, and every turn depends on the filesystem state the previous ones built. With one-shot sandboxes you'd either re-run the whole setup on every tool call or lose the state that makes the task work. When you notice your framework re-cloning or re-installing per turn, you've outgrown one-shot execution and need a long-running sandbox.

What's the difference between a persistent sandbox and a hibernated one?

A persistent sandbox stays alive — on PandaStack, `persistent: true` exempts the VM from the idle reaper and pins it to its host, so it's instantly ready every turn but consumes its memory and CPU the whole time, idle or not. A hibernated sandbox is snapshotted and paused (hibernate = snapshot + pause), so it stops consuming compute while quiet and is resumed on the next turn (wake = resume). Persistent optimizes for turn latency during an active session; hibernate optimizes for idle cost between bursts. The strong pattern is to keep a VM persistent while the agent is actively working and hibernate it when the session goes quiet.

How do I stop long-running sandboxes from costing money while idle?

Use hibernate to pause idle sessions so they stop burning CPU, and push toward scale-to-zero for sessions that may sit dormant a long time: because the hibernated state is just a snapshot, you can delete the paused VM entirely on hibernate and stream it back from a GCS-backed seed on wake, so an idle session holds no live machine at all. The trade is wake latency — a warm resume of a still-present paused VM is fast, while a full cold wake that re-boots and re-downloads from the seed is on the order of tens of seconds. Exact wake time depends on which path you're on, so match the aggressiveness of your idle savings to how much wake latency the workload can tolerate.

What does ttl_seconds actually do for a long-running sandbox?

`ttl_seconds` is a hard, absolute deadline from creation — a backstop reaper, not an idle timer. It guarantees the VM is reclaimed that many seconds after it's created no matter what, which is your last line of defense against leaked machines when an agent crashes or your orchestration process dies without calling destroy. For a long-running agent you set it generously to cover the worst-case task duration and treat it as insurance, not as your normal teardown path. The primary teardown is an explicit `destroy()` when the task ends — ideally in a `finally` block. Use `persistent: true` to exempt a workspace from idle reaping when you intend to manage its lifetime yourself.

When should I use a long-lived sandbox versus a fresh one per run?

Use fresh-per-run when the task is a single stateless execution with no dependence on prior state — one-shot tool calls, scheduled jobs, function evals. Snapshot-restore makes a fresh create cheap (p50 179ms), so nothing survives, nothing leaks, and idle cost is structurally zero. Use a long-lived sandbox the moment the task is a session rather than a shot: an agent iterating on a working tree, a hosted notebook, a browser or REPL session that can't be reconstructed from stdout. Keep one VM per task or user, mark it persistent, and destroy it when the session ends. For long-lived but bursty workloads, layer hibernate/wake on top so the workspace survives without billing for the quiet hours.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.