Workspaces
A durable file API over content-addressed object storage — writable before a pod exists, readable after it's gone.
A workspace is the user's files. It is a file API, not blob get/put: real directories, Stat, partial reads and writes, rename, snapshots. Underneath it's a content-addressed blob store over object storage plus a path index, which is what makes CopyFile, MoveFile and SnapshotWorkspace metadata-only operations rather than byte movement.
It is deliberately not a PVC. A workspace outlives any pod, resumes on any node, has no volume-count ceiling, and costs only object bytes while nothing is running.
Lifecycle
const ws = await cp.workspaces.create({
label: "#general channel",
fromSnapshot: snapshotId, // optional: fork a snapshot — metadata-only, instant
});cp.workspaces.get(id) returns a handle for an id you already hold, with no round trip.
| Call | Notes |
|---|---|
GetWorkspace / ListWorkspaces | Size, file count, last update; paginated |
UpdateWorkspace | Label only — everything else is immutable |
DeleteWorkspace | Independent of any sandbox that used it |
The file API
Every call works with or without a live sandbox. That's the property that makes "pre-load files → run later → retrieve after" work at all.
| Call | Notes |
|---|---|
Stat | One FileInfo: size, mode, is_dir, content sha256, mtime, symlink target |
ListFiles | Directory listing, optionally recursive |
ReadFile | Server stream; offset + length for partial reads (length: 0 = to EOF) |
WriteFile | Client stream: a WriteHeader then data frames |
MakeDir | parents: true is mkdir -p |
RemoveFile | File or directory (recursive) |
CopyFile / MoveFile | Metadata-only — no bytes move, however large the file |
Truncate | Set size |
SetAttr | Mode, mtime, symlink target |
ImportZip / ExportZip | Seed or retrieve a subtree |
CreateUpload | Presigned PUT so a browser uploads straight to object storage |
Paths are validated on every call: no traversal, namespace-scoped, confined to the workspace.
await ws.files.write("src/main.py", source); // string, bytes, or a stream
const report = await ws.files.readText("out/report.json");
await ws.files.edit("src/main.py", "3000", "8080"); // one exact occurrence
await ws.files.list("/", { recursive: true });
await ws.files.move("a.txt", "b.txt");
await ws.files.remove("tmp", { recursive: true });Reads fail at the call for a missing path, not part-way through the body — a lazily-opened stream would let a caller write a 200 before discovering the file was never there. Large bodies stream both ways: ws.files.reader(path) and passing a stream to write.
Write modes are OVERWRITE, APPEND and PATCH_AT_OFFSET (write length bytes starting at offset, leaving the rest intact).
Zip import
Two ways in. Stream the bytes, or hand the server a URL — the console's path, where the browser uploads straight to object storage and never through the API:
const { uploadUrl, objectRef } = await cp.workspaces.createUpload({
filename: "repo.zip",
size: bytes.length,
});
await fetch(uploadUrl, { method: "PUT", body: bytes });
await ws.files.importZipFrom(objectRef, { dest: "repo" });For a zip you already hold, ws.files.importZip(bytes, { dest: "repo" }) streams it directly; ws.files.exportZip("dist") is the way back out.
Entry paths are traversal-checked and expansion is size-capped, so a zip bomb fails the import rather than the platform.
Snapshots
SnapshotWorkspace freezes an immutable, copy-on-write view. It's content-addressed, so it's cheap and dedupes against the workspace it came from.
const snapshot = await ws.snapshot({ label: "v1" });
const frozen = await snapshot.files.readText("src/main.py");A snapshot handle exposes the read half of the file API and nothing else — snapshot.files.write does not exist, because a snapshot is immutable and that is better enforced by the type than by a runtime error.
Snapshots are useful three ways:
- Fork or template —
CreateWorkspace({ fromSnapshotId })gives an independent workspace instantly. - Read directly —
Stat,ListFilesandReadFileacceptsnapshot_idinstead ofworkspace_id, so a snapshot id plus a path names one set of bytes forever, with no fork. - Ship it — an app version mounts a snapshot read-only, and a built environment can use one as its build context.
ListSnapshots and DeleteSnapshot manage them.
Inside a sandbox
Attach a workspace to a sandbox (or a job) and the agent presents it as POSIX /workspace. The workload sees an ordinary directory tree — it never calls the file API, and it holds no credential for it. The agent materialises the tree on start and syncs changes back.
Cache.policy picks the durability/latency trade:
| Policy | Behaviour |
|---|---|
WRITE_THROUGH | Changes flushed promptly — durable, slower |
WRITE_BACK | Flushed on writeback_sec or on SyncWorkspace — fast, loses un-flushed writes on a hard kill |
While a sandbox is attached, the live copy is authoritative: file-API calls are routed through that sandbox's agent, so a WriteFile lands in the tree the process sees and a ReadFile reflects writes that haven't been flushed. Same call, same result — the caller doesn't choose, and there is no split-brain against the write-back cache.
SyncWorkspace forces a flush and reports conflicts — paths that changed on both sides since the last sync. The live container wins; the overwritten remote change is named so you can reconcile it.
Sizing
Cold start is proportional to file count (the agent fetches per file) and the periodic scan re-reads the tree, so the comfortable shape today is source trees and artifacts — thousands of files, not hundreds of thousands. Keep node_modules-scale dependency trees in the environment image, where they're layer-cached and shared, rather than in the workspace. That split is the intended one: environment for deps, workspace for your files.