How many sandboxes should a multi-agent system have?
The first multi-agent system I put in front of users had two agents and one sandbox, and that was the correct design. The second had five agents and still one sandbox, and it broke in a way that cost me a day. A code agent ran a pip install to pin an older pandas for a legacy notebook. The analysis agent, three turns later in the same box, started producing subtly different numbers. Nothing crashed. No error surfaced. The run completed and the output was wrong.
That bug is not really about pandas. It is about a decision I never made: how many isolated environments this system has, and where the boundaries between them sit. Most teams make that decision by accident. You write the orchestrator, you create a sandbox because you need one, you pass the handle around because it is convenient, and eight weeks later the topology is load-bearing and nobody chose it.
There are three shapes worth knowing, and the choice between them is decidable if you know what symptom you are treating. I build PandaStack, so that is the sandbox in the code below, but the shapes are provider-agnostic and the reasoning holds if you are running containers.
Topology one: one box for the whole crew
Every agent in the run shares a single sandbox. It is the cheapest option and it has one genuine advantage that gets undersold: state exchange is free. The retrieval agent writes /workspace/pages.jsonl and the analysis agent reads it. No serialisation, no artifact registry, no plumbing in the orchestrator. The filesystem is the message bus, and it is a very good message bus.
It fails in three specific ways, and it is worth naming them precisely rather than gesturing at 'interference'.
Path collision. Two agents write /workspace/output.json. The second wins, silently, and which one is second depends on model latency that morning. This is the failure that does not reproduce.
Environment mutation. One agent changes something global — a package version, an environment variable, the working directory of a long-lived process — and every other agent inherits it. My pandas bug. The damage is invisible at the moment it happens and shows up as a wrong answer much later.
The wedge. One agent fills the disk, exhausts memory, leaves a process bound to port 8000, or spins forever in a loop. The whole run dies with it, including the four agents that were doing fine.
None of that means the shared box is wrong. It means it is right for small cooperative crews working on one coherent task, where the agents are supposed to be building on each other's state and where the failure of one agent should reasonably fail the run anyway. That is a lot of real systems.
Topology two: one sandbox per agent
Each agent gets its own environment for the length of the run. Blast radius is one agent. Dependency sets are independent, so the pandas conflict is structurally impossible rather than merely discouraged. A wedged agent is a failed task, not a failed run.
What you give up is the free message bus, and this is a larger cost than it looks on a whiteboard. You now need an explicit exchange mechanism, and you need to choose it deliberately. Three options, in increasing order of how much rope they give you:
- Through the orchestrator. Agents declare their outputs, the orchestrator reads them out of one sandbox and writes them into the next. Boring, auditable, and the right default for anything under a few hundred megabytes.
- Shared object storage. Agents write to content-addressed keys, downstream agents fetch by key. Good when artifacts are large or when stages run on different machines at different times.
- A shared mutable volume that several sandboxes write to. Available, occasionally necessary, and a bug factory.
That last one deserves the accusation. A shared mutable filesystem between concurrent agents has no locking you did not build, no versioning, no ownership, and no way to attribute a write after the fact. You have reintroduced every failure of the shared box while paying for the isolation you wanted, which is the worst square on the board. If two agents genuinely need to see each other's work as it happens, put them in one sandbox on purpose. Do not simulate it with a mount.
Here is the fan-out with the exchange step made explicit. The important detail is not the concurrency, it is that outputs are a declared list rather than whatever happens to be in the output directory.
# pip install pandastack
import concurrent.futures as cf
from pandastack import Sandbox
STAGES = [
[ # stage 1 runs in parallel
{"name": "retrieval", "setup": "pip install -q httpx trafilatura",
"outputs": ["pages.jsonl"]},
{"name": "schema", "setup": "pip install -q pydantic",
"outputs": ["schema.json"]},
],
[ # stage 2 sees stage 1's artifacts
{"name": "modeling", "setup": "pip install -q 'pandas<2' scikit-learn",
"outputs": ["scores.parquet"]},
],
]
def run_agent(spec, run_id, inbox):
sbx = Sandbox.create(
template="code-interpreter",
ttl_seconds=900,
metadata={"run": run_id, "agent": spec["name"]},
)
try:
sbx.exec(spec["setup"])
for name, blob in inbox.items():
sbx.filesystem.write("/workspace/in/" + name, blob)
agent_loop(sbx, spec["name"]) # your framework's turn loop
return {n: sbx.filesystem.read("/workspace/out/" + n)
for n in spec["outputs"]}
finally:
sbx.kill()
def orchestrate(run_id):
bus = {}
for stage in STAGES:
with cf.ThreadPoolExecutor(max_workers=len(stage)) as pool:
futures = [pool.submit(run_agent, s, run_id, dict(bus)) for s in stage]
for f in futures:
for name, blob in f.result().items():
if name in bus:
raise RuntimeError("two agents produced " + name)
bus[name] = blob
return busThe collision check on the last few lines is the whole point. In a shared box, two agents writing the same filename is a silent overwrite you discover in production. Here it is an exception with both agents' names in scope, thrown before anything downstream consumes the wrong artifact.
Topology three: one sandbox per attempt, forked from a prepared state
This is the one that changes what you can build, and it is the least commonly reached for. The unit of isolation is not the agent, it is the attempt.
Consider what an agent needs before it can do anything useful on a real codebase: the repo cloned, dependencies installed, a build run, fixtures loaded, maybe a database seeded. That is minutes of wall clock and it is identical for every attempt. If you want five agents trying five approaches to the same bug, the naive version pays for that preparation five times, which usually means you decide parallel exploration is too expensive and you run one attempt instead.
Copy-on-write fork removes the multiplication. Prepare once, snapshot the whole machine, then fork N children that each start from that exact state — same installed packages, same built artifacts, same warm caches — and diverge only in the pages they actually write. On PandaStack a same-host fork lands in 400-750ms, which is small enough that the preparation cost stops driving the design.
import concurrent.futures as cf
from pandastack import Sandbox
# Pay for preparation exactly once.
base = Sandbox.create(template="base", ttl_seconds=1800)
base.exec("git clone --depth 1 https://github.com/acme/api /workspace/api")
base.exec("cd /workspace/api && npm ci && npm run build")
snapshot_id = base.snapshot() # disk and memory, frozen
base.kill()
def attempt(n, prompt):
sbx = Sandbox.fork(snapshot_id, ttl_seconds=600) # 400-750ms same-host
try:
agent_loop(sbx, prompt)
test = sbx.exec("cd /workspace/api && npm test")
return {
"n": n,
"passed": test.exit_code == 0,
"diff": sbx.exec("cd /workspace/api && git diff").stdout,
}
finally:
sbx.kill()
with cf.ThreadPoolExecutor(max_workers=5) as pool:
tries = list(pool.map(lambda n: attempt(n, PROMPT), range(5)))
winner = next((t for t in tries if t["passed"]), None)There is a second benefit that is easy to miss. Because every attempt began from a byte-identical state, the attempts are comparable. When attempt three passes and attempt four fails, the difference is the agent's work and nothing else — not a package that resolved differently, not a cache that was warm for one run and cold for another. Best-of-N selection is only meaningful if the N were actually the same experiment.
Untrusted input is a different axis entirely
The three topologies above are arguments about correctness and cost, and you can defer them until a symptom appears. This one you cannot.
Say one sub-agent in your system summarises scraped web pages, or parses a PDF a user uploaded, or reads an email. That content is adversarial input by default. It arrives as text, it goes into a model's context, and the model is an instruction-follower. Nobody has a reliable defence against that at the prompt layer. What you can control is what the compromised agent can reach.
So the question stops being about dependency conflicts and becomes: what is in this box? If the sub-agent reading the scraped page shares a sandbox with the agent holding your production API token in an environment variable, a cloned private repo on disk, an authenticated database session, or an SSH key, then a successful injection has all of that. Not because anything was misconfigured, but because that is what sharing a machine means.
The rule I use is short: content I did not author does not share a machine with credentials I cannot cheaply rotate. The untrusted work happens in its own sandbox, with no secrets in its environment and no network reach it does not need. What comes back crosses the boundary as data with provenance attached, not as text spliced into another agent's instructions. That second half matters as much as the first — isolating the process and then pasting its output into a privileged agent's system prompt just moves the injection one hop.
What the extra sandboxes actually cost
The money argument against splitting is weaker than it sounds, and it is worth being precise about why. Sandbox cost is a function of how long things exist, not how many exist. Five sandboxes that live forty seconds each cost less than one sandbox that idles for an hour while a model thinks. If your platform bills for idle time you have a different problem, and it is the one to fix first, because it makes every good architecture expensive.
The real cost is orchestration code, and it is a permanent cost. Per-agent isolation means lifecycle management, cleanup on partial failure, an artifact exchange layer, and a result-collection step that behaves sanely when one of five attempts dies. That is a few hundred lines you will maintain for as long as the system exists, and there is no version of it that is fun.
Debugging is where the split pays you back, and this is underrated. In a shared box, everything you want to know is one interleaved stream: five agents' stdout, five agents' installs, five agents' stack traces, in whatever order they happened. Answering 'what did the retrieval agent actually run' becomes an exercise in grep and inference.
With one sandbox per agent, tagged at creation with the run id and the agent name, that question has a lookup instead of an answer. You pull the sandbox for that agent in that run, read its logs and its filesystem, and see exactly what it did without anything else in the frame. The first time you debug a five-agent failure this way you will stop resenting the orchestration code.
A heuristic that survives contact
Start with one sandbox for the whole system. Split when you hit a concrete symptom, and split on the axis the symptom names — not on the axis that feels most thorough.
- Dependency conflict, or one agent mutating the environment under another: split per agent.
- You want N parallel attempts at the same task and the setup cost is what is stopping you: split per attempt, forked from a prepared snapshot.
- One agent wedging the box takes down agents that were fine: split per agent, and put a timeout on every exec while you are there.
- You cannot tell which agent produced a wrong artifact: split per agent, mostly for the per-agent logs.
- An agent processes input you did not author: split on the trust boundary, before the symptom, because the symptom is a breach.
The last one is the exception to the whole approach and that is deliberate. Every other line on that list is a bug you can absorb, diagnose, and fix. Trust boundaries are not like that, so they get designed in up front even though nothing has gone wrong yet.
And the concession, which I mean: if you have a researcher agent and a writer agent working on one document, one sandbox is correct. Give them the shared filesystem. Skip the artifact bus, skip the per-agent lifecycle, skip all of it. Building a three-tier isolation topology for a two-agent crew is over-engineering that you will be maintaining long after you have forgotten why you did it — and you will still hit the same bugs, just with more code in between you and them.
Frequently asked questions
Should every agent in a multi-agent system get its own sandbox?
No, and defaulting to it costs you more than it saves. A shared sandbox gives agents a free message bus — one writes a file, the next reads it — with no serialisation and no orchestration code. That is the right shape for small cooperative crews working on one coherent task. Split when a symptom names the axis: conflicting dependencies, one agent mutating global state under another, or one agent wedging the box and killing agents that were fine. The exception is a trust boundary, which you design in before any symptom appears.
How do agents exchange state if they are in separate sandboxes?
Three patterns, and the first is the right default. Route artifacts through the orchestrator: each agent declares its outputs, the orchestrator reads them out of one sandbox and writes them into the next. For large artifacts or stages that run at different times, use object storage with content-addressed keys. The third option — a shared mutable volume several sandboxes write to concurrently — reintroduces every failure of the shared box while you pay for isolation, because there is no locking, no versioning, and no way to attribute a write afterwards.
Is running many sandboxes more expensive than one?
Less than you would guess, because cost tracks how long sandboxes exist rather than how many. Five sandboxes living forty seconds each cost less than one idling for an hour while a model thinks. If your platform bills for idle time, fix that first — it makes every good architecture look expensive. The genuine cost is orchestration code: lifecycle handling, cleanup on partial failure, artifact plumbing, and result collection that survives one attempt dying. That is a permanent maintenance burden, and it is the honest reason to delay splitting.
When is forking a sandbox per attempt worth it?
When the setup cost is large and identical across attempts. Cloning a repo, installing dependencies, running a build, and seeding fixtures can take minutes, and paying that N times is usually what stops teams from running parallel explorations at all. Snapshot the prepared machine once and fork children from it — a same-host fork on PandaStack is 400-750ms. A second benefit is comparability: because every attempt started byte-identical, differences in outcome are the agent's work rather than a package that resolved differently. If attempts need different environments, there is no shared prefix and forking buys nothing.
Does a sub-agent handling scraped or user-supplied content need its own sandbox?
Yes, and this is the one case where you should not wait for a symptom. Untrusted text goes into a model's context and models follow instructions, so treat successful prompt injection as a given and control what the compromised agent can reach instead. If it shares a box with an agent holding an API token, a cloned private repo, or a live database session, the injection inherits all of it. Give the untrusted work its own sandbox with no secrets and minimal network reach, and pass results back as data with provenance rather than text spliced into a privileged agent's prompt.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.