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 newimport 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 newpt vol create modelscurl -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-v2curl "$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 --waitcurl -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:rocurl -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}}}'subdirmounts one directory of the volume as the mount's root. It is created if missing.read_onlymounts 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 firstmount = sbx.volumes.attach("/scratch", client.volumes.open("scratch"))
mounts = sbx.volumes.list()
sbx.volumes.detach("/scratch") # commits firstcurl -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
syncor a program callsfsyncon 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:/datacurl -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 itpt 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:/datacurl -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
overwrittenwith the reasonnewer_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 (
skippedwithdir_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:/archivecurl -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-copyWithout 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-migrationcurl "$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"}'restoreadds a new commit whose contents match the one you name. The replaced version stays in history. Running mounts see it after their next pull.forkcreates 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.tagpoints a name at a commit (head by default) and moves an existing tag.untagremoves 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.
reason | Severity | What it means |
|---|---|---|
gateway_lock_held | stuck | One write, upload commit, or merge has held the volume for over 2 minutes; every other write waits behind it. |
upload_intent_stale | stuck | An upload never ended after it lapsed. |
merge_queue_stalled | stuck | Commits wait to merge and nothing is merging them. |
mount_stuck_pending / mount_stuck_pulling / mount_stuck_unmounting | stuck | A mount, pull, or detach has not finished. |
mount_unflushed / merge_job_parked | degraded | Committed changes are not on the head yet. |
mount_stale | notice | A 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
subdirper 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
| Limit | free | t1 | t2 and up |
|---|---|---|---|
| Volumes per organization | 3 | 10 | 25 |
| Size of one volume | 10 GiB | 50 GiB | 100 GiB |
| Commits per minute, per mount | 30 | 120 | 600 |
Private volume mounts per sandbox (each is a disk in the VM; share node mounts do not count) | 8 | 8 | 8 |
| Volume mounts per sandbox, any share | 64 | 64 | 64 |
| Other limits | Value |
|---|---|
A subdir mount's directory | 10 GiB |
One write request over the REST API | 64 MiB (the SDKs switch to an upload above this) |
| Tags per volume | 100 |
| Stored data per volume, counting history and tags | 4 times its size |
| Uploads in progress per organization | 4 |
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).
| Code | Status | Meaning | What to do |
|---|---|---|---|
volume_not_found | 404 | No volume has that name or id in this organization. | Check the name, or use open to create it. |
volume_exists | 409 | The name is taken (names ignore case). | Pick another name, or use open. |
volume_busy | 409 | A pull or detach found processes using the mount (holders), a delete found live mounts (mounts), or a clone or fork found mounted volumes. | Close the files and cd / for a pull or detach. Detach every mount before deleting, cloning, or forking. |
quota_exceeded | 403 | A limit was hit. limit names it: volumes, bytes, stored_bytes, tags, mounts (private mounts), or total_mounts. | Delete volumes, files, or tags, or raise your tier. |
read_only_mount | 409 | You asked a read-only mount to commit. | Mount it read/write, or write through vol.files. |
path_exists | 409 | The destination exists and overwrite is off, or a mount path is already used on that sandbox. | Pass overwrite: true, or pick another path. |
path_not_found | 404 | The file or directory does not exist at that version. | Check the path and at. |
not_a_file | 409 | You read a directory or a symlink as a file. | Read a regular file. |
not_a_directory | 409 | A parent in the path, or a subdir, is a file. | Fix the path. |
directory_not_empty | 409 | You removed a non-empty directory without recursive. | Pass recursive: true. |
sub_path_too_large | 422 | The subdir holds more than 10 GiB. | Mount the whole volume, or a smaller directory. |
commit_failed | 503 | The commit or pull did not finish. Your changes are kept, and a retry is always safe. | Retry. The SDKs already retry a few times. |
template_lacks_volume_support | 409 | The sandbox's template was built before volumes existed. | Build a new template from any image (see Mount volumes) and create the sandbox from it. |
sandbox_not_running | 409 | Attach, commit, and pull need a running sandbox. | Start the sandbox. |
share_unavailable | 409 | A share node mount could not be node-shared and its share_fallback is refuse (the default on a volume that is node-shared elsewhere). reason says why. Nothing was mounted. | Pass share_fallback: "private" to accept a private copy, or mount without share node. |
insufficient_scope | 403 | The API key lacks volumes:read or volumes:write. | Use a key with that scope. |
payload_too_large | 413 | A single REST write was over 64 MiB. | Use the SDK or pt vol cp, which upload large files. |
rate_limited | 429 | Four uploads are already in progress for your organization. | Wait for one to finish, then retry. |
conflict | 409 | The running sandbox is placed where it cannot mount this volume. | Stop and start the sandbox, then attach again. |
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 volcommand - API reference: full request and response shapes