Sandboxes
A machine with a shell and a filesystem, and the thirteen tools on it.
An agent version may declare a sandbox: a machine of its own, with a workspace and
a shell.
export const plane = definePlane({
agent: {
slug: "repo-bot",
model: { provider: "gateway", model: "anthropic/claude-sonnet-4.6" },
system: "You work in the checked-out repository at /workspace.",
sandbox: {
provider: "modal",
image: {
base: "ghcr.io/acme/agent-base:1.4",
steps: [
"RUN apt-get update && apt-get install -y git",
"RUN git clone --depth 1 https://github.com/acme/handbook /workspace"
]
},
resources: { cpu: 2, memoryMb: 4096 },
network: { egress: { mode: "allow", allow: ["github.com"] } },
lifecycle: { idleAfterMs: 15 * 60 * 1000, onIdle: "snapshot", keepSnapshots: 2 }
}
}
})The machine dials out to Plane; there is no inbound path to it and none is
required. provider is modal (hosted), docker (local development and CI, never
production — a container shares the host kernel) or local (a process, a development
affordance and not isolation).
The thirteen tools
bash | Run a command. |
read, write, edit, multi_edit | Files. |
glob, grep | Find files, find text. |
process_list, process_logs, process_control | What is running, its output, start/stop. |
port_expose | Publish a port as a preview URL. |
publish | Put a file into blob storage and hand back a reference. |
fetch | Download a URL, or a blob reference, into the workspace. |
The catalogue is static — Plane presents all thirteen without starting a machine — which is what makes just-in-time provisioning possible: the sandbox is created on the first sandbox tool call and released when the session goes quiet.
Narrowing what may be called
sandbox: {
// …
tools: { preset: "read-only", deny: ["fetch"] }
}preset and allow are allow-lists and intersect; deny is applied last. Every
field narrows and none widens, which is what lets a sub-agent roster entry compose its
own narrowing over the agent's and be provably unable to reach a tool the parent
could not:
subagents: [{
kind: "derived", name: "review", extends: "parent",
description: "Reads the diff and comments. Changes nothing.",
overrides: { sandbox: { tools: { preset: "read-only" } } }
}]read-only is read, glob, grep, process_list, process_logs, fetch.
bash is never in it — a shell can write, kill and deploy whatever the command
says. fetch is, deliberately: it writes a file, and a review lens that cannot pull a
reference into the workspace is not much of a lens.
A denied tool is not declared to the model at all, and invoking one anyway is refused as a permission — never "unknown tool", because there is no spelling that would work.
One machine per session tree
A sandbox belongs to a session tree, keyed on the root session. A sub-agent child
attaches to the machine its root already has — same workspace, same processes — and
its sandbox.attached event records sharedWith.
Two dials: sandbox.scope: "session" puts a session on its own machine, and
sandbox.serializeTools runs the tree's sandbox tool calls through one semaphore.
Parallel children genuinely share a filesystem, and that is worth thinking about
before you fan out four writers.
Lifecycle
lifecycle: {
idleAfterMs: 900_000,
onIdle: "snapshot", // or "release"
snapshotEveryMs: 0, // off
keepSnapshots: 1,
maxSnapshotAgeMs: 86_400_000
}An idle tree's machine is checkpointed rather than thrown away wherever the provider can do it, and the tree's next tool call brings the workspace back. A policy that asks to snapshot on a provider that cannot is downgraded honestly rather than silently doing nothing — a workspace the customer believes is coming back is worse than one they know is gone.
sandbox.resumed says which of three things happened:
from | Meaning |
|---|---|
attach | Nothing was lost, running processes included. |
snapshot | The filesystem came back. Processes did not. |
fresh | It did not, and detail says why. |
The sandbox id never changes across a resume: it is also the channel id and a preview's audience.
The image
The image is part of the version. Changing a build step appends an agent version
exactly as changing the prompt does, so sync stays idempotent and a step you did not
edit does not rebuild.
Building it is not. A bake takes minutes, so sync returns as soon as the version
is written and Plane builds behind it. Until then, sessions.create throws
AgentNotReady carrying baking or failed.
let { status } = await client.agents.imageStatus({ id: agentId }) // a read; never builds
while (status === "baking") { await sleep(5_000); ({ status } = await client.agents.imageStatus({ id: agentId })) }Only four instructions are executable, one per line: RUN, COPY, ENV, WORKDIR.
Anything else — an ENTRYPOINT that could take the tools offline, a FROM that could
swap the base out — is refused at bake time, terminally.
agents.rebake({ id }) rebuilds the latest version's image without appending a
version. That is for when the image's content moves while its spec does not — the
clone above bakes whatever main was that morning. Run it on a schedule, the way you
would a deploy, not on every request.
Operating one
await client.sandboxes.list({ sessionId })
await client.sandboxes.get({ id })
await client.sandboxes.snapshot({ id })
await client.sandboxes.resume({ id })
await client.sandboxes.release({ id }) // also revokes its preview grantsThe control plane's session view has the same four buttons, with the state and the snapshot's age.