Every file the agent reads, every command it runs, every Python script it executes — all of it travels through one interface: SandboxApi. The interface has eleven methods. What changes between deployment modes is not the interface but the implementation behind it: local filesystem and subprocess in development, a remote control server connected over a private TCP port in production. The agent, the tools, and the turn loop never observe the difference. That topology-independence is the core design decision of the sandbox layer, and this chapter traces exactly how it is achieved.
Start with the interface itself, because everything else derives from it.
from typing import AsyncIterator, Protocol
from typing import TypedDict
class MkdirOptions(TypedDict, total=False):
recursive: bool
class RmOptions(TypedDict, total=False):
recursive: bool
force: bool
class SandboxApi(Protocol):
# Filesystem (8)
async def read_file(self, path: str) -> str: ...
async def read_file_buffer(self, path: str) -> bytes: ...
async def write_file(self, path: str, content: str | bytes) -> None: ...
async def stat(self, path: str) -> FileStat: ...
async def readdir(self, path: str) -> list[str]: ...
async def exists(self, path: str) -> bool: ...
async def mkdir(self, path: str, options: MkdirOptions | None = None) -> None: ...
async def rm(self, path: str, options: RmOptions | None = None) -> None: ...
# Execution (1)
async def exec(self, command: str, options: ExecOptions | None = None) -> ShellResult: ...
# Streaming (2 — v2 addition)
async def read_file_stream(self, path: str) -> AsyncIterator[bytes]: ...
async def write_file_stream(self, path: str, source: AsyncIterator[bytes]) -> None: ...Eight filesystem methods and one execution method make up the core. The two streaming methods are a v2 addition, and their significance becomes clear later. What matters first is what the contract is not: it exposes no terminal, no package manager, no networking layer, no process group management. It exposes the minimum surface an agent needs to read and write files in a sandboxed directory and run shell commands. Every implementation must satisfy all eleven methods. The rest of the runtime never branches on which one it has.
The exec method carries ShellResult as its return type — { stdout, stderr, exit_code } — and accepts timeout_s and supports cancellation via asyncio.Task.cancel() / asyncio.CancelledError. The timeout_s field is the primary cancellation contract: most provider SDKs expose a native timeout option but few support mid-flight cancellation, so the interface prioritizes what adapters can reliably implement. Async task cancellation is available for adapters whose execution context supports it; the interface notes that adapters observing both should honor whichever fires first. This is a contract that has thought through the failure modes of remote execution, not just the happy path.
The file stat type is similarly careful. FileStat marks is_symlink, size, and mtime as optional and notes that adapters must never fabricate placeholder values for fields their provider does not expose. The constraint is a statement about trust boundaries: callers must not build logic on fields that a remote backend might silently omit.
LocalSandbox is the simplest implementation. It wraps aiofiles (or pathlib/os) for file operations and asyncio.create_subprocess_exec for execution. It takes an optional default_cwd for exec calls and otherwise delegates directly to the standard library with no intermediate layer.
The shell is /bin/sh -c rather than bash. This is deliberate: providers differ in what shells they have available, and POSIX sh is the only safe common denominator. An agent that issues bash-specific syntax through the exec method will break when the backend runs on a platform without bash. LocalSandbox sets the precedent that the shell is the minimal portable shell, and remote adapters follow it.
LocalSandbox is not what you use in production. It has no isolation boundary — exec runs commands with the same privileges as the runtime process, and the filesystem is the host filesystem. But isolation is not what LocalSandbox is for. It is the dev loop adapter. You use it locally to run the full agent harness against your own machine's filesystem, with no remote service needed. When you run execute_code in a chat session during development, LocalSandbox is what executes it. The round-trip is nanoseconds rather than milliseconds. You iterate fast.
The reason this works without special casing is that LocalSandbox implements SandboxApi exactly, not approximately. There is no feature flag in the turn loop that says "if we are in dev mode, do this." The turn loop calls env.exec(command) and gets back a ShellResult. Whether env is backed by LocalSandbox or a remote control server is determined at construction time, before the first turn runs. You swap implementations by changing what you pass to the session factory, not by changing the code that calls it.
GatewaySandbox in v1 is instructive precisely because it does not work. Every method in the class throws the same error:
GatewaySandbox is a stub — gateway RPC not implemented yet.
But the stub is doing real work. Each method contains a # TODO comment that specifies exactly what the RPC call would look like: GET <base_url>/fs/read?path=… for read_file, POST <base_url>/exec with command plus cwd/env/timeout_s/cancel for exec. The comment in exec even notes that the cancellation should be forwarded for mid-flight abort and that the response maps into a ShellResult. The stub is not scaffolding waiting to be filled in — it is a design decision committed to source control. The wire protocol is specified here, before any of it is implemented.
This is the correct way to build a two-mode system. You define the interface first. You build one implementation fully — LocalSandbox, the dev loop. You stub the other with the right shape and enough documentation to know exactly what building it requires. You ship with LocalSandbox in development, knowing the production implementation slot is ready. When you add the remote backend later, you implement GatewaySandbox's methods one by one, and the rest of the harness never notices — it was already calling through SandboxApi the whole time.
The GatewaySandboxOptions type is worth noting too. It takes base_url, auth_token, and tenant_id. The constructor stores them. Every method will need them when the RPC calls are implemented. By defining the options at stub time, you confirm that tenant identity and authentication are first-class inputs to the gateway adapter — not ambient globals, not thread-local state, not something inferred from context. The remote adapter is constructed with the identity it operates as.
v1's SandboxApi had nine methods. v2 adds two more: read_file_stream and write_file_stream. The addition signals that the remote execution path has moved from planned to real.
The distinction between the buffered and streaming paths is a memory cliff. read_file_buffer loads the entire file content into a bytes object before returning it. For a 50KB CSV file or a 200KB notebook, that is fine — the buffer lives briefly in process memory and is garbage collected. For a 500MB training dataset or a multi-gigabyte model checkpoint, that is a process kill waiting to happen. You cannot buffer a file that is larger than your available heap.
The streaming methods solve this by moving bytes as an AsyncIterator[bytes] — an async iterator. read_file_stream returns an AsyncIterator[bytes] of the file's raw bytes; a clean end signals a complete transfer, a raised exception signals a failed one. write_file_stream accepts an AsyncIterator[bytes] as source and iterates it to the file, returning once the write completes. The underlying transport — for the control server adapter — mints a single-use transfer token over the control connection, dials the host's data port, and returns the raw socket as an AsyncIterator[bytes]. Bytes move from the in-sandbox streaming helper's stdio directly to the runtime process over TCP, with end-to-end flow control enforced by async iteration. Nothing materializes in process memory.
One difference from write_file: write_file_stream does not auto-create parent directories. The buffered write_file does — it wraps the write attempt with a lazy mkdir -p of the parent on failure. The streaming path has no retry semantics. The comment in the source is direct about why: the streaming path has no retry, so the caller must write to an existing directory. The contract difference reflects the trade-off between convenience and predictability at scale. For small files, convenience wins. For bulk transfers, predictability wins.
The runtime wires the sandbox in two layers, and keeping them separate is the key to session isolation.
The first layer is the SandboxApi backend, resolved per tenant. A sandbox_resolver function takes a tenantId and returns a SandboxApi — a shared instance that may connect lazily to the remote execution environment. All sessions for the same tenant share this backend. There is one connection, one authentication context, one underlying resource.
The second layer is SessionEnv, created per session. create_scoped_session_env(api, perSessionCwd) wraps the shared backend into a cwd-scoped view. The session's working directory is derived from its storage key — a deterministic, collision-resistant path segment computed by hashing the full key with djb2 and appending the hash as a base-36 suffix. A chat session's key, a workflow node's key, and a sub-agent's key all produce distinct directory segments, so concurrent agents are filesystem-isolated without coordination.
sandbox_resolver(tenant_id) → SandboxApi (shared, lazy-connected)
├── create_scoped_session_env(api, '/sessions/abc/') → SessionEnv (session abc)
├── create_scoped_session_env(api, '/sessions/def/') → SessionEnv (session def)
└── create_scoped_session_env(api, '/sessions/ghi/') → SessionEnv (session ghi)
Sessions are isolated by directory, not by connection. The SandboxApi backend is shared across all sessions for a tenant; the SessionEnv is private to each session. This is the right partition. Sharing a backend is safe because sessions only ever access paths inside their own per_session_cwd. Creating a new backend per session would waste connections and complicate lifecycle management.
The create_scoped_session_env wrapper also handles lazy directory creation. It does not call mkdir -p on the session's working directory at construction time. Instead, every file and exec method calls ensure_cwd() before delegating to the base session env. ensure_cwd is memoized: it runs mkdir -p once and stores the promise. If the mkdir fails, the memo clears — a transient filesystem error must not permanently poison the session. A session that never runs code never pays for a sandbox directory. A session that calls execute_code on its first turn pays for exactly one mkdir before the exec runs. The optimization is possible because the backend is shared — if every session had its own remote connection, lazy initialization would require more careful lifecycle management.
The turn loop that drives agent execution calls env.exec(command). It does not call instanceof LocalSandbox or check whether the backend is remote. It does not branch on deployment mode. The SessionEnv interface is what the tools consume (see Chapter 8), and it is what the SessionFactory constructs and hands to the session (see Chapter 4). The backend is an implementation detail of construction, invisible at runtime.
This is dependency injection applied to execution topology. The policy — which backend runs — is determined at startup, when the runtime assembly resolves a sandbox_resolver for the tenant and wraps it in a SessionEnv for the session. The mechanism — the turn loop and the tools — operates against the SessionEnv interface and never observes the backend. Changing the topology is a construction-time swap, not a runtime branch.
The practical consequence is that you can test the full turn loop against LocalSandbox without a remote environment. You can run a staging environment backed by a remote sandbox and the same agent code runs unchanged. You can introduce a new backend — FirecrackerSandbox, ModalSandbox, E2BSandbox — by implementing SandboxApi and registering it in the resolver. The turn loop is unchanged. The tools are unchanged. The isolation model changes; the code does not.
Not all tenants need the same isolation level, and the cost of isolation is not uniform. This is where the SandboxApi seam becomes a deployment primitive.
Consider the threat model. An agent session running inside one tenant's data — Cy analyzing a report for the tenant it is deployed in — has access to that tenant's data by definition. There is no cross-tenant exposure risk. The execution environment can be a container on the same host as the runtime process. Container isolation is sufficient because the threat is not present. The cost is low: a container start, a process spawn, a few milliseconds of overhead.
Now consider a multi-tenant deployment where agents from different tenants may run on the same host. A container escape — a kernel exploit, a misconfigured namespace — could give one tenant's execution environment access to another tenant's data. The threat is real. Container isolation is insufficient. You need a hardware boundary: a hypervisor that gives each tenant's execution environment its own virtualized kernel. A process running inside a Firecracker microVM cannot escape to the host through a container namespace exploit because the VM's kernel is not the host's kernel. The attack surface shrinks to the virtual hardware interface.
Production deployment
Host machine
┌─────────────────────────────────────────────────────────┐
│ │
│ Intra-org (same tenant) Cross-org │
│ ┌─────────────────────┐ ┌──────────────────┐ │
│ │ Container A │ │ Firecracker VM │ │
│ │ ┌───────────────┐ │ │ ┌────────────┐ │ │
│ │ │ runtime proc │ │ │ │ VM kernel │ │ │
│ │ │ LocalSandbox │ │ │ │ sandbox │ │ │
│ │ └───────────────┘ │ │ └────────────┘ │ │
│ └─────────────────────┘ └──────────────────┘ │
│ │
│ SandboxApi seam: swap implementation, runtime unchanged │
└─────────────────────────────────────────────────────────┘
The SandboxApi interface is the seam that makes tiered isolation possible. Swap LocalSandbox for a ControlServerSandboxApi that speaks to a VM agent over a control connection, and the rest of the runtime is unchanged. The sandbox_resolver returns a different implementation for tenants that require stronger isolation. The turn loop never knows. The tools never know. The isolation level is a deployment decision encoded in the resolver, not a code change.
This is the answer to the question every distributed system has to answer: where does the trust boundary live? For agent execution, the answer is SandboxApi. Everything inside SessionEnv trusts the interface. Everything outside — the isolation mechanism, the authentication, the network topology — is behind the implementation. The boundary is thin, explicit, and stable. That combination is what lets the system evolve: new backends, new isolation levels, new deployment modes, none of which require touching the agent, the tools, or the turn loop.
It is also what makes distribution possible. v1 baked Fly topology into the runtime — the sandbox knew it was remote, the turn loop knew it was on Fly, and moving to a different topology required surgery. v2's SandboxApi contract means the runtime already talks to an interface. Running the sandbox across a network boundary is a matter of implementing that interface over a transport. Chapter 12 shows exactly this: a JSON-RPC + control server stack that implements SandboxApi remotely, with the runtime unchanged. The abstraction in this chapter is not preparation for distribution; it is the distribution seam, already in place.
Files read for this chapter:
docs/the-rewrite/chapters/09-the-sandbox-boundary.md— the existing chapter outline, with section stubs and summary descriptions for each section, plus the introduction and conclusion scaffold.apps/runtime/src/sandbox/api.ts— the fullSandboxApiinterface,SessionEnvinterface,createSandboxSessionEnvandcreate_scoped_session_envimplementations, path helpers (makeResolvePath,normalizePath,posixParentDir),writeFileCreatingParentsshared utility, andsandboxDirSegment/per_session_sandbox_cwdfor stable per-session directory derivation.apps/runtime-v1/src/sandbox/gateway.ts— the v1GatewaySandboxstub: a class that implementsSandboxApiwith every method throwingNOT_IMPLEMENTED, with# TODOcomments specifying the RPC routes for each method and the gateway options type (base_url,auth_token,tenant_id).