# 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. The archive is unpacked as it arrives. Volumes that outlive sandboxes are billed at about $0.11 per GiB per 30-day month. Runtime mounts your own bucket, and the sandbox never holds 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 the result is 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. It gives each kind its own route. ## Which route fits which files? Two questions decide the route: 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 covers them in the order that an agent's task uses them: in, during, and 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. The sandbox's own `tar` unpacks it as it arrives, so a thousand small files cost about the same as one archive. A single file is sent in parallel, in 1 MiB chunks. Each chunk is checked by SHA-256, and an upload resumes after a dropped connection. Then the agent works. 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: ```ts import { 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 arrives const 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`); ``` ```python import os import tempfile import time from datetime import datetime from 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 changed the content, compare content hashes instead. The recorder in [see what your agent did](/blog/audit-what-your-agent-did) does this. For a code task, `git diff` inside the sandbox is the best option: it shows the change 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. The data arrives faster, your server stays out of the path, and nothing large is held in your process's memory on the way: ```ts import { 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()); ``` ```python from withruntime import Sandbox url = "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 that code matter. The allow list names the one host the download needs. If a model later reads instructions inside the data, it still cannot send anything to another host. 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](/how-to/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. The agent reads it with ordinary file tools. You store the bucket's key once, as a secret. The sandbox gets a placeholder. The real key is added to requests only on the way to the bucket's host. The agent can read the data, but it cannot read the key: ```ts check import { 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); ``` ```python check from withruntime import Sandbox with 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) ``` Keep `readOnly` and `prefix`. A prefix limits the agent to the data for this task. Read-only means that an agent that decides to clean up cannot delete anything in 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 it ends when the sandbox stops ([mount an S3 bucket](/how-to/mount-an-s3-bucket)). ## When is a volume the right answer? Use a volume when the same bytes are needed by many sandboxes, or by the next sandbox. A volume is a disk that outlives sandboxes. 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 stopped. - **Read-only copies, any number at the same time.** Model weights or a reference corpus that every worker reads. Copy the data in once. Then attach it to a hundred sandboxes with `mode: "snapshot"`. None of them can change it. ```ts check import { 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())); ``` ```python check from withruntime import Runtime runtime = 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 for its full size from the moment it is created, whether or not it holds data. Runtime backs it up off its server every day. A volume lives on one server, and a sandbox that uses it is placed on that server. Before you choose a volume, note this trade-off: a sandbox with volumes cannot be snapshotted. If your agent relies on snapshots to save and restore its sandbox, keep the reused data in a custom image or a bucket mount instead ([an undo button for AI agents](/blog/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 to rebuild, and every sandbox from the image starts with them installed. Data that one 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`. A model that does not know where output belongs scatters it across the disk. That 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`. It can also read a range of lines from a large file, not the whole file. ## What limits should you plan around? A few numbers save a debugging session: - **Writes outside `/workspace`** are limited to 1 MiB through the files API. Write large files to `/workspace`, then move them with a command. - **Four uploads and four downloads** run at the same time per sandbox. Extra transfers wait in line. Upload a folder rather than a thousand files one by one. - **Downloads are checked.** A read that arrives short is read again. If the retry is also short, the download fails instead of returning part of a file. A downloaded folder appears in place only after all of it has arrived. - **A folder download refuses links that point outside the folder,** so nothing in a sandbox can write anywhere else 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](/how-to/compare-your-costs)). Every Runtime sandbox has a files API with checked transfers. Folder uploads travel as one archive. Bucket mounts keep keys out of the sandbox. Volumes outlive the sandbox that uses them. [Read the storage guide](/docs/storage), then [create an account at withruntime.com](/sign-in) and hand your agent its first task folder.