PLATINUM DOCS

Volumes

Persistent, versioned disks you mount into sandboxes, or read and write without one.

A volume is a named, persistent disk that outlives any sandbox. Mount it into one or many sandboxes, read and write its files straight from the API, and roll it back to any earlier version.

A volume is stored durably, apart from any one sandbox, so any sandbox in your organization can mount it. Manage them in the Volumes tab of the dashboard or with the calls below.

Create or open a volume

open returns the volume with that name and creates it when it does not exist. create fails with volume_exists when the name is taken.

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.open("data");        // get or create
const fresh = await client.volumes.create("models");  // must be new
import os
from platinum import Platinum

client = Platinum(token=os.environ["PT_TOKEN"], api_url=os.environ["PT_API_URL"])
vol = client.volumes.open("data")        # get or create
fresh = client.volumes.create("models")  # must be new
pt vol create models
curl -X PUT "$PT_API_URL/v1/volumes/data" -H "Authorization: Bearer $PT_TOKEN"
curl -X POST "$PT_API_URL/v1/volumes" -H "Authorization: Bearer $PT_TOKEN" \
  -H "Content-Type: application/json" -d '{"name": "models"}'

Use a temporary volume

A temporary volume is deleted when your code is done with it. If your process dies first, it is deleted about 10 minutes after the SDK's last heartbeat.

await using scratch = await client.volumes.temp();
with client.volumes.temp() as scratch:
    ...

Temporary volumes are hidden from list unless you pass includeTemporary: true (include_temporary=True in Python, --all in the CLI).

List, rename, and delete volumes

for await (const v of client.volumes.list()) console.log(v.name, v.logicalBytes);
const v = await client.volumes.get("data");
await client.volumes.rename("data", "data-v2");
await client.volumes.delete("data-v2");
for v in client.volumes.list():
    print(v.name, v.logical_bytes)
v = client.volumes.get("data")
client.volumes.rename("data", "data-v2")
client.volumes.delete("data-v2")
pt vol list
pt vol info data
pt vol rename data data-v2
pt vol delete data-v2
curl "$PT_API_URL/v1/volumes" -H "Authorization: Bearer $PT_TOKEN"
curl -X PATCH "$PT_API_URL/v1/volumes/data" -H "Authorization: Bearer $PT_TOKEN" \
  -H "Content-Type: application/json" -d '{"name": "data-v2"}'
curl -X DELETE "$PT_API_URL/v1/volumes/data-v2" -H "Authorization: Bearer $PT_TOKEN"

Delete fails with volume_busy while any sandbox has the volume mounted. Pass missingOk: true (missing_ok=True, --missing-ok) to succeed when the volume is already gone. Deleting a volume deletes its files and its history.

Mount volumes when you create a sandbox

Mounting needs a template built since volumes launched. Build one once from any image, then create sandboxes from it:

import { Template } from "@platinum-dev/sdk";

await Template.fromImage("ubuntu:24.04").build(client, { name: "my-template" });
from platinum import Template

Template.from_image("ubuntu:24.04").build(client, "my-template")
pt template build my-template --image ubuntu:24.04 --wait
curl -X POST "$PT_API_URL/v1/templates/from-image" \
  -H "Authorization: Bearer $PT_TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "my-template", "image": "ubuntu:24.04"}'

See Templates for packages, files, and sizes. An older template fails the mount with template_lacks_volume_support.

Pass volumes, keyed by the path to mount at. A value is a volume, its name, or a spec narrowed with subdir() and readOnly(). The mounts are ready before your code starts.

const users = await client.volumes.open("users");
const sbx = await client.sandboxes.create(
  {
    template: "my-template",
    volumes: {
      "/data": vol,                                    // read/write
      "/home/app": users.subdir("/users/42"),          // one directory of a volume
      "/models": (await client.volumes.get("models")).readOnly(),
    },
  },
  { waitForRunning: true },
);
users = client.volumes.open("users")
sbx = client.sandboxes.create(
    template="my-template",
    volumes={
        "/data": vol,                                  # read/write
        "/home/app": users.subdir("/users/42"),        # one directory of a volume
        "/models": client.volumes.get("models").read_only(),
    },
    wait_for_running=True,
)
pt sandbox create -t my-template -v data:/data -v users:/home/app:subdir=/users/42 -v models:/models:ro
curl -X POST "$PT_API_URL/v1/sandboxes?wait_for_state=running" \
  -H "Authorization: Bearer $PT_TOKEN" -H "Content-Type: application/json" \
  -d '{"template": "my-template", "volumes": {
        "/data": {"volume": "data"},
        "/home/app": {"volume": "users", "subdir": "/users/42"},
        "/models": {"volume": "models", "read_only": true}}}'
  • subdir mounts one directory of the volume as the mount's root. It is created if missing.
  • read_only mounts the volume read-only. Writes inside the sandbox fail, and a pull still brings in new commits.
  • A mount path is absolute, is not /, and does not sit under a system directory (/bin, /boot, /dev, /etc, /lib, /lib64, /proc, /root, /run, /sbin, /sys, /usr, /var). Mount paths on one sandbox must not overlap.
  • The same volume can be mounted by many sandboxes at once, and more than once in one sandbox.

The created sandbox lists its mounts in volume_mounts.

Attach and detach on a running sandbox

attach mounts a volume into a running sandbox. It waits up to 30 seconds and returns the mount; a slower mount comes back with state: "pending".

const scratch = await client.volumes.open("scratch");
const mount = await sbx.volumes.attach("/scratch", scratch);
const mounts = await sbx.volumes.list();
await sbx.volumes.detach("/scratch");                  // commits first
mount = sbx.volumes.attach("/scratch", client.volumes.open("scratch"))
mounts = sbx.volumes.list()
sbx.volumes.detach("/scratch")                         # commits first
curl -X POST "$PT_API_URL/v1/sandboxes/$SBX/volumes/_" \
  -H "Authorization: Bearer $PT_TOKEN" -H "Content-Type: application/json" \
  -d '{"volume": "scratch", "mount_path": "/scratch", "read_only": false}'
curl "$PT_API_URL/v1/sandboxes/$SBX/volumes" -H "Authorization: Bearer $PT_TOKEN"
curl -X DELETE "$PT_API_URL/v1/sandboxes/$SBX/volumes/$MOUNT_ID" -H "Authorization: Bearer $PT_TOKEN"

Detach commits first. Pass commit: false to discard uncommitted changes. It fails with volume_busy while files are open on the mount.

Commit and pull

Each mount is a private working copy. Your writes stay inside that sandbox until they are committed, and other sandboxes' commits reach you when you pull.

Commits happen on their own:

  • about every 5 seconds while a mount has uncommitted changes,
  • when you run sync or a program calls fsync on the mount,
  • when the sandbox stops, pauses, or is deleted, and when you detach.

Commit now

commit() returns once the changes are durable. Inside the sandbox, run pt-vol commit on the mount path.

const r = await sbx.volumes.get("/data").commit();
console.log(r.commit_id, r.overwritten);
r = sbx.volumes.get("/data").commit()
print(r["commit_id"], r["overwritten"])
pt vol commit $SBX:/data
curl -X POST "$PT_API_URL/v1/sandboxes/$SBX/volumes/$MOUNT_ID/commit" \
  -H "Authorization: Bearer $PT_TOKEN" -H "Content-Type: application/json" -d '{}'

From a shell or script inside the sandbox:

pt-vol commit /data     # returns once the commit is durable
pt-vol status /data     # mount state and uncommitted bytes
sync                    # starts a commit within moments, without waiting for it

pt vol commit /data works too when the full CLI is installed in the sandbox. pt-vol exits 0 on success, 2 when the mount is busy, 3 when the operation failed, and 4 when the path is not on a volume. Add --json for machine-readable output.

Pull the latest commits

A pull commits your pending changes, then switches the mount to the volume's latest version. A mount never changes under a running program on its own.

const p = await sbx.volumes.get("/data").pull();
console.log(p.mounted_commit_id);
p = sbx.volumes.get("/data").pull()
print(p["mounted_commit_id"])
pt vol pull $SBX:/data
curl -X POST "$PT_API_URL/v1/sandboxes/$SBX/volumes/$MOUNT_ID/pull" \
  -H "Authorization: Bearer $PT_TOKEN" -H "Content-Type: application/json" -d '{}'

Inside the sandbox, run pt-vol pull /data. A sandbox that stops and starts again pulls each mount to the latest version once it is running. A mount with files open at that moment stays on the version it had; pull it once they are closed.

A mount of a volume in drive sync mode is never swapped at a start. A sandbox that resumes from memory comes back on the version it stopped at, and the latest version's changes are then written into the mount file by file. That catch-up waits while the volume still has other commits to merge, so the mount can stay behind for as long as the volume's merges are behind.

Every mount read carries stale. It is true when the volume has a commit the mount did not write and does not show yet. Pull to bring it in. A mount's own commits never make it stale.

Every mount read also carries durability. It is commit on every mount today: a write survives the loss of its host once a commit holds it, not when fsync returns.

Warning

A pull fails with volume_busy while any process has a file open on the mount or its working directory inside it. The error lists each one (pid, command, and path). Close those files, and cd / before you run pt-vol pull /data from a shell.

When two sandboxes change the same files

Commits from different mounts merge file by file. The last commit to land wins each file it changed, as a whole file. Nothing is mixed inside a file.

  • Files you did not touch are kept, whoever changed them.
  • If you change a file another commit also changed, your version wins. The commit result lists that path in overwritten with the reason newer_remote_replaced.
  • If you change a file another commit deleted, your file comes back (resurrected).
  • If you delete a file another commit changed, their version stays. The result lists it in skipped.
  • Deleting a directory keeps any files another commit added inside it (skipped with dir_kept_unseen_children).

Every replaced version stays in history, so an overwritten file can be read back or restored.

Work with files without a sandbox

vol.files reads and writes a volume directly. Each write, copy, move, or remove is one commit, and running mounts see it after their next pull.

await vol.files.write("/config.json", JSON.stringify({ debug: true }));
const text = await vol.files.readText("/config.json");
const head = await vol.files.readBytes("/logs/app.log", { range: { offset: 0, length: 4096 } });
const entries = await vol.files.list("/", { recursive: true });
const st = await vol.files.stat("/config.json");

await vol.files.copy("/config.json", "/backup/config.json");
await vol.files.move("/backup", "/archive/backup");
await vol.files.remove("/archive", { recursive: true });
vol.files.write("/config.json", '{"debug": true}')
text = vol.files.read_text("/config.json")
head = vol.files.read("/logs/app.log", offset=0, length=4096)
entries = vol.files.list("/", recursive=True)
st = vol.files.stat("/config.json")

vol.files.copy("/config.json", "/backup/config.json")
vol.files.move("/backup", "/archive/backup")
vol.files.remove("/archive", recursive=True)
pt vol cp ./config.json data:/config.json
pt vol cat data:/config.json
pt vol ls data:/ -r
pt vol cp data:/config.json data:/backup/config.json
pt vol mv data:/backup data:/archive/backup
pt vol rm -r data:/archive
curl -X PUT "$PT_API_URL/v1/volumes/data/files/content?path=/config.json" \
  -H "Authorization: Bearer $PT_TOKEN" --data-binary @config.json
curl "$PT_API_URL/v1/volumes/data/files/content?path=/config.json" -H "Authorization: Bearer $PT_TOKEN"
curl "$PT_API_URL/v1/volumes/data/files?path=/&recursive=true" -H "Authorization: Bearer $PT_TOKEN"
curl -X DELETE "$PT_API_URL/v1/volumes/data/files?path=/archive&recursive=true" -H "Authorization: Bearer $PT_TOKEN"

write replaces an existing file unless you pass overwrite: false. copy and move refuse an existing destination unless you pass overwrite: true. Reads never follow symlinks. Add at (a commit id or tag) to list, stat, or a read to see an earlier version.

Upload a directory

upload sends many files and directories in one call. Data the volume already holds is not sent again, so re-uploading a tree after small changes is fast.

const r = await vol.files.upload((up) => {
  up.dir("./dataset", "/dataset");
  up.file("./model.bin", "/models/model.bin");
}, { overwrite: true });
console.log(r.files, r.uploaded_bytes);
with vol.files.upload(overwrite=True) as up:
    up.dir("./dataset", "/dataset")
    up.file("./model.bin", "/models/model.bin")
print(up.result["files"], up.result["uploaded_bytes"])
pt vol cp -r --overwrite ./dataset data:/dataset
pt vol cp ./model.bin data:/models/model.bin
pt vol cp -r data:/dataset ./dataset-copy

Without overwrite, an upload that would replace an existing file fails with path_exists and changes nothing. Empty directories are kept, and symlinks are skipped. write switches to an upload on its own for bodies over 64 MiB.

pt vol cp copies in every direction: a NAME:/path operand is a volume, anything else is local, and - is stdin or stdout. It creates the destination volume when it does not exist, and -r copies directories.

Browse history, tag, restore, and fork

Every commit is a version of the whole volume. List them newest first, pin one with a tag, make an old one current again, or start a new volume from it.

const { commits } = await vol.history({ limit: 20 });
await vol.tag("before-migration");                    // tags head
await vol.restore("before-migration");                // new commit, nothing is lost
const copy = await vol.fork("data-experiment", { at: "before-migration" });
commits = vol.history(limit=20)["commits"]
vol.tag("before-migration")                           # tags head
vol.restore("before-migration")                       # new commit, nothing is lost
copy = vol.fork("data-experiment", at="before-migration")
pt vol log data
pt vol tag data before-migration
pt vol restore data before-migration
pt vol fork data data-experiment --at before-migration
curl "$PT_API_URL/v1/volumes/data/commits?limit=20" -H "Authorization: Bearer $PT_TOKEN"
curl -X PUT "$PT_API_URL/v1/volumes/data/tags/before-migration" -H "Authorization: Bearer $PT_TOKEN" \
  -H "Content-Type: application/json" -d '{}'
curl -X POST "$PT_API_URL/v1/volumes/data/restore" -H "Authorization: Bearer $PT_TOKEN" \
  -H "Content-Type: application/json" -d '{"to": "before-migration"}'
curl -X POST "$PT_API_URL/v1/volumes/data/fork" -H "Authorization: Bearer $PT_TOKEN" \
  -H "Content-Type: application/json" -d '{"name": "data-experiment", "at": "before-migration"}'
  • restore adds a new commit whose contents match the one you name. The replaced version stays in history. Running mounts see it after their next pull.
  • fork creates a new, independent volume from any commit. It is instant, whatever the volume's size. A fork of a volume made under a smaller per-volume limit gets your organization's current limit; its filesystem takes the extra space the next time it is mounted writable.
  • tag points a name at a commit (head by default) and moves an existing tag. untag removes one. A tagged commit is kept until you remove the tag.

History keeps the newest 50 commits and the last 7 days of commits, thinned to one per 10 minutes once they are older than an hour. Tag any version you want to keep longer.

Find out why a volume is stuck

health() says whether writes, commits, or detaches on a volume are waiting on something, and on what. It changes nothing.

const h = await vol.health();
console.log(h.status, h.reason);        // "stuck" "gateway_lock_held"
for (const r of h.reasons) console.log(r.code, r.message);
h = vol.health()
print(h["status"], h["reason"])
for r in h["reasons"]:
    print(r["code"], r["message"])
curl "$PT_API_URL/v1/volumes/data/health" -H "Authorization: Bearer $PT_TOKEN"

status is ok, degraded (something needs attention, writes still land), or stuck (something will not end by itself). reasons lists every finding, worst first. The answer also shows the volume's open uploads, its merge queue, and each mount's state, with stale: true on a mount that is behind commits it did not make.

reasonSeverityWhat it means
gateway_lock_heldstuckOne write, upload commit, or merge has held the volume for over 2 minutes; every other write waits behind it.
upload_intent_stalestuckAn upload never ended after it lapsed.
merge_queue_stalledstuckCommits wait to merge and nothing is merging them.
mount_stuck_pending / mount_stuck_pulling / mount_stuck_unmountingstuckA mount, pull, or detach has not finished.
mount_unflushed / merge_job_parkeddegradedCommitted changes are not on the head yet.
mount_stalenoticeA writable mount is behind the head. Pull it.

The key needs volumes:read.

Use the dashboard

The Volumes tab lists your volumes with their size, live mounts, and last commit. Open one to:

  • browse its files, download them, and upload files or whole folders,
  • create folders, rename, move, and delete files,
  • read the commit history, browse files as of any commit, and tag, restore, or fork from it,
  • see which sandboxes mount it, mount it to a sandbox, or unmount it.

The sandbox create form has a Volumes section for mounting at create.

Pick the right workload

Volumes fit data that should outlive a sandbox and be shared by several:

  • a persistent home or drive per user (one volume, a subdir per user),
  • agent memory and working state that survives across sessions,
  • model weights, datasets, and other large read-mostly files (mount them read-only),
  • build caches, checkpoints, and outputs you want to keep or roll back.

They are not a live shared folder:

  • Two running sandboxes do not see each other's writes until one commits and the other pulls.
  • There are no locks across sandboxes. Coordinate writers yourself, or give each writer its own files or subdir.

Limits

Limitfreet1t2 and up
Volumes per organization31025
Size of one volume10 GiB50 GiB100 GiB
Commits per minute, per mount30120600
Private volume mounts per sandbox (each is a disk in the VM; share node mounts do not count)888
Volume mounts per sandbox, any share646464
Other limitsValue
A subdir mount's directory10 GiB
One write request over the REST API64 MiB (the SDKs switch to an upload above this)
Tags per volume100
Stored data per volume, counting history and tags4 times its size
Uploads in progress per organization4

A volume that is full behaves like a full disk inside the sandbox. Commits that arrive faster than the per-minute rate wait for their turn rather than fail. Read your organization's current limits and usage with client.volumes.limits() or pt vol limits. node_share in that answer says whether a mount with share node runs node-shared for your organization now; when it is false such a mount runs private and counts toward the private limit. share_fallback on the mount decides what a node mount does when it cannot be node-shared: private mounts a private copy and share_fallback_reason says why, refuse fails with share_unavailable and mounts nothing. Without it, a volume that is node-shared somewhere now refuses, and any other volume falls back to private. While node share is switched off or draining, a mount without share_fallback always falls back to private. A sandbox create never fails because a node mount fell back to private: one past the private limit is created parked (state parked, error says why) and mounts at a later start that has room.

Errors

Volume errors carry one of these code values. The SDKs raise a matching class (VolumeBusyError, QuotaExceededError, and so on).

Check out KDRIVE, a versioned drive built on volumes. Each drive is one volume, and every upload, rename or delete is a commit, so history, restore and share links pinned to a version come straight from volume versions. Source: kortix-ai/kdrive.

See also

  • Sandboxes: create options and lifecycle
  • Snapshots: point-in-time copies of a sandbox's own disk
  • CLI: every pt vol command
  • API reference: full request and response shapes