Plane docs
Guides

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

bashRun a command.
read, write, edit, multi_editFiles.
glob, grepFind files, find text.
process_list, process_logs, process_controlWhat is running, its output, start/stop.
port_exposePublish a port as a preview URL.
publishPut a file into blob storage and hand back a reference.
fetchDownload 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:

fromMeaning
attachNothing was lost, running processes included.
snapshotThe filesystem came back. Processes did not.
freshIt 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 grants

The control plane's session view has the same four buttons, with the state and the snapshot's age.

On this page