PLATINUM DOCS

Volumes

Persistent disks that outlive sandboxes. Create a volume once, attach it, and keep the data.

A volume is persistent storage that outlives a sandbox. Platinum has two explicit products with intentionally different guarantees.

Volumes are feature-gated per org. Manage them in the dashboard or through the API.

Choose a volume type

TypeBackingBest forConstraints
Local Volumehost-local sparse ext4 over virtio-blkdatabases and latency-sensitive single-sandbox statehost-pinned; one writable sandbox at a time; host loss loses the volume
Shared Volumededicated S3-compatible object namespace via host-side FUSE + virtio-fsfiles, datasets, model weights, source, caches, and collaborative agentsportable and multi-attach, but not block storage or full POSIX/transactional semantics; never use for databases, SQLite, or lockfiles

Constraints

  • Local Volumes attach to one sandbox at a time and only on their pinned host.
  • Shared Volumes can attach read-only to multiple sandboxes and hosts. Gated mounted writes allow at most one pending or active rw attachment per volume, which may coexist with ro readers. Every attachment has its own mount path and optional confined subpath.
  • The S3 mount has no real symlinks, hard links, device nodes, advisory locks, or transactional rename. Workloads such as npm install that rely on symlinks are unsupported on Shared Volumes.
  • Sandbox snapshots/backups retain attachment metadata, not volume contents. Use explicit volume snapshots for volume data.

Historical prototype baseline

Measured on 2026-08-24 in one 1-vCPU KVM sandbox mounting Local and a now-disabled Shared RW prototype simultaneously, with Shared backed by Scaleway Object Storage in fr-par. Each cell is 30 guest-side samples in milliseconds. These are design evidence only, not supported-current behavior or durable-S3 commit latency, and are not projections for other hosts or regions.

OperationLocal p50 / p95 / p99Shared p50 / p95 / p99
Sequential write, 4 MiB0.920 / 1.008 / 2.2512.746 / 2.985 / 3.134
Sequential read, 4 MiB0.718 / 0.748 / 0.7502.959 / 3.426 / 3.457
Random read/write, 128 × 4 KiB0.638 / 0.705 / 0.7265.841 / 10.390 / 11.139
20 create/stat/rename/delete cycles0.815 / 0.843 / 0.86522.600 / 39.637 / 40.822

Create a volume

FieldTypeDefaultNotes
namestringRequired, unique case-insensitively per organization. Rename does not move data.
typelocal | sharedlocalChoose behavior above. Shared is gated per organization.
size_gbint 1–5001Local allocation size. Shared direct-file and mounted mutations are admitted against it.
mount_pathstring/mnt/dataLocal Volume default mount point. Shared paths belong to attachments.
sandbox_idstringLocal only: place the volume on that sandbox's host and auto-attach if it runs.

Local creation is synchronous and returns when the sparse ext4 disk is ready. Shared creation records the isolated S3 namespace immediately; objects are created only when files are written.

A new volume returns in state available. Pass a sandbox_id for a running sandbox and it returns attached. Omit sandbox_id and the API picks the host.

import { Platinum } from "@platinum-dev/sdk";
const client = new Platinum({ url: process.env.PT_API_URL, token: process.env.PT_TOKEN });

const vol = await client.volumes.create({ name: "project-db", type: "local", size_gb: 10, sandbox_id: "sbx_01K…" });
from platinum import Platinum
client = Platinum(token="pt_live_…", api_url="https://api.platinum.dev")

vol = client.volumes.create("project-db", type="local", size_gb=10, sandbox_id="sbx_01K…")
pt volume create --name project-data --type shared --size 10
curl -sS -X POST "$PT_API_URL/v1/volumes" \
  -H "Authorization: Bearer $PT_TOKEN" \
  -d '{"name":"project-data","type":"shared","size_gb":10}'

Attach at sandbox create

Use volumes for explicit Local or Shared attachments. One sandbox can mount several mixed volume types at distinct paths. Each descriptor carries its own mount path, Shared subpath, and access mode. volume_ids remains only as the backward-compatible Local Volume shorthand; a Shared id in volume_ids is rejected instead of being silently treated as a block disk.

const local = await client.volumes.create({ name: "database", type: "local", size_gb: 10 });
const shared = await client.volumes.create({ name: "project-data", type: "shared", size_gb: 20 });
const sbx = await client.sandboxes.create(
  {
    template: "pt-base",
    volumes: [
      { volume_id: local.id, mount_path: "/mnt/database", mode: "rw" },
      { volume_id: shared.id, mount_path: "/mnt/project", subpath: "users/alice", mode: "ro" },
    ],
  },
  { waitForRunning: true },
);

Explicit volume snapshots

Volume snapshots are separate from sandbox snapshots. A Shared snapshot first rejects active rw attachments, blocks direct mutations, copies data/ into an S3 generation, then writes its manifest last. Only ready manifests are restorable. This is consistent only because writers are quiesced; it is not an atomic snapshot of concurrent writes.

const snapshot = await client.volumes.snapshots.create("vol_…");
await client.volumes.snapshots.restore("vol_…", snapshot.id);

Shared restore is quiesced but S3 has no multi-object atomic rename: an error is returned if the copy fails, never a fake success. Each generation manifest records every object, size, and ETag; restore verifies the complete generation before replacing live data. Per-attempt fencing stops a stale control-plane worker from publishing or clearing a newer operation. On buckets without Object Lock this is application-enforced immutability, not provider WORM storage.

Shared snapshots are capped at 10,000 objects and a 16 MiB manifest. The API returns shared_snapshot_too_large before copying any object when a source exceeds the object cap, and refuses an oversized manifest before restore. Split larger datasets across volumes; the service never reports a partial generation as ready.

Local snapshots require the volume to be detached. The host proves no process holds the stable backing inode, runs read-only e2fsck, captures an atomic reflink beneath volume-snapshots/<volume-id>/<snapshot-id>/, and verifies the captured ext4 image before marking it ready. Restore uses the same offline checks and atomically replaces the stable backing path. A host filesystem without reflink support returns an explicit failure.

Local Volume limits

A Local Volume is a sparse ext4 file on one host's data disk, so its declared size is a claim on physical capacity every sandbox on that host shares. Limits are tier-configurable and enforced at admission.

Limitfreet1t2 / prot3t4enterprise
volumes01251020
aggregate GiB05201002501000
max GiB per volume05102550100
snapshots per volume001235
mounted per sandbox012468
creates / minute · hour01 · 31 · 51 · 101 · 102 · 20
snapshots / 10 min · day001 · 21 · 51 · 52 · 10

Zero disables an operation.

It never means unlimited. An organization on free cannot create a Local Volume at all.

Local Volumes are opt-in per organization: flags.volumes_enabled alone grants nothing. Creation and new attachments additionally require flags.local_volumes_enabled and an explicit limits.org_overrides[<orgId>].localVolumes = true. Revoking that grant stops new creates and attachments only — listing, detaching, downloading, snapshot restore and deletion stay available so no data is stranded.

Admin configuration cannot exceed the hard ceilings: 100 volumes, 1024 GiB aggregate, 500 GiB per volume, 20 snapshots per volume, 16 mounted per sandbox, 10 creates/minute, 100 creates/hour, 10 snapshots/10 minutes, 100 snapshots/day.

Lowering a limit below current usage blocks new allocation and deletes nothing.

Host capacity

Sparse allocation means a volume admitted today can fill a disk next week, so admission reserves the declared size rather than the written size:

projectedFree = observedFree - declaredButUnwritten - requested
required      = max(local_volume_host_min_free_gib, total × local_volume_host_min_free_pct / 100)
admitted      ⟺ projectedFree ≥ required

observedFree comes from the host agent's heartbeat, not from what the scheduler believes it handed out. Defaults are 50 GiB and 15%; the stricter of the two wins. A host whose disk telemetry is missing or older than 120 seconds is refused, never estimated. Shared Volumes are not counted here — their bytes live in the object store and their per-attachment cache is separately capped.

Capacity is released when the host confirms the backing file is gone, not when a delete is requested.

Errors

local_volume_creation_disabled · local_volume_max_size · local_volume_max_count · local_volume_max_total · local_volume_create_rate_minute · local_volume_create_rate_hour · local_volume_snapshot_count · local_volume_snapshot_rate_ten_minutes · local_volume_snapshot_rate_day · local_volume_host_capacity · local_volume_mounted_limit

Returned as 403 (not granted), 409 (count or mount ceiling), 413 (size or capacity) or 429 (rate). None of them expose host paths or fleet capacity.

Shared Volume file API and quota boundary

Use the direct file API while a Shared Volume is unmounted or mounted read-only. Uploads stream through the control plane and require Content-Length; the API enforces the configured size against direct writes and reports usage_bytes/object_count after mutations. The control plane's request-body ceiling is 128 MiB by default (PT_MAX_REQUEST_BODY_BYTES), so larger direct uploads are explicitly unsupported by the configured rollout limits. Direct mutations are serialized per volume so concurrent uploads cannot each reserve the same remaining capacity; callers receive 409 and retry when another mutation is active.

Mounted read/write is a default-off gated rollout. Admission requires the global Admin switch, an explicit organization/tier grant, and a fresh host advertising the exact write-gateway v1 fingerprint. Even if a configured tier contains a higher number, the hard protocol ceiling is one pending or active writer per volume. This is not multi-writer support.

For an admitted writer, bytes flow from virtiofs through rclone to a root-only loopback gateway and bounded durable spool, then directly to the configured Shared Volume S3 bucket. Provider credentials never enter the sandbox or RW rclone process, and file bytes never transit the control plane. The control plane atomically reserves growth and object count before the provider accepts bytes, then commits the actual logical delta from provider evidence. Reads do not contact quota accounting; a control-plane outage therefore leaves reads available while new writes fail closed.

The v1 filesystem contract is sequential create/truncate/write/fsync/close, copy/rename, and delete. It is not a general POSIX filesystem: random or append writes, mmap durability, hard links, locks, transactional rename, databases, SQLite, and package-manager trees that require symlinks remain unsupported. Quota or emergency-lock rejection surfaces as filesystem EIO at the write or fsync durability boundary, and rejected bytes are never published to readers.

The host caps new Shared attachment processes from physical memory (one slot per 16 GiB, clamped to 1-32, with at most 2 GiB of bounded hardware-accounting slack) in addition to the 10 GiB hard cache image and 20 GiB host free-space floor. Existing retained-cache attachments keep their reservation across restart even when a host is over the current cap; new attachments fail explicitly until old slots drain.

Recursive directory deletion first enumerates the complete subtree without mutating it, then deletes with bounded concurrency. It is capped at 10,000 objects per request; a larger subtree returns shared_directory_delete_too_large before deleting anything. Delete smaller subtrees in bounded calls, or delete the whole volume through its separate asynchronous bounded reaper; a read-only mount cannot perform cleanup.

// Byte-like bodies infer the length. A ReadableStream needs the known byte count
// so the API can reserve quota before it starts reading the stream.
await client.volumes.files.upload("vol_…", "datasets/train.jsonl", stream, {
  contentLength: datasetBytes,
  contentType: "application/jsonl",
});
const page = await client.volumes.files.list("vol_…", { path: "datasets" });
# Bytes infer their length. Iterators and async iterators stay streaming when
# given the known length; the SDK never buffers them to discover it.
client.volumes.upload("vol_…", "datasets/train.jsonl", chunks(),
                      content_length=dataset_bytes,
                      content_type="application/jsonl")
# The CLI sends the local file as a stream; it does not read it all into memory.
pt volume files upload vol_… ./train.jsonl --path datasets/train.jsonl
pt volume files download vol_… datasets/train.jsonl ./train-copy.jsonl

Presigned downloads are supported. Presigned direct uploads remain disabled: they would bypass snapshot barriers and quota admission. Direct API uploads above the control-plane request limit are unsupported; use an explicitly admitted v1 mounted writer for sequential filesystem workloads, or split the dataset.

Attach to a running sandbox

Local attach requires a running sandbox on the volume's pinned host. Shared read-only attach may target any eligible host and may mount the same volume into multiple sandboxes. Shared rw attach additionally requires every rollout gate and the exact fresh write-gateway v1 capability; otherwise it fails closed. The request returns 200 when a supported attach completed promptly or 202 while the durable attachment command continues.

Shared detach is fail-closed. It first performs a normal guest unmount. An open file, working directory, or bind mount can reject detach. The attachment and host backend remain live. Close the handles and retry. The host persists the guest kernel mount identity before unmount. It waits for descriptors with that identity, including renamed or unlinked files. Shared attach and detach are not automatically replayed after a definitive host error. The caller controls the retry.

Attachments survive stop and start. The volume comes back mounted. When you delete the sandbox, the volume returns to available with data intact. An initial create that never boots releases its admitted leases; a failed restart remains retryable and retains its Local Volume lease while Shared attachments are rearmed for replay.

await client.volumes.attach("vol_…", "sbx_01K…", {
  mountPath: "/mnt/project",
  subpath: "users/alice",
  mode: "ro",
});
client.volumes.attach("vol_…", "sbx_01K…", mount_path="/mnt/project",
                      subpath="users/alice", mode="ro")
pt volume attach vol_… --sandbox sbx_01K… --mount-path /mnt/cache
curl -sS -X POST "$PT_API_URL/v1/volumes/vol_…/attach" \
  -H "Authorization: Bearer $PT_TOKEN" \
  -d '{"sandbox_id": "sbx_01K…", "mount_path": "/mnt/cache"}'

Move data between sandboxes

Detach the volume, then attach it to another sandbox on the same host. Detach flushes all writes to disk. The next attach sees exactly what the last writer left.

List and get

Pass includeDeleted to include deleted volume records in the list.

const vols = await client.volumes.list();
const one  = await client.volumes.get("vol_…");
const all  = await client.volumes.list({ includeDeleted: true });
vols = client.volumes.list()
one  = client.volumes.get("vol_…")
all_ = client.volumes.list(include_deleted=True)
pt volume list
pt volume get vol_…
# routes are listed in /docs/api — or use the SDK or CLI

Detach

await client.volumes.detach("vol_…");
client.volumes.detach("vol_…")
pt volume detach vol_…
curl -sS -X POST "$PT_API_URL/v1/volumes/vol_…/detach" \
  -H "Authorization: Bearer $PT_TOKEN" -d '{}'

Delete

Delete removes the data for good. The record stays for include_deleted listings, but the data is not recoverable. Delete fails while the volume is attached — detach first. Local snapshots must be deleted explicitly before deleting their Local Volume. Shared Volume deletion tombstones first, then the bounded reaper removes both live data and snapshot generations; snapshot generations are never part of sandbox backups.

await client.volumes.delete("vol_…");
client.volumes.delete("vol_…")
pt volume rm vol_…
# route is listed in /docs/api — or use the SDK or CLI