PLATINUM DOCS

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 1024
curl -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:

The create body also accepts ssh_keys (Terminal) and gpus. Full options, with types and validation rules, are in the reference for your surface:

Lifecycle

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:

typeidle → stopstopped → archivestopped → delete
persistentnever7 daysnever
ephemeral15 min7 days30 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 id

PATCH /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 100
curl "$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 --roots
curl "$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 --clear
curl -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 unset
curl -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 stateAnswer
running200 {id, state: "running"}
starting, unarchiving, resuming202 {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, provisioning409 with code: "in_progress" and state. Retry when it settles.
failed-start that never ran, lost409 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, or placement.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's wait_timeout_ms if shorter) and the queue by placement.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:

FieldValueMeaning
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_restoredtrue / falsefalse 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 it

Resize

disk_gb can only grow.

  • A running sandbox can grow disk_gb in place, without a restart: the guest sees the bigger disk within a second.
  • A running sandbox can change cpu and ram_mb in place when its host supports live resize: processes and memory keep running. Change disk_gb in a separate request.
  • A stopped sandbox 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 nproc counts them.
  • Added memory is online when the call returns. MemTotal grows 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": true and ram_mb is 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.
  • cpu and ram_mb stay 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 8
curl -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 $SBX
curl -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.