Sandboxes
Create, list, rename, stop, start, resize, archive, and delete sandboxes.
A sandbox is one isolated microVM, created from a template. Use sandboxes to run untrusted code, agents, or full dev workloads. Manage them in the dashboard or via the API (reference).
Sandboxes are persistent by default. stop keeps the disk and the spec, and start brings the sandbox back. A sandbox is removed only when you delete it, or automatically for ephemeral sandboxes. See Lifecycle.
Create
import { Platinum } from "@platinum-dev/sdk";
const client = new Platinum({ url: process.env.PT_API_URL!, token: process.env.PT_TOKEN! });
const sbx = await client.sandboxes.create(
{ template: "pt-base", cpu: 2, ram_mb: 1024 },
{ waitForRunning: true },
);import os
from platinum import Platinum
client = Platinum(token=os.environ["PT_TOKEN"], api_url=os.environ["PT_API_URL"])
sbx = client.sandboxes.create(template="pt-base", cpu=2, ram_mb=1024,
wait_for_running=True)pt sandbox create -t pt-base --cpu 2 --ram 1024curl -sS -X POST "$PT_API_URL/v1/sandboxes?wait_for_state=running" \
-H "Authorization: Bearer $PT_TOKEN" -H "Content-Type: application/json" \
-d '{"template":"pt-base","cpu":2,"ram_mb":1024}'Common create fields:
| Field | Default | What it does |
|---|---|---|
template | pt-base | Template to boot from. Or pass image for an inline build. Set one, not both. |
type | persistent | persistent never auto-stops. ephemeral applies the auto-lifecycle defaults. See Retention. |
cpu · ram_mb · disk_gb | template defaults | Resource shape. Template minimums are enforced. |
env | {} | Environment variables set in the sandbox at boot. Anything running in the sandbox can read them — for a credential, use a secret instead. Changeable later with setEnv: new commands see the change at once, the boot environment on the next cold boot. |
expose | — | Ports to publish in the create call. See Networking. |
volumes | — | Persistent volumes to mount, keyed by guest path. See Volumes. |
region | template's, else any | Placement. See Regions. |
pool | — (general fleet) | Host pool to place on, e.g. fast. Without it the sandbox only ever lands on the general fleet; a dedicated pool is reserved for creates that name it, and must be enabled for your organization (403 pool_not_enabled otherwise). Region still applies within the pool. |
pool_fallback | any | With pool: any places on the general fleet when the pool has no room, none fails with the usual 503 no capacity. The sandbox reports where it landed as placedPool (placed_pool on the create response) — pool: "fast" with placedPool: "general" is a spillover. |
runtime | microvm | Execution backend. See Runtimes. |
metadata | {} | Arbitrary JSON (≤ 16 KiB), echoed back on reads. |
backup_interval_min | 60 | Automatic recovery-backup interval. Set 0 for disposable workloads that should not receive periodic backups. This is independent of stop/archive/delete timers. |
The create body also accepts ssh_keys (Terminal) and gpus. Full options, with types and validation rules, are in the reference for your surface:
- TypeScript:
sandboxes.createoptions - Python:
sandboxes.createkwargs - Raw HTTP:
POST /v1/sandboxes
Lifecycle
| State | What it means |
|---|---|
pending | The create request is accepted and the sandbox is queued. The first boot has not started. |
provisioning | The sandbox boots for the first time. It becomes running on success, or failed-start on failure. |
starting | A stopped sandbox comes back up. It becomes running when it is reachable. |
resuming | The sandbox boots from a snapshot after restore, clone, or fork. Snapshots hold disk only, so processes do not carry over. |
unarchiving | An archived sandbox returns from cold storage before it starts. |
restoring | The sandbox cold-boots from a filesystem backup. This also runs automatically after a host is lost. |
running | The sandbox is live. It accepts commands, code execution, and file operations. Snapshots start from this state. |
stopping | The sandbox shuts down and writes its state to disk. It becomes stopped. |
stopped | The sandbox is frozen to disk. The filesystem is preserved, and start brings it back. |
archiving | The stopped sandbox's disk moves to cold storage. It becomes archived. |
archived | The disk is in cold storage and the local disk is freed. start restores the sandbox. |
failed-start | The sandbox did not come up. If it ran before, start retries it. |
deleted | The sandbox and its disk are torn down. This state is final; the record remains for the audit log. |
Only running bills compute. stopped bills local storage. archived bills nothing. See Billing.
Retention
type sets when a sandbox auto-stops, auto-archives, and auto-deletes:
type | idle → stop | stopped → archive | stopped → delete |
|---|---|---|---|
persistent | never | 7 days | never |
ephemeral | 15 min | 7 days | 30 days |
These are defaults. Pass auto_stop_minutes, auto_archive_days, or auto_delete_days at create to override one. 0 disables that timer.
These lifecycle timers are separate from final disposal of an already-deleted sandbox. A user-deleted record is eligible for hard deletion after 30 days. If the platform deleted a sandbox with a completed backup or an archive, that recovery data is retained for 90 days instead. The extension is bounded: after 90 days it is eligible for disposal too. An incomplete backup alone does not qualify, and a customer-configured archive-retention policy expiring does not become a platform-caused deletion because an old error message remains.
Final disposal removes the expired record and schedules deletion of its own
archive, backup, snapshot-manifest/sidecar, and immutable-generation descriptor
objects in their recorded buckets. Object deletion is asynchronous; a
purged-row count is not proof that storage cleanup has finished. Identical
keys in different buckets identify different objects.
Shared content-addressed chunks are not deleted by this
step, so disposing of one sandbox's manifests must not break another sandbox's
snapshot. Hard deletion records the owning organization, archive/backup keys and
buckets, snapshot bucket, and manifest/sidecar/descriptor keys in the internal
deletion ledger. Historical bucket values that were not recorded remain unknown, not
inferred from current storage configuration. Surviving children, including
younger deleted children, lose only their parent_id link when the parent is
purged; their data and retention windows are unchanged.
Auto-resume
A request to one of a stopped or archived sandbox's preview URLs starts it, then forwards the request. Only a request that passes the token check can start it.
Turn this off when your own system decides when the sandbox runs. With auto_resume: false, only start or resume brings the sandbox back. A request to a stopped sandbox gets a 404 with the reason auto-resume-disabled.
const sbx = await client.sandboxes.create({ template: "pt-base", auto_resume: false });
await sbx.setAutoResume(true); // turn it back on
await client.sandboxes.update(sbx.id, { auto_resume: false }); // or by idPATCH /v1/sandboxes/:id with {"auto_resume": false} changes an existing sandbox. The setting does not affect a running sandbox.
List
const { rows } = await client.sandboxes.list({ state: "running", limit: 100 });
for await (const s of client.sandboxes.iterate({ state: "running" })) { /* auto-paginate */ }rows = client.sandboxes.list(state="running", limit=100)["rows"]
for s in client.sandboxes.iter(state="running"):
...pt sandbox list --state running --limit 100curl "$PT_API_URL/v1/sandboxes?state=running&limit=100" -H "Authorization: Bearer $PT_TOKEN"Query params: state, limit (default 50, max 200), offset, and paginated=true. Results are org-scoped.
List and child-list responses never contain stored environment values.
Inventory is ordered by creation time descending, then unique ID descending.
List and child-list createdAt values are UTC ISO 8601 strings retaining the
database's six fractional-second digits. Regional merging uses that same native
precision, so distinct creation times within one millisecond do not become false
ties that duplicate or omit rows across offset pages.
For paginated=true, each region reads its local rows and total from one
database snapshot. With fixed matching membership, state or metadata changes do
not change the creation order. Separate pages or regions do not share a snapshot:
concurrent insertion, deletion, or changes to fields used by a filter can still
move items between successive offset pages.
Parent and child sandboxes
A sandbox can be the child of another sandbox. Set parent_id at create time. A sandbox without a parent_id is a top-level sandbox.
Use this to group related sandboxes. An agent working inside a sandbox can create child sandboxes and manage only those.
const child = await client.sandboxes.create({ template: "pt-base", parent_id: parent.id });
const kids = await client.sandboxes.children(parent.id);child = client.sandboxes.create(template="pt-base", parent_id=parent["id"])
kids = client.sandboxes.children(parent["id"])pt sandbox create -t pt-base --parent sbx_01K…
pt sandbox children sbx_01K…curl -X POST "$PT_API_URL/v1/sandboxes" -H "Authorization: Bearer $PT_TOKEN" \
-H 'Content-Type: application/json' -d '{"template":"pt-base","parent_id":"sbx_01K…"}'
curl "$PT_API_URL/v1/sandboxes/$SBX/children" -H "Authorization: Bearer $PT_TOKEN"The parent must be running and in the same organization. A missing parent returns 404.
parent_id is set once at create. You cannot move a sandbox to a different parent.
List sandboxes by position in the tree with parent_id:
const children = await client.sandboxes.list({ parent_id: parent.id });
const roots = await client.sandboxes.list({ rootsOnly: true });children = client.sandboxes.list(parent_id=parent["id"])["rows"]
roots = client.sandboxes.list(roots_only=True)["rows"]pt sandbox list --parent sbx_01K…
pt sandbox list --rootscurl "$PT_API_URL/v1/sandboxes?parent_id=$SBX" -H "Authorization: Bearer $PT_TOKEN"
curl "$PT_API_URL/v1/sandboxes?parent_id=none" -H "Authorization: Bearer $PT_TOKEN"The default list is unchanged. Without parent_id you get every sandbox in the organization.
Warning: delete children before the parent. Deleting a parent that still has live children returns 409. The response names the blocking children.
Deleting a sandbox never deletes its children.
Get
const s = await client.sandboxes.get("sbx_01K…");
const sbx = await client.sandboxes.connect("sbx_01K…");s = client.sandboxes.get("sbx_01K…")
sbx = client.sandboxes.connect("sbx_01K…")pt sandbox get sbx_01K…curl "$PT_API_URL/v1/sandboxes/$SBX" -H "Authorization: Bearer $PT_TOKEN"get returns the record; connect returns a handle for further calls. Live metrics and billed usage are separate endpoints: GET /v1/sandboxes/:id/metrics and GET /v1/sandboxes/:id/usage.
Rename
Pass null (Python: None) to clear the name.
await sbx.rename("ci-runner-2");sbx.rename("ci-runner-2")pt sandbox rename $SBX ci-runner-2
# clear the name:
pt sandbox rename $SBX --clearcurl -X PATCH "$PT_API_URL/v1/sandboxes/$SBX" -H "Authorization: Bearer $PT_TOKEN" \
-H "Content-Type: application/json" -d '{"name":"ci-runner-2"}'Update environment variables
Merge variables into a sandbox after it was created. A null value removes that
key; keys you do not mention are left untouched.
Concurrent patches merge against the latest committed environment without losing
successful disjoint additions or removals. The complete merged result is checked
under the same row lock: individually valid patches can return 413 env_too_large
when their combined values would exceed 256 KiB. A rejected patch changes nothing.
Request-body upload does not hold that lock; deletion during upload returns 404.
Each KEY=VALUE string including its terminating NUL must also fit Linux's
128 KiB per-string limit. Create and patch return 400 env_var_too_large for an
oversized string even when the complete environment is below the total limit.
New commands see the change at once, removals included. Every exec,
processes start and run-code after the call runs with the new environment.
Two things keep the old one until the sandbox's next cold boot: processes that
are already running, and anything started from the guest's boot environment
(/etc/pt-env), such as services started at boot and terminal shells. The
response says which applies: applies is next_exec for a running sandbox and
next_start for a stopped one.
Use this to rotate a credential the sandbox holds. Before it existed, env was
fixed at create for the sandbox's whole life: every cold boot re-seeded
/etc/pt-env from the create-time values, so a rotated token written into a
running guest was silently replaced by the original on the next boot, and the
sandbox could never authenticate again.
Values are never echoed back — the response carries key names only.
await sbx.setEnv({ API_TOKEN: "new-value", OLD_UNUSED: null });
(await sbx.exec(["printenv", "API_TOKEN"])).stdout; // new-value; OLD_UNUSED is unsetcurl -X PATCH "$PT_API_URL/v1/sandboxes/$SBX/env" -H "Authorization: Bearer $PT_TOKEN" \
-H "Content-Type: application/json" -d '{"env":{"API_TOKEN":"new-value","OLD_UNUSED":null}}'For a value the sandbox should never be able to read, use a
secret instead — the same guidance as env at create.
Stop and start
stop saves memory where it can, so start resumes with processes intact. start also unarchives an archived sandbox first.
await sbx.stop({ waitForStopped: true });
await sbx.start({ waitForRunning: true });sbx.stop(wait_for_stopped=True)
sbx.start(wait_for_running=True)pt sandbox stop sbx_01K…
pt sandbox start sbx_01K…curl -X POST "$PT_API_URL/v1/sandboxes/$SBX/stop?wait_for_state=stopped" -H "Authorization: Bearer $PT_TOKEN"
curl -X POST "$PT_API_URL/v1/sandboxes/$SBX/start?wait_for_state=running" -H "Authorization: Bearer $PT_TOKEN"POST /start and POST /resume are idempotent. Call them again whenever you want the sandbox running:
| Sandbox state | Answer |
|---|---|
running | 200 {id, state: "running"} |
starting, unarchiving, resuming | 202 {id, state} joins the start in flight. wait_for_state and wait_timeout_ms apply. The status is 200 when the wait target is reached. |
stopping (/start waits up to 45 s for it to settle) | then as stopped |
archiving, migrating, provisioning | 409 with code: "in_progress" and state. Retry when it settles. |
failed-start that never ran, lost | 409 with code: "sandbox_not_startable" and state |
A start that must copy the snapshot to another host can take longer than one request. It answers 202 with code: "relocating" and finishes in the background. Poll state or call /start again: the call joins the copy in flight.
A wait that runs out before the target state is never a 200. /start or /resume with ?wait_for_state=running, and /stop or /pause with ?wait_for_state=stopped, answer 202 with wait_timed_out: true, Retry-After: 5 and the state the sandbox is still in (starting, stopping). Call again, or poll state. A call without wait_for_state answers as before.
When a committed readiness notification arrives during a state read, the wait rechecks the state before sleeping again, even when notifications share a wall-clock tick. Rechecks keep the original deadline; backstop polling still covers other writers and lost notifications.
Starting a stopped sandbox while its host is busy. A /start of a box that already lives on a host re-takes the RAM its /stop released, so only the host's allocation limits can refuse it outright. With placement.start_admission_queue on, a start that arrives while the host is busy (many starts in flight, or its measured CPU over overcommit.host_cpu_refuse_pct during a resume storm) waits for one of the host's start slots instead of being refused at once:
placement.start_admission_slots(default 8) concurrent starts per host, orplacement.start_admission_slots_saturated(default 2) while its CPU is over the line, so a saturated host keeps starting boxes;- slots go to the org with the fewest starts in flight on that host first, then in arrival order;
- waiting requests do not consume active-start slots; the load view is refreshed when the waiter list changes;
- the wait is bounded by
placement.start_admission_wait_ms(default 30 s, or the call'swait_timeout_msif shorter) and the queue byplacement.start_admission_max_queue(default 64). The same request does not queue on its source host a second time when the relocation anti-flap guard keeps it there. Queue budgets and cached load ages use a monotonic clock: wall-clock corrections neither prolong a wait nor expire it early.
A start the queue cannot admit is relocated through its S3 stop-snapshot when that is safe (placement.start_relocate_on_capacity). Otherwise it answers 503 with code: "capacity", Retry-After: 5, reason: "start_queue_timeout" or "start_queue_full", and queued_ms. The box stays stopped, so call again after Retry-After. Creates are not queued.
Create follows the same rule. POST /v1/sandboxes with ?wait_for_state=running or "wait": true whose wait runs out while the sandbox is still provisioning answers 202 with wait_timed_out: true and Retry-After: 5, not 201. The body names the sandbox, so poll it by id and do not create it again. A create that did not ask to wait answers 201 as before. The E2B-compatible create, whose Sandbox.create() promises a ready sandbox, answers a retryable 503 sandbox_not_ready in that case and deletes the sandbox it made.
What a start restored
A start that reaches running says what it brought back:
| Field | Value | Meaning |
|---|---|---|
restore | "memory" | The stop snapshot was resumed: memory, processes and the clock are as they were at the stop. |
"disk_only" | The sandbox cold-booted its kept disk. Files are intact. Every process, and anything held only in memory, from before the stop is gone. This happens when the stop could not save memory (for example a guest that stopped responding mid-stop), or when the memory image could not be used on this host. | |
"running" | It was already running; nothing was restored and nothing lost. | |
ram_restored | true / false | false exactly when restore is "disk_only". |
The fields are absent when the sandbox is not running yet (a 202), and on hosts whose agent does not report them yet. The Daytona start and E2B connect/resume answers carry the same information as restore and ramRestored. The latest value is also kept on the sandbox as metadata.last_start. A disk_only start is recorded as a start_ram_not_restored host event.
curl -sS -X POST "$PT_API_URL/v1/sandboxes/$SBX/start?wait_for_state=running" -H "Authorization: Bearer $PT_TOKEN"
# {"id":"sbx_01K…","state":"running","host_id":"host_…","restore":"disk_only","ram_restored":false}
# ram_restored=false: restart whatever the sandbox should be running before using itResize
disk_gb can only grow.
- A
runningsandbox can growdisk_gbin place, without a restart: the guest sees the bigger disk within a second. - A
runningsandbox can changecpuandram_mbin place when its host supports live resize: processes and memory keep running. Changedisk_gbin a separate request. - A
stoppedsandbox resizes by cold-booting: the disk survives, in-memory state does not.
Live CPU and memory resize
A sandbox can be resized live only within the headroom it booted with. The answer says what the sandbox has afterwards, with applies_at: "now":
curl -sS -X POST "$PT_API_URL/v1/sandboxes/$SBX/resize" -H "Authorization: Bearer $PT_TOKEN" \
-H "Content-Type: application/json" -d '{"cpu":4,"ram_mb":8192}'
# {"id":"sbx_01K…","state":"running","cpu":4,"ram_mb":8192,"disk_gb":20,"applies_at":"now",
# "requested":{"cpu":4,"ram_mb":8192},"partial":false,
# "live":{"max_cpu":16,"max_ram_mb":16384,"guest_cpus_online":4,"guest_mem_total_mb":7962}}- New vCPUs are online inside the guest when the call returns, and
nproccounts them. - Added memory is online when the call returns.
MemTotalgrows by the added amount, less the kernel's own bookkeeping for it. - A shrink removes vCPUs and then memory. Memory can go back down to the size the sandbox booted with, and only as far as the guest can free it. When it frees less, the answer has
"partial": trueandram_mbis what the sandbox still has. You are billed for what the sandbox has, not for what you asked for. - You are billed at the old size up to the resize and at the new size from then on.
cpuandram_mbstay within your plan's per-sandbox limits and your organization's pool, as for any resize.
When a sandbox cannot be resized live, /resize answers 409 sandbox_not_resizable with the reason and what to do instead:
{"code":"sandbox_not_resizable","error":"this running sandbox cannot change cpu or ram_mb live (cpu 32 is above this boot's maximum of 16); stop it, then resize",
"live_resize":{"supported":false,"reason":"cpu 32 is above this boot's maximum of 16"},
"hint":{"action":"stop_then_resize","applies_at":"restart","steps":["POST /v1/sandboxes/sbx_01K…/stop","POST /v1/sandboxes/sbx_01K…/resize"]}}This happens when the sandbox's host does not support live resize yet, when the sandbox booted before it did, when the size is beyond what the sandbox booted with room for, or when its image has an in-VM agent older than live resize. A cold-booted resize gives the sandbox new room to grow live. For a sandbox whose root disk is a volume, applies_at is next_start: the stopped resize only records the size, and the next start boots at it.
A 202 with applies_at: "pending" means the host has not finished yet. Read the sandbox for its size.
A running sandbox whose own disk passes 90% used can also grow by itself, by half and at least 10 GB, when the platform enables it (limits.disk_autogrow_max_gb). It is billed at the new size. A guest that cannot grow its filesystem online keeps its size, and a live resize of it answers 409 disk_grow_unsupported. A sandbox started before 2026-10-08 can have a root filesystem journal too small for an online grow; stopping it and resizing it cold-boots it with one that can, and it grows live from then on.
await sbx.resize({ cpu: 4, ram_mb: 8192, disk_gb: 8 });sbx.resize(cpu=4, ram_mb=8192, disk_gb=8)pt sandbox resize sbx_01K… --cpu 4 --ram 8192 --disk 8curl -X POST "$PT_API_URL/v1/sandboxes/$SBX/resize" -H "Authorization: Bearer $PT_TOKEN" \
-H "Content-Type: application/json" -d '{"cpu":4,"ram_mb":8192,"disk_gb":8}'Archive
Archive a stopped sandbox only. Archiving moves the disk to cold storage and frees the local disk. An archived sandbox bills nothing. start restores it; there is no separate unarchive endpoint.
await sbx.archive();sbx.archive()pt sandbox archive $SBXcurl -X POST "$PT_API_URL/v1/sandboxes/$SBX/archive" -H "Authorization: Bearer $PT_TOKEN"Delete
Delete removes the sandbox and its disk. The call returns immediately and tears down in the background.
await sbx.delete();sbx.delete()pt sandbox rm sbx_01K…curl -X DELETE "$PT_API_URL/v1/sandboxes/$SBX" -H "Authorization: Bearer $PT_TOKEN"Snapshot and fork
A running sandbox also supports snapshot, restore, clone, fork, and off-host backup. See Snapshots.