How to upload and download files from a sandbox
Every sandbox integration hits file transfer within an hour. The agent needs a CSV to analyse, or produced a chart you want to show the user, or checked out a repository you would like to inspect. The API for this is small and the first version of your code will work immediately — and then break in one of four specific ways as soon as the files get real.
This covers the API, both SDKs, and each of those four failure modes with the workaround that actually fits.
The API is five endpoints
Files are addressed by absolute path inside the guest, passed as a query parameter. Reads return raw bytes; writes take raw bytes in the body. There is no multipart, no upload session, and no signed URL — it is deliberately the simplest thing that works.
# Read a file out. Response body is the raw bytes; a missing path is a
# 404 rather than a 500, so you can branch on it.
curl -sS -H "Authorization: Bearer $PANDASTACK_API_KEY" \
"https://api.pandastack.ai/v1/sandboxes/$SANDBOX_ID/fs?path=/work/out.csv" \
-o out.csv
# Write a file in. Body is raw bytes -- NOT form data. Parent directories
# are created for you, and the path must be absolute.
curl -sS -X PUT \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @input.csv \
"https://api.pandastack.ai/v1/sandboxes/$SANDBOX_ID/fs?path=/work/input.csv"
# -> {"path":"/work/input.csv","bytes":81422}
# Delete. Returns 204.
curl -sS -X DELETE -H "Authorization: Bearer $PANDASTACK_API_KEY" \
"https://api.pandastack.ai/v1/sandboxes/$SANDBOX_ID/fs?path=/work/scratch"
# List a directory, and stat one path.
curl -sS -H "Authorization: Bearer $PANDASTACK_API_KEY" \
"https://api.pandastack.ai/v1/sandboxes/$SANDBOX_ID/fs/dir?path=/work"
curl -sS -H "Authorization: Bearer $PANDASTACK_API_KEY" \
"https://api.pandastack.ai/v1/sandboxes/$SANDBOX_ID/fs/stat?path=/work/out.csv"Python
from pandastack import Sandbox
sbx = Sandbox.create(template="code-interpreter", ttl_seconds=900)
# --- in ---
sbx.filesystem.write("/work/data.csv", open("data.csv", "rb").read())
sbx.filesystem.upload("./notebook.ipynb", "/work/notebook.ipynb") # same thing
sbx.filesystem.write("/work/config.json", '{"mode": "strict"}') # str is fine
# --- out ---
raw = sbx.filesystem.read("/work/report.pdf") # -> bytes, always
sbx.filesystem.download("/work/report.pdf", "./report.pdf")
# --- inspect ---
if sbx.filesystem.exists("/work/out.csv"):
info = sbx.filesystem.stat("/work/out.csv")
print(info.size, info.mode, info.is_dir)
for entry in sbx.filesystem.listdir("/work"):
print(entry.name, entry.size)
# Recursive, when you do not know the shape of what the agent produced.
for directory, dirs, files in sbx.filesystem.walk("/work/output"):
for f in files:
print(f.path, f.size)TypeScript
The TypeScript surface mirrors it, with one important difference: `read()` returns a `string`, not a byte array. That is exactly right for source files, logs, and JSON, and wrong for anything binary — a PDF or a PNG round-tripped through it will be corrupted by UTF-8 decoding. For binary payloads in Node, go around the SDK and read the response as an array buffer.
import { Sandbox } from "@pandastack/sdk";
import { readFile, writeFile } from "node:fs/promises";
const sbx = await Sandbox.create({ template: "code-interpreter" });
// --- in --- Uint8Array for binary, string for text. Both fine.
await sbx.filesystem.write("/work/data.csv", await readFile("data.csv"));
await sbx.filesystem.write("/work/config.json", JSON.stringify({ mode: "strict" }));
await sbx.filesystem.upload("./notebook.ipynb", "/work/notebook.ipynb");
// --- out, TEXT --- read() decodes as UTF-8 text.
const log = await sbx.filesystem.read("/work/run.log");
// --- out, BINARY --- do NOT use read() for this. It returns a string,
// and UTF-8 decoding a PNG destroys it. Hit the endpoint directly:
const res = await fetch(
`https://api.pandastack.ai/v1/sandboxes/${sbx.id}/fs` +
`?path=${encodeURIComponent("/work/chart.png")}`,
{ headers: { Authorization: `Bearer ${process.env.PANDASTACK_API_KEY}` } },
);
if (!res.ok) throw new Error(`fs read failed: ${res.status}`);
await writeFile("./chart.png", Buffer.from(await res.arrayBuffer()));
// --- inspect ---
for (const e of await sbx.filesystem.list("/work")) {
console.log(e.name, e.size, e.is_dir);
}The four things that break
1. There is a 32 MiB cap on a single write
The write endpoint reads the body through a limit reader capped at 32 MiB and rejects anything larger. This is not an arbitrary annoyance — a file transfer that goes through the control plane occupies a request slot for its duration, and buffering multi-gigabyte bodies through an HTTP API is a bad shape for everyone involved.
So for anything larger, invert the direction. Do not push the bytes through the API; give the sandbox a URL and let it pull. A sandbox has full network access and a real Linux userland, so `curl` inside the guest is faster, resumable, and does not hold your request open.
# WRONG for a 2 GB dataset: pushes every byte through the control plane
# and fails at 32 MiB anyway.
# sbx.filesystem.write("/work/train.parquet", open("train.parquet","rb").read())
# RIGHT: hand the sandbox a pre-signed URL and let it fetch directly.
# One HTTP call from you; the bytes never touch your process.
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "datasets", "Key": "train.parquet"},
ExpiresIn=900,
)
r = sbx.commands.run(
# --fail so a 403 from an expired signature is an error, not an HTML
# file named train.parquet that fails confusingly two steps later.
f"curl -sS --fail --retry 3 -o /work/train.parquet {shlex.quote(url)}",
timeout=1800,
)
assert r.exit_code == 0, r.stderr
# Same trick outward-bound: PUT to a pre-signed upload URL from inside.
put_url = s3.generate_presigned_url(
"put_object", Params={"Bucket": "results", "Key": "out.parquet"}, ExpiresIn=900
)
sbx.commands.run(
f"curl -sS --fail -X PUT --upload-file /work/out.parquet {shlex.quote(put_url)}",
timeout=1800,
)2. There is no directory upload
The write endpoint takes one file. Uploading a project by walking it locally and issuing one request per file works, and then someone points it at a `node_modules` directory and you have made forty thousand HTTP calls.
Tar it. One archive, one write, one extraction — and the tar preserves the mode bits and directory structure that a per-file loop quietly loses. For a repository specifically, cloning inside the sandbox is better still: nothing crosses your process at all.
import io, shlex, tarfile
def upload_dir(sbx, local_dir: str, remote_dir: str) -> None:
"""One request instead of one-per-file. Preserves modes and layout."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
# arcname="." so the archive extracts INTO remote_dir rather than
# creating a nested copy of the local directory name.
tar.add(local_dir, arcname=".")
payload = buf.getvalue()
if len(payload) > 32 * 1024 * 1024:
raise ValueError(
f"archive is {len(payload) / 1e6:.0f} MB, over the 32 MiB write cap "
"-- use a pre-signed URL and fetch it from inside the sandbox"
)
sbx.filesystem.write("/tmp/upload.tgz", payload)
r = sbx.commands.run(
f"mkdir -p {shlex.quote(remote_dir)} && "
f"tar xzf /tmp/upload.tgz -C {shlex.quote(remote_dir)} && "
"rm -f /tmp/upload.tgz",
timeout=300,
)
assert r.exit_code == 0, r.stderr
# And the reverse, for collecting whatever an agent produced.
def download_dir(sbx, remote_dir: str, local_tgz: str) -> None:
sbx.commands.run(
f"tar czf /tmp/out.tgz -C {shlex.quote(remote_dir)} .", timeout=300
)
sbx.filesystem.download("/tmp/out.tgz", local_tgz)
# For a git repository, skip all of this: clone inside the sandbox.
# sbx.commands.run("git clone --depth 1 https://... /work/repo")3. Nothing you write survives the sandbox
A sandbox's root filesystem is a copy-on-write clone that is discarded when the sandbox is deleted — including by its TTL, while you were not looking. Agents that produce artifacts hit this constantly: the work succeeded, the sandbox is gone, the output went with it.
There are three durable options and they suit different things. Download the artifact before you tear the sandbox down, which is the simplest and right for small outputs. Attach a volume, which persists independently of any sandbox and is right for a working directory you return to. Or snapshot the sandbox, which captures the whole machine — disk and memory — and is right when you want to resume the exact state later rather than just keep a file.
# The pattern that stops losing artifacts: collect in a finally block,
# so a failure path still gets you the logs that explain it.
sbx = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
try:
r = sbx.commands.run("python /work/train.py", timeout=1500)
finally:
# Always retrieve diagnostics, even -- especially -- on failure.
for path, dest in [
("/work/run.log", "./artifacts/run.log"),
("/work/metrics.json", "./artifacts/metrics.json"),
]:
if sbx.filesystem.exists(path):
sbx.filesystem.download(path, dest)
sbx.kill()4. Ownership and permissions are not what you assumed
Files arrive owned by the user the guest bridge writes as, and the mode is whatever the guest's umask produces — which is a problem when your application inside the sandbox runs as a different user, or when you upload a script and it is not executable. The write API sets content, not metadata.
Fix it explicitly after writing rather than assuming: `chmod +x` the script, `chown` the directory to the service user. It is one extra exec call and it removes a class of confusing permission-denied failures that look like sandbox bugs.
The short version
- Small text and small binaries: use the SDK's write and read, and remember TypeScript's `read()` gives you a string — go direct to the endpoint for binary.
- Anything over a few megabytes: give the sandbox a short-lived pre-signed URL and let it `curl` the bytes itself, in both directions.
- Directories: tar, write once, extract inside. For a git repository, clone inside the sandbox and transfer nothing.
- Artifacts you want to keep: download in a `finally` block, or attach a volume, or snapshot. The rootfs disappears with the sandbox and the TTL will fire while you are not watching.
- Executables and service-owned paths: `chmod` and `chown` after writing. The API sets bytes, not metadata.
Frequently asked questions
What is the maximum file size I can upload to a sandbox?
A single write through the filesystem API is capped at 32 MiB; larger bodies are rejected. The cap exists because a file transfer through the control plane holds an HTTP request open for its whole duration, and buffering large bodies through an API is the wrong shape for both sides. For anything bigger, invert the transfer: generate a short-lived pre-signed URL for the object in your own storage and run curl inside the sandbox to fetch it. That is faster, it is resumable, and the bytes never pass through your process. The same trick works outbound — curl with --upload-file against a pre-signed PUT URL. If you are moving large files repeatedly to the same place, attach a volume instead so the data is already there next time.
Why is my downloaded file corrupted?
Two causes, and both are encoding rather than transfer. In the TypeScript SDK, filesystem.read() returns a string — the response is decoded as UTF-8 text — which is correct for logs and source files and destroys anything binary. Bytes that are not valid UTF-8 get replaced, so a PDF or a PNG comes back the right approximate size and unopenable. For binary in Node, call the fs endpoint with fetch and use arrayBuffer(). The Python SDK returns bytes and does not have this problem. The other cause is on the way in: using curl -d instead of --data-binary. The -d flag strips newlines, so a multi-line file is silently mangled and the request still returns 200 with a plausible byte count. Always --data-binary for uploads.
How do I upload a whole directory to a sandbox?
Create a tar archive, write it as one file, and extract it inside the sandbox. Walking the directory locally and issuing one request per file works for a handful of files and becomes untenable fast — point it at a node_modules tree and you have tens of thousands of round trips. The tar approach is also more correct, because it preserves directory structure and mode bits that a per-file loop loses. Use arcname="." when building the archive so it extracts into your target directory instead of creating a nested copy of the local directory's name. If the archive exceeds the 32 MiB write cap, upload it to storage and fetch it from inside the sandbox with a pre-signed URL. And for a git repository specifically, skip the transfer entirely: run git clone inside the sandbox, which is faster and sends nothing through your process.
Do files written to a sandbox persist?
Not past the sandbox's life. The root filesystem is a copy-on-write clone that is discarded when the sandbox is deleted, and that includes deletion by its own TTL — which is the version that surprises people, because the work succeeded and the output vanished while nobody was looking. Three durable options. Download the artifact before teardown, ideally in a finally block so failures still yield their logs. Attach a volume, which exists independently of any sandbox and can be mounted by the next one, and is the right answer for a working directory you keep returning to. Or take a snapshot, which captures the whole machine including memory, and is the right answer when you want to resume the exact state rather than keep a file. Deciding which one you need before the first long-running job is much cheaper than deciding after.
Can I stream a file into a sandbox instead of buffering it?
Not through the write endpoint, which reads the whole body up to its 32 MiB limit before writing. But you can get streaming behaviour by moving the transfer inside the sandbox, which is the better architecture anyway: give the guest a pre-signed URL and let curl stream the object to disk, which is resumable, shows progress you can read from the exec stream, and does not hold one of your API requests open for the duration. If the data is being produced live rather than sitting in storage — a log tail, a generated dataset — the practical pattern is to pipe it in through an exec session: run a command in the sandbox that reads from stdin and writes where you want, and feed it from your side. That trades the filesystem API for the exec API, which is the one designed for streams.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.