Agent runtimes: containers vs microVMs
Read the source of enough open-source coding agents and you notice they all built the same thing. OpenHands, SWE-agent, and most of the frameworks orbiting them ship an action-execution runtime with an identical shape: a long-lived container holding a workspace directory, a shell, and a small server the agent loop talks to over HTTP. The model emits an action — run this command, edit this file, read this path — the server executes it, and the observation goes back into the context window.
This convergence is not laziness. It is the correct shape. A workspace that persists across steps, a session that outlives a single command, and a narrow action protocol between the loop and the machine are exactly the primitives a coding agent needs. Anyone who has tried to run an agent by shelling out per action, statelessly, discovers within an afternoon why everyone else built a session.
The part worth arguing about is not the shape. It is the boundary. The default answer — a container — is a decision that was made when these projects were research harnesses run by their own authors, and it quietly stayed the default when they became platforms other people point at customer repositories. This post compares the container runtime against a microVM-per-session runtime on the axes agents actually stress, and tries to be honest about where the container is still the right call.
The pattern everyone converged on
Stripped to its essentials, the container runtime looks like this. The specifics vary by project and by version — check each project's own docs before assuming any particular flag or default, because these things move — but the skeleton is remarkably consistent.
#!/usr/bin/env bash
# The agent runtime, roughly as every OSS coding agent builds it.
set -euo pipefail
SESSION="agent-$1" # one container per agent session
WORKSPACE="$PWD/workspaces/$1"
mkdir -p "$WORKSPACE"
# Long-lived: it holds the checkout, the venv, the node_modules, the
# half-finished patch. Killing it between actions would defeat the point.
docker run -d --name "$SESSION" \
-v "$WORKSPACE:/workspace" \
-p 0:8100 \
--memory=4g --cpus=2 \
--pids-limit=512 \
agent-runtime:latest \
/opt/action-server --port 8100 --workspace /workspace
PORT="$(docker port "$SESSION" 8100 | head -1 | cut -d: -f2)"
# The agent loop now speaks to the action server for the rest of the task:
# POST /run {"command": "pytest -x tests/"}
# POST /edit {"path": "src/app.py", "diff": "..."}
# POST /read {"path": "src/app.py"}
curl -s -XPOST "http://localhost:$PORT/run" \
-H 'content-type: application/json' \
-d '{"command": "pytest -x tests/"}'
# ...and hours later, when the task ends:
docker rm -f "$SESSION"That is a good piece of engineering. It is also, on a shared host, a polite suggestion to the kernel. Namespaces and cgroups are what the container has instead of a boundary: a private view of the system and a budget, both enforced by the same kernel that every other session on the box is also using. Nothing in that stack was designed to withstand an adversary who gets to choose the syscalls.
Axis 1: the boundary, under commands a model chose
Here is what makes agent runtimes different from every other container workload you operate. In a normal service, the code inside the container is code you wrote and reviewed. In an agent runtime, the commands are chosen at runtime by a model, and the model's choices are downstream of whatever text it read: the repository, its issues, its dependencies, the web page a tool fetched.
So the threat model is not "my code has a bug." It is closer to "an issue body on GitHub gets a vote on what runs as root inside my runtime." Prompt injection turns any attacker-controlled text in the agent's context into a partial command channel, and the agent's job description is to run commands. You do not need a jailbreak; you need a convincing paragraph in a README.
There is a middle ground and it deserves credit: gVisor puts a user-space kernel between the workload and the host to shrink that syscall surface, and Kata Containers wraps containers in lightweight VMs to get container ergonomics with VM isolation. Both are real, both are deployable under an agent runtime, and both exist precisely because the industry already agreed that a bare container is not a multi-tenant boundary. Their compatibility and performance characteristics change between releases, so verify against their current docs rather than against anything you read in a blog post — including this one.
Axis 2: an agent session is mostly waiting
Watch the timeline of a real agent task. The runtime executes a command for a few seconds, then sits there while the model reads the output, thinks, and writes the next action. Then it executes again. Across a long task, the machine is idle for the overwhelming majority of wall-clock time, and it is idle in a way you cannot avoid, because the whole point of the session is that the workspace is still there when the model comes back.
For a container runtime this is mostly a resource-accounting problem: the container holds its memory reservation while it waits, and if you run a fleet of them you are paying for a lot of RAM that is doing nothing but remembering. The usual mitigations are worse than the disease — reaping idle sessions aggressively means agents lose their workspace mid-task, which shows up as mysterious quality regressions rather than as errors.
A microVM runtime can attack the same problem from a different direction, because a VM has something a container does not: a serializable machine state. You can hibernate a waiting session — snapshot guest memory and device state, release the host resources — and restore it when the next action arrives. On PandaStack that restore is the same code path as a normal create, which lands around 179ms p50 and 203ms p99. Billing follows the same logic: $0.054 per active vCPU-hour and $0.0162 per GiB-hour, so a session that spends an hour waiting on a model is not billed as an hour of compute.
Axis 3: workspace state across a long task
Both models keep a workspace. The difference is what "state" means. A container's durable state is its bind-mounted directory: the files. Everything else — the running dev server, the warm build cache in tmpfs, the page cache, the shell's environment, the language server that finally finished indexing — evaporates when the container stops.
A microVM's state is the machine. A snapshot captures guest RAM, device state, and disk together, so restoring it gives you back the process that was running, not just the files it had written. For an agent that spent four minutes getting a monorepo's toolchain into a usable condition, the distinction is the difference between resuming and starting over. It is also why the next axis is possible at all.
Axis 4: branching a session, which is where containers stop
An agent that has narrowed a bug to two plausible fixes should try both. Not sequentially with an undo in between — actually both, from the same warm starting state, and keep whichever one turns the tests green. This is the single most useful thing a runtime can offer an agent loop, and the container runtime cannot really do it.
You can approximate it. Commit and branch in git, which covers tracked files and nothing else. Start a second container from the same image, which throws away the warm state that made the first one useful. Copy the workspace directory, which duplicates gigabytes and still loses every running process. Each approximation is lossy in a way that matters precisely when the environment was expensive to build.
Copy-on-write forking of a microVM is not an approximation. The child shares the parent's memory pages until it writes to them and shares the parent's disk blocks via reflink until it writes to those, so a branch costs metadata rather than a copy. Same-host forks land in 400-750ms; cross-host is 1.2-3.5s because the artifacts have to move over the network first.
from pandastack import Sandbox
# One microVM per agent session. Same shape as the container runtime:
# a workspace, a shell, a long-lived session the loop talks to.
# ttl_seconds is the dead-man's switch for the day your loop crashes
# and never reaches the finally block. Always set it.
session = Sandbox.create(template="agent", ttl_seconds=3600)
try:
# The expensive part, paid once: checkout, toolchain, deps, a
# test run that warms every cache the next test run will hit.
session.exec("git clone https://github.com/acme/widget /work",
timeout_seconds=300)
session.exec("cd /work && mise install && npm ci", timeout_seconds=600)
baseline = session.exec("cd /work && npm test", timeout_seconds=900)
print(baseline.exit_code, baseline.stdout[-4000:])
# A durable copy of that warm machine -- RAM, device state, disk.
# Restoring it later is a restore, not a rebuild.
session.snapshot()
# The model proposed two fixes. Try BOTH, from the same warm state.
# Copy-on-write: 400-750ms same-host, no re-install, no re-index.
branches = []
for i, patch in enumerate(candidate_patches):
b = session.fork(metadata={"candidate": str(i)})
b.filesystem.write("/work/fix.patch", patch.encode())
b.exec("cd /work && git apply fix.patch", timeout_seconds=60)
branches.append((i, b, b.exec("cd /work && npm test",
timeout_seconds=900)))
winner = next((i for i, _, r in branches if r.exit_code == 0), None)
print("green candidate:", winner)
# Keep the branch that passed; the losers were never real machines
# anyone has to reason about again.
for i, b, _ in branches:
if i != winner:
b.kill()
finally:
session.kill()Note what the loop did not have to do: re-clone, re-install, re-index, or reason about whether candidate two was contaminated by candidate one. Each branch is a separate machine with a separate kernel, which is a much easier thing to explain to yourself at 2am than "we reset the working tree and hoped."
Axis 5: fan-out for parallel sessions
Fan-out is the same problem one level up: not two branches of one session, but many sessions at once — a batch evaluation, a swarm of agents on separate issues, an interactive product with real users. Containers fan out cheaply in the sense that starting one is cheap, and expensively in the sense that each one is a fresh cold environment unless you build your own warm-pool machinery.
The microVM answer is the same primitive as branching: warm one machine, snapshot it, and fork per session. The other constraint people worry about is networking, because per-sandbox network namespaces sound expensive to create. They are, if you create them on demand — which is why PandaStack pre-allocates 16,384 /30 subnets per agent host and hands one out per sandbox in roughly a millisecond. The real ceiling in practice is memory and CPU on the host, not addresses.
Axis 6: startup, and why "containers start faster" stopped being true
The reflex is that containers start in milliseconds and VMs start in seconds, so containers win. That reflex compares the wrong things. A container starts fast when the image is already local and the process inside it is ready immediately; an agent runtime image is neither small nor instantly ready, and the honest measure is not "container started" but "the action server answered and the toolchain works."
Meanwhile, a production microVM platform does not boot a VM per request. It restores a snapshot of an already-booted, already-warm machine. On PandaStack every create is a restore: about 179ms p50 and 203ms p99 end to end, of which the snapshot-load step is roughly 49ms. The first-ever spawn of a template does a real cold boot at around 3s and then bakes the snapshot; every create after that takes the fast path. Restoring a booted machine is a fundamentally different operation from starting a cold one, and it is why the latency argument for containers has quietly expired.
Axis 7: blast radius when something does get out
Assume the escape happens, because the interesting question is never whether the boundary is perfect but what it costs when it is not. On a shared host running container sessions, an escape reaches the host kernel, and from there the other tenants' workspaces, the container runtime socket if it is reachable, the host's credentials, and the cloud metadata endpoint. If the runtime is what mounts customer repositories, the escape reaches all of them.
The version of this that keeps people up is not an elite exploit chain. It is a model that read a poisoned issue body, concluded with great confidence that cleaning the build directory was step one, and ran a recursive delete with a path that resolved somewhere it should not have. A shared kernel means "somewhere it should not have" has more places to be. In a microVM, that command destroys one guest, the guest is ephemeral by design, and the incident report is one paragraph long.
This is also the axis where the two models diverge most in operational terms. Container escapes are an incident affecting a host. MicroVM guest compromise is a sandbox doing exactly what sandboxes are for.
Axis 8: cost and complexity, honestly
MicroVMs are not a free lunch and it does readers no favours to pretend otherwise. A VM has a guest kernel and a device model, so there is real per-guest memory overhead a container does not pay. You need hardware virtualization, which means bare metal or a cloud instance type that exposes nested virtualization — not every environment offers it, and it is a genuine constraint on where you can run.
And running the stack yourself is more work than running containers. Somebody has to own snapshot baking and invalidation, a network-namespace pool, copy-on-write disk plumbing, memory streaming so a restore does not wait on a multi-gigabyte file, and a scheduler that knows which host has which artifact. That is a platform team's quarter, not an afternoon. The reason managed microVM platforms exist is that this list is long and mostly undifferentiated for anyone whose actual product is the agent.
The container runtime's honest advantage is that it is one dependency you probably already have, with a debugging story every engineer on your team already knows. That is worth real money, and it is why the next section is not a formality.
Side by side
- Isolation boundary — Container runtime: namespaces and cgroups on the host's shared kernel; a filter and a budget, not a wall. microVM runtime: a separate guest kernel behind hardware virtualization, with a narrow virtio device surface.
- Model-chosen commands — Container runtime: every syscall the model reaches for hits the host kernel you share with other sessions. microVM runtime: the syscalls hit a kernel that belongs to that session and dies with it.
- Idle session cost — Container runtime: holds its memory reservation for the whole task, most of which is spent waiting on the model. microVM runtime: hibernate to a snapshot while waiting, restore in ~179ms p50 when the next action arrives; per-second billing at $0.054/vCPU-hour and $0.0162/GiB-hour.
- Workspace state — Container runtime: durable state is the mounted directory; running processes and warm caches are lost on stop. microVM runtime: the snapshot is the whole machine — RAM, device state, and disk — so a restore resumes rather than restarts.
- Branching a session — Container runtime: approximations only (git branch, a second container, a directory copy), each losing the warm state that mattered. microVM runtime: copy-on-write fork, 400-750ms same-host and 1.2-3.5s cross-host, from the exact warm state.
- Fan-out — Container runtime: cheap to start many, but each is cold unless you build a warm pool yourself. microVM runtime: fork one warm parent per session; 16,384 pre-allocated /30 subnets per host means networking is not the bottleneck, memory and CPU are.
- Startup — Container runtime: fast once the image is local, but "started" is not "ready". microVM runtime: snapshot restore of an already-booted machine — ~179ms p50 / ~203ms p99, ~49ms for the load step; only the very first spawn cold-boots (~3s).
- Escape blast radius — Container runtime: the host kernel, and therefore every other tenant on the box. microVM runtime: one guest, which was ephemeral anyway.
- Operational burden — Container runtime: one dependency your team already knows how to debug. microVM runtime: snapshots, CoW disks, netns pools, memory streaming, artifact-aware scheduling — a platform, unless you buy one.
- Best fit — Container runtime: one trusted developer, their own machine, their own repository, fastest possible iteration. microVM runtime: multi-tenant, customer repositories, untrusted input in the context window, or anywhere branching and idle-cost actually matter.
When the container runtime is the right call
A single trusted developer running an agent on their own laptop against their own repository should use the container runtime that ships with the framework. The threat model is "I might break my own machine," which a container handles adequately. The iteration loop is fast, the mount is right there in the file explorer, and every debugging instinct the developer already has still works. Reaching for a hypervisor here would be ceremony.
The same applies to CI steps on code you control, internal tools behind your own auth, and demos. The container is not wrong; it is scoped. What changes the answer is a second party: another tenant on the host, a customer's repository, or text in the context window that someone outside your organisation wrote.
The container runtime is right about the shape of an agent session and optimistic about its boundary. Optimism is free until the session belongs to someone else.
The bottom line
Keep the design the OSS agents converged on — session, workspace, action server, narrow protocol. It is the right architecture and reimplementing it would be a waste of a quarter. What is worth revisiting is the layer underneath it, because the two arguments that made the container the default have both weakened: startup latency is no longer a microVM problem once creates are snapshot restores, and the cost argument inverts once you can hibernate a session that is only waiting on a model.
What is left is a straightforward question about who else is on the host and whose code the model is reading. If the answer is "only me," ship the container and move on. If the answer involves anyone else, a session boundary with its own kernel buys you an isolation story you can actually explain — plus branching, which turns out to be the feature agents want most and containers can least provide.
Frequently asked questions
Why did OpenHands, SWE-agent, and similar projects all pick containers?
Because containers were the obvious tool for the problem they had. These projects started as research harnesses run by their own authors on their own machines, where the threat model is self-inflicted damage and the priority is a fast, portable iteration loop. Docker delivers exactly that, with an ecosystem every contributor already knows. The choice was correct for that context; it just was not re-litigated when the same runtimes started being pointed at other people's repositories on shared hosts. Check each project's current docs for what isolation options it supports today — several offer alternative runtimes, and the details change between releases.
Can I keep the agent framework and just swap the runtime underneath?
Usually yes, and it is the cheapest path. The action protocol between an agent loop and its runtime is small — run a command, read a file, write a file, list a directory — so an adapter that speaks the framework's runtime interface and executes against a microVM session is a modest amount of code. You keep the framework's prompting, planning, and evaluation harness, and change only where the commands land. That is a much better trade than forking an agent project.
Isn't a VM per agent session too heavy when I have hundreds of them?
The two things people expect to be expensive are creation and idling, and both are addressable. Creates are snapshot restores rather than boots — around 179ms p50 on our platform — and a warm parent can be forked per session via copy-on-write in 400-750ms same-host, so hundreds of sessions do not mean hundreds of cold environments. Idling is handled by hibernating sessions that are only waiting on the model. What genuinely does cost more than a container is the per-guest kernel and device-model memory. Weigh that against a full agent run's model spend, where it is usually a rounding error.
Do gVisor or Kata Containers solve this without moving to microVMs?
They move meaningfully in the right direction. gVisor shrinks the host-kernel surface by intercepting syscalls in a user-space kernel; Kata gives containers a lightweight VM boundary and can use Firecracker as the VMM underneath. Both are reasonable choices for an agent runtime and both are worth benchmarking on your own workload, since compatibility and overhead vary by version and by what your agents actually do. What neither gives you for free is the machine-state layer — snapshot, restore, and copy-on-write fork of a running session — which is where most of the agent-specific value in the microVM model comes from.
What actually breaks first if I run container-based agent sessions multi-tenant?
In practice, resource interference before security. Sessions on a shared kernel contend for page cache, I/O, and PIDs in ways cgroups bound loosely, so one agent running an enormous build makes its neighbours slow and flaky, and flakiness in an agent's feedback loop degrades the agent's decisions rather than producing a clean error. The isolation failure is rarer and much worse when it arrives. Both push in the same direction: give each session its own kernel and its own memory, and the failure modes become local.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.