Move files in and out of agent sandboxes: uploads, volumes and buckets
Upload the inputs, let the sandbox fetch big public data itself, keep reused data on a volume or bucket, and download only what changed.
Runtime (withruntime.com) moves a folder into a sandbox as one compressed archive that is unpacked as it arrives, keeps volumes that outlive sandboxes at about $0.11 per GiB per 30-day month, and mounts your own bucket without the sandbox ever holding its key.
An agent's work begins with files it did not make and ends with files someone
else needs. Get the transfer wrong and it shows up as slow starts, a server of
yours that relays gigabytes for no reason, or a results folder full of
node_modules. This post sorts an agent's files by where they come from and
how often they are reused, and gives each kind its route.
Which route fits which files?
Two questions decide it: how big are the files, and will the next sandbox need them too?
| Files | Route | Why |
|---|---|---|
| A user's upload, a prompt's attachment | files.write from your server |
Small, and already in your hands |
| A project folder | files.upload of the folder |
One archive, permissions and links kept |
| A public dataset or a release download | The sandbox fetches it itself | Your server never relays the bytes |
| Private data already in a bucket | A bucket mount, read-only | No copy, and no key inside the sandbox |
| Data many sandboxes read: weights, a corpus | A volume, attached read-only to each | Copied in once, read by any number |
| A cache one agent reuses run after run | A read-write volume, or an image | Survives the sandbox |
| Results someone reads | files.download of what changed |
Only the agent's output comes back |
| Results other systems read | Written to a mounted bucket | Lands where they already look |
The rest of this post goes through them in the order an agent's task uses them: in, during, out.
How do you hand a task to the agent?
Put everything the task needs in one folder and upload it in one call. A
folder travels as one compressed archive and the sandbox's own tar unpacks
it as it arrives, so a thousand small files cost about what one archive does.
A single file goes in parallel 1 MiB chunks, each checked by SHA-256, and
resumes after a dropped connection.
Then the agent works, and at the end you want its output, not the inputs you already have. List the folder, keep the files modified after the hand-over, and bring back only those:
TypeScriptimport { mkdtemp, writeFile } from "node:fs/promises";import { tmpdir } from "node:os";import { join } from "node:path";import { Sandbox } from "withruntime";// The task: a folder on your machine with the inputs and the instructions.const task = await mkdtemp(join(tmpdir(), "task-"));await writeFile(join(task, "sales.csv"), "region,revenue\nnorth,1200\nsouth,950\n");await writeFile(join(task, "TASK.md"), "Summarize revenue by region in out/summary.md\n");await using sbx = await Sandbox.create({ labels: { task: "revenue-summary" } });await sbx.files.upload(task, "/workspace/task"); // in: one archive, unpacked as it arrivesconst handedOver = Date.now() - 2_000; // a little slack for the two clocks// The agent works here, through exec and files calls. Its output, for example:await sbx.files.write("/workspace/task/out/summary.md", "north: 1,200\nsouth: 950\n");// Out: only what changed since the hand-over.const entries = await sbx.files.list("/workspace/task", { depth: 4 });const changed = entries.filter((e) => e.type === "file" && Date.parse(e.modifiedAt) >= handedOver);for (const file of changed) { await sbx.files.download(file.path, join(task, file.path.slice("/workspace/task/".length))); console.log("brought back", file.path, `${file.size} bytes`);}console.log(`${changed.length} of ${entries.length} entries changed`);Pythonimport osimport tempfileimport timefrom datetime import datetimefrom withruntime import Sandbox# The task: a folder on your machine with the inputs and the instructions.task = tempfile.mkdtemp(prefix="task-")with open(os.path.join(task, "sales.csv"), "w") as f: f.write("region,revenue\nnorth,1200\nsouth,950\n")with open(os.path.join(task, "TASK.md"), "w") as f: f.write("Summarize revenue by region in out/summary.md\n")with Sandbox.create(labels={"task": "revenue-summary"}) as sbx: sbx.files.upload(task, "/workspace/task") # in: one archive, unpacked as it arrives handed_over = time.time() - 2 # a little slack for the two clocks # The agent works here, through exec and files calls. Its output, for example: sbx.files.write("/workspace/task/out/summary.md", "north: 1,200\nsouth: 950\n") # Out: only what changed since the hand-over. entries = sbx.files.list("/workspace/task", depth=4) changed = [e for e in entries if e["type"] == "file" and datetime.fromisoformat(e["modifiedAt"].replace("Z", "+00:00")).timestamp() >= handed_over] for file in changed: local = os.path.join(task, file["path"].removeprefix("/workspace/task/")) os.makedirs(os.path.dirname(local), exist_ok=True) sbx.files.download(file["path"], local) print("brought back", file["path"], f"{file['size']} bytes") print(f"{len(changed)} of {len(entries)} entries changed")A time check is enough when the agent writes new files. When it edits files in
place and you need to know which edits are real, compare content hashes
instead, as the recorder in see what your agent did
does. For a code task, git diff inside the sandbox is the best answer of all:
the change itself, as text, ready to review.
Should big data pass through your server?
No. If the data is on the public internet, let the sandbox download it directly. It arrives faster, your server stays out of the path, and nothing large is held in your process's memory on the way:
TypeScriptimport { Sandbox } from "withruntime";const url = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet";await using sbx = await Sandbox.create({ diskMiB: 16_384, network: { internet: true, allow: ["d37ci6vzurychx.cloudfront.net"] }, // this host and no other});const r = await sbx.exec(`curl -fsSL --retry 3 -o trips.parquet '${url}' && ls -l trips.parquet`, { cwd: "/workspace", timeoutMs: 600_000,});console.log(r.exitCode, r.stdout.trim());Pythonfrom withruntime import Sandboxurl = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet"with Sandbox.create(disk_mib=16_384, network={"internet": True, "allow": ["d37ci6vzurychx.cloudfront.net"]}) as sbx: r = sbx.exec(f"curl -fsSL --retry 3 -o trips.parquet '{url}' && ls -l trips.parquet", cwd="/workspace", timeout_ms=600_000) print(r.exit_code, r.stdout.strip())Two settings in there matter. The allow list names the one host the download needs, so a model that later reads instructions inside the data cannot send anything elsewhere. And the disk is sized for the data: a sandbox's default disk is 4 GiB, and a large file plus its unpacked or converted copy fills that quickly (size CPU and memory).
What about private data in a bucket?
Mount it. A bucket in S3, R2 or Google Cloud Storage appears as a folder, and the agent reads it with ordinary file tools. You store the bucket's key once as a secret; the sandbox gets a placeholder, and the real key is added on the way out to the bucket's host only. The agent can read the data and cannot read the key:
TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create();await sbx.mounts.add({ provider: "s3", bucket: "acme-research", prefix: "datasets/2026-q3", region: "us-east-1", path: "/data", secret: "RESEARCH_BUCKET", readOnly: true, // the agent reads; it cannot delete or overwrite});console.log((await sbx.exec("ls -la /data | head")).stdout);Pythonfrom withruntime import Sandboxwith Sandbox.create() as sbx: sbx.mounts.add(provider="s3", bucket="acme-research", prefix="datasets/2026-q3", region="us-east-1", path="/data", secret="RESEARCH_BUCKET", read_only=True) # the agent reads; it cannot delete or overwrite print(sbx.exec("ls -la /data | head").stdout)readOnly and prefix are the two lines to keep. A prefix limits the agent to
the data for this task, and read-only means an agent that decides to clean up
cannot clean up your bucket. For results that other systems pick up, mount a
second, writable prefix for output only. A mount lasts through a pause and a
wake, and ends when the sandbox stops
(mount an S3 bucket).
When is a volume the right answer?
When the same bytes are needed by many sandboxes, or by the next one. A volume is a disk that outlives sandboxes, and it has two modes:
- Read-write, one sandbox at a time. A package cache, a database file, a model's checkpoint directory. Each run picks up where the last one left off.
- Read-only copies, any number at once. Model weights or a reference corpus
that every worker reads. Copy it in once, then attach it to a hundred
sandboxes with
mode: "snapshot", and none of them can change it.
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const weights = await runtime.volumes.create({ sizeMiB: 40_960, name: "weights-v3" });const workers = await Promise.all( [1, 2, 3, 4].map(() => runtime.sandboxes.create({ volumes: [{ volumeId: weights.id, path: "/models", mode: "snapshot" }], }), ),);console.log(workers.map((w) => w.id));await Promise.all(workers.map((w) => w.stop()));Pythonfrom withruntime import Runtimeruntime = Runtime()weights = runtime.volumes.create(40_960, name="weights-v3")workers = [runtime.sandboxes.create(volumes=[{"volumeId": weights["id"], "path": "/models", "mode": "snapshot"}]) for _ in range(4)]print([w.id for w in workers])for w in workers: w.stop()A volume is billed on its full size from the moment it is created, written or not, and backed up off its server every day. It lives on one server, and a sandbox that uses it is placed there. One trade-off to know before choosing: a sandbox with volumes cannot be snapshotted. If your agent relies on snapshots to save and restore its machine, keep the reused data in a custom image or a bucket mount instead (an undo button for AI agents).
Volume, image or bucket?
All three keep data beyond one sandbox. The difference is how often it changes and who writes it:
| Keep it in | Changes | Written by | Works with snapshots |
|---|---|---|---|
| A custom image | Rarely: when you rebuild it | Your build | Yes |
| A volume | Every run, or never (read-only) | The sandbox using it | No |
| A bucket mount | Any time, from anywhere | Any system with the key | Mount it in each new sandbox |
Tools and libraries belong in an image: they change when you decide, and every sandbox from it starts with them installed. Data a run writes and the next run reads belongs on a volume. Data that other systems also read or write belongs in a bucket.
How should the model itself find the files?
Tell it. The task message should name the input folder, the output folder and
what belongs in each: "the data is in /workspace/task, write results to
/workspace/task/out, do not change the inputs". A model that has to discover
the layout spends its first turns on ls and find, and one that does not
know where output belongs scatters it across the disk, which defeats the
download step above. An agent connected over MCP reads, writes and lists files
with runtime_sandboxes_files_read, runtime_sandboxes_files_write and
runtime_sandboxes_files_list, and can read a range of lines from a large file
rather than the whole of it.
What limits should you plan around?
A few numbers save a debugging session:
- Writes outside
/workspaceare at most 1 MiB through the files API. Write large files to/workspaceand move them with a command. - Four uploads and four downloads run at once per sandbox; more wait their turn. Upload a folder rather than a thousand files one by one.
- Downloads are checked. A read that arrives short is read again, then fails rather than handing you part of a file, and a downloaded folder lands in place only once all of it has arrived.
- A folder download refuses links that leave it, so nothing in a sandbox can write elsewhere on your machine.
In short
- Upload a task's inputs as one folder, and bring back only the files that changed since the hand-over.
- Let the sandbox fetch large public data itself, with an allow list naming only the source.
- Mount private buckets read-only with a prefix; the sandbox never holds the key.
- Use a volume for data many sandboxes read or the next run reuses, and remember that a sandbox with volumes cannot be snapshotted.
- Put tools in an image, run-to-run state on a volume, and shared data in a bucket.
Run it on Runtime
Runtime costs 42% to 88% less than fourteen other sandbox providers for an agent that mostly waits on a model (compare costs). Every Runtime sandbox has a files API with checked transfers, folder uploads as one archive, bucket mounts that keep keys out of the sandbox, and volumes that outlive it. Read the storage guide, then create an account at withruntime.com and hand your agent its first task folder.