Filesystem and ports
Reading and writing session files, uploads, downloads, watching, and exposing ports to the internet.
Filesystem
GET /v1/sessions/{id}/fs/list?path=/workspace
GET /v1/sessions/{id}/fs/read?path=/workspace/out.json
PUT /v1/sessions/{id}/fs/write?path=/workspace/in.json
DELETE /v1/sessions/{id}/fs?path=/workspace/tmp
POST /v1/sessions/{id}/fs/mkdir
POST /v1/sessions/{id}/fs/move
GET /v1/sessions/{id}/fs/archive?path=/workspace&format=tar.gz
POST /v1/sessions/{id}/fs/extract
GET /v1/sessions/{id}/fs/watch?path=/workspace (SSE)
Available at tiers 1–4. Tier 1 is limited to /tmp and /workspace; tiers 2–4 reach the whole
guest filesystem subject to guest permissions.
List
curl -sS "$WB_BASE_URL/sessions/$SESSION/fs/list?path=/workspace&depth=2" \ -H "Authorization: Bearer $WB_API_KEY"
{
"object": "list",
"path": "/workspace",
"data": [
{ "name": "app", "type": "dir", "size_bytes": 4096, "mode": "0755", "modified_at": "2026-10-06T09:18:02Z" },
{ "name": "out.json", "type": "file", "size_bytes": 2048, "mode": "0644", "modified_at": "2026-10-06T09:21:44Z" },
{ "name": "link", "type": "symlink", "target": "/workspace/app" }
]
}
depth defaults to 1, maximum 5. Entries are capped at 10,000 per response; beyond that, use
archive or narrow the path.
Read
curl -sS "$WB_BASE_URL/sessions/$SESSION/fs/read?path=/workspace/out.json" \ -H "Authorization: Bearer $WB_API_KEY" -o out.json
Streams raw bytes with the guessed Content-Type. Supports Range for partial reads, which is how
you tail a large log without transferring it.
curl -sS "$WB_BASE_URL/sessions/$SESSION/fs/read?path=/workspace/build.log" \ -H "Range: bytes=-4096" -H "Authorization: Bearer $WB_API_KEY"
Write
curl -sS -X PUT "$WB_BASE_URL/sessions/$SESSION/fs/write?path=/workspace/in.json&mode=0644" \ -H "Authorization: Bearer $WB_API_KEY" \ -H "Content-Type: application/octet-stream" \ --data-binary @local.json
Parent directories are created as needed. Writes are atomic: content goes to a temporary file and is renamed, so a reader never sees a partial file.
Maximum single-request body is 100 MiB. For larger files, use a multipart upload:
# 1. start
UPLOAD=$(curl -sS "$WB_BASE_URL/sessions/$SESSION/fs/uploads" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{ "path": "/workspace/dataset.parquet", "size_bytes": 4294967296 }' | jq -r .id)
# 2. upload parts (parallel, 8 MiB+ each)
curl -sS -X PUT "$WB_BASE_URL/sessions/$SESSION/fs/uploads/$UPLOAD/parts/1" \
-H "Authorization: Bearer $WB_API_KEY" --data-binary @part1
# 3. complete
curl -sS -X POST "$WB_BASE_URL/sessions/$SESSION/fs/uploads/$UPLOAD/complete" \
-H "Authorization: Bearer $WB_API_KEY"
Archive and extract
Moving a tree in or out in one call.
# download a directory curl -sS "$WB_BASE_URL/sessions/$SESSION/fs/archive?path=/workspace/dist&format=tar.gz" \ -H "Authorization: Bearer $WB_API_KEY" -o dist.tar.gz # upload a directory curl -sS -X POST "$WB_BASE_URL/sessions/$SESSION/fs/extract?path=/workspace" \ -H "Authorization: Bearer $WB_API_KEY" \ -H "Content-Type: application/gzip" --data-binary @src.tar.gz
Formats: tar, tar.gz, tar.zst, zip. Extraction rejects entries with absolute paths or ..
components, so an archive cannot write outside the target directory.
Watch
curl -N "$WB_BASE_URL/sessions/$SESSION/fs/watch?path=/workspace&recursive=true" \ -H "Authorization: Bearer $WB_API_KEY"
event: fs
data: {"op":"create","path":"/workspace/dist/index.js","size_bytes":48211}
event: fs
data: {"op":"modify","path":"/workspace/out.json","size_bytes":2048}
Events are coalesced over a 100 ms window, so a build writing a thousand files produces a readable stream rather than a thousand frames. Watches are capped at 10 concurrent per session.
Permissions and limits
| Limit | Value |
|---|---|
| Single-request write | 100 MiB |
| Multipart upload | 100 GiB |
| Single-request read | Unlimited, streamed |
| List entries per response | 10,000 |
| Concurrent watches | 10 per session |
| Path length | 4096 bytes |
Operations run as the session's default user — root at tiers 2–4, an unprivileged user at tier 1.
Symlinks are followed for reads and never followed for writes, which stops a session's own symlink
from redirecting an upload.
Ports
GET /v1/sessions/{id}/proxy/{port}/{rest} reach a declared port under this API's own domain
GET /v1/sessions/{id}/proxy-url/{port} mint a capability URL on the port's own subdomain
Tiers 0–2 today. A port is declared once, at session creation, with expose_ports — there is no
separate expose/unexpose call, and a running session cannot add or remove one. long_running only;
one_shot rejects expose_ports outright (it has nothing stable to expose a port to).
// POST /v1/sessions
{
"tier": 0,
"execution_mode": "long_running",
"expose_ports": [
{ "port": 8080, "protocol": "http" },
{ "port": 9090, "protocol": "ws", "public": true }
]
}
| Field | Default | Notes |
|---|---|---|
port | — | 1024–65535; must be listening when traffic arrives |
protocol | http | http or ws — both ride the same relay, a WebSocket upgrade included |
public | false | No capability token at all when true — see below |
Up to 4 ports per session. Start the process that listens on the declared port with POST /v1/sessions/{id}/start (a detached process, outliving any one request — see the
Processes reference) before reaching it. An undeclared
port — one never named in expose_ports at creation, and there is no way to add one afterward —
returns 404; a declared port on a session that isn't running right now (still provisioning,
stopped, expired) returns 409. A declared port nothing is listening on yet is neither of those —
see the "port must be listening" note below.
Two ways to reach a declared port
Path-based, under this API's own domain — always available, authenticated the normal way (the caller's own credential):
curl -sS "$WB_BASE_URL/sessions/$SESSION/proxy/8080/" -H "Authorization: Bearer $WB_API_KEY"
A capability URL on the port's own subdomain — for an app that assumes it's served from /
(relative asset URLs, path-scoped cookies break under a path prefix):
curl -sS "$WB_BASE_URL/sessions/$SESSION/proxy-url/8080" -H "Authorization: Bearer $WB_API_KEY"
{ "url": "https://sess-7f2a9c1e8b4d6a30.sandboxes.workspaces.build/?t=c2Vzc19…" }
The t= token is a real credential, checked locally (signature and expiry) then confirmed live
against this API (cached briefly, so revocation — stopping the session, suspending the organization
— takes effect within seconds, not at the token's own expiry). It's valid for 24 hours or the
session's own lifetime, whichever ends first. A port declared public: true instead mints a URL with
no token at all — https://sess-<hash>.sandboxes.workspaces.build/, reachable by anyone who has
it, gated purely by the session's own lifetime. Mark a port public deliberately, not as a default: it
trades away the only thing that makes the signed form a real credential.
Notes
- The port must be listening. Reaching a declared port nothing is bound to yet returns
502/504from the process behind it, not a platform-level error — expose after the server starts. - Bind to
0.0.0.0. A server on127.0.0.1is unreachable from the relay. - Exposures die with the session, and are not addable back once the session stops — boot a new one.
- Planned, not yet built: raw TCP exposure (not just HTTP/WebSocket),
basic_authas an extra layer on any port, and inbound byte metering as its own meter. The shape above is deliberately narrower than that ambition today.