# How to Give an Agent a Large File Without Filling Its Context

> Put the file in the sandbox and give the agent its path, not its bytes. Upload with sbx scp or fetch it by URL inside the sandbox, then expose small tools that return metadata, a head, a checksum or query rows. On prod, 52 MB files came back as tool results of 73 to 249 bytes.

Source: https://sandboxer.com/guides/give-an-agent-a-large-file-without-filling-its-context. Updated 2026-11-01.

**Short answer:** Put the file in the sandbox and give the agent its path, not its bytes. Upload with sbx scp or fetch it by URL inside the sandbox, then expose small tools that return metadata, a head, a checksum or query rows. On prod, 52 MB files came back as tool results of 73 to 249 bytes.

## The pattern

The model never reads the file. It reads answers about the file.

1. **The bytes go to the sandbox**, not to the model: `sbx scp` from your machine, or `curl` inside the sandbox when the file has a URL.
2. **The agent gets a path**, such as `inbox/orders.csv`, in its prompt or in a tool result.
3. **The agent calls small tools on that path**: probe, head, checksum, query, split. Each returns a few hundred bytes of JSON.
4. **Big results stay files too.** A processed file goes back as a URL or a download, not as tool output.

This page measures each step. It doesn't cover streaming input into a running process ([send input to a running command](/guides/send-input-to-a-running-command-in-a-sandbox)) or hosted URLs in depth ([give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)).

## What we ran

We tested on prod on 2026-11-01 with one `small` sandbox (2 vCPU, 4 GB) in `us-east`. Our test machine had a 32 ms TCP connect time to sandboxer.com. We made two files locally:

- `interview.wav`: 272 seconds of synthetic 48 kHz stereo audio with a 3-second gap every 45 seconds, made with ffmpeg. 52,224,078 bytes.
- `orders.csv`: 846,139 rows of synthetic orders, 7 columns. 52,000,011 bytes.

A third file came from a URL: a 49,961,641-byte Parquet file of New York City taxi trips from the [NYC TLC trip record data](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page).

No LLM key was used. We called each tool handler directly, the way an agent's tool loop would, and measured what it would return to the model.

## 1. Get the file into the sandbox

| Way in | 52 MB file | Notes |
| - | - | - |
| `sbx scp` from our machine | 1.87 s (WAV), 1.68 s (CSV) | Your upload speed sets the pace |
| `curl` inside the sandbox | 0.69 s (50 MB Parquet) | The bytes never touch your machine |
| SDK `files.write`, whole file | Too big | The files API takes up to 5 MiB per file |
| SDK `files.write` in 4 MiB parts, joined with `cat` | 2.4 s for 13 parts | Works from code when you can't run `scp` |

`sbx scp`, then a checksum to prove the copy:

```bash
id=sb_wo9x0s9c5c
sbx exec $id "mkdir -p ~/inbox"
time sbx scp ./interview.wav $id:/home/user/inbox/
# real    0m1.871s
time sbx scp ./orders.csv $id:/home/user/inbox/
# real    0m1.680s
sbx exec $id "sha256sum ~/inbox/*"
# e4b2d1cd3983ebe47f929e7af59b97480df165a776f0dccdf3294f2606a6736e  /home/user/inbox/interview.wav
# 274209ff95f2896061a7b8881087fcb44111441c1682d835de8809b59160a1a2  /home/user/inbox/orders.csv
```

Both hashes matched the local files. When the file already has a URL, skip your machine entirely:

```bash
sbx exec $id --timeout 120 "cd ~/inbox && curl -sS -o trips.parquet \
  -w 'HTTP %{http_code} %{size_download} bytes in %{time_total} s\n' \
  https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet"
# HTTP 200 49961641 bytes in 0.690292 s
```

From code without `scp`, the Python SDK (`pip install sandboxer-sdk`) can send a large file in parts. The files API takes up to 5 MiB per file, so write 4 MiB parts and join them:

```python
import hashlib, time
from sandboxer import Sandboxer

sandbox = Sandboxer.get("sb_wo9x0s9c5c")
data = open("orders.csv", "rb").read()

t = time.time()
CHUNK = 4 * 1024 * 1024
parts = []
for n, off in enumerate(range(0, len(data), CHUNK)):
    name = f"sdk/parts/{n:03d}"
    sandbox.files.write(name, data[off:off + CHUNK])
    parts.append(name)
run = sandbox.exec("cat sdk/parts/* > sdk/orders.csv && rm -r sdk/parts && sha256sum sdk/orders.csv")
print(f"{len(parts)} parts in {time.time() - t:.1f} s")
print(run.stdout.strip())
```

```text
13 parts in 2.4 s
274209ff95f2896061a7b8881087fcb44111441c1682d835de8809b59160a1a2  sdk/orders.csv
```

The joined file's hash matched.

## 2. Write tools that return answers, not bytes

Put one small script in the sandbox. Each subcommand prints compact JSON. We copied it in with `sbx scp ftools.py $id:/home/user/` and ran `python3 -m venv ~/v && ~/v/bin/pip install duckdb` (3.3 s). FFmpeg and ffprobe are already in the image.

```python
#!/home/user/v/bin/python
"""Small tools an agent calls on big files. Each prints compact JSON, never the file's bytes."""
import hashlib, json, os, re, subprocess, sys

def file_info(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for block in iter(lambda: f.read(1 << 20), b""):
            h.update(block)
    kind = subprocess.run(["file", "-b", path], capture_output=True, text=True).stdout.strip()
    return {"path": path, "bytes": os.path.getsize(path), "sha256": h.hexdigest(), "type": kind[:80]}

def audio_probe(path):
    out = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
        "format=duration,bit_rate:stream=codec_name,sample_rate,channels",
        "-of", "json", path], capture_output=True, text=True, check=True).stdout
    j = json.loads(out); s = j["streams"][0]
    return {"duration_s": round(float(j["format"]["duration"]), 1), "codec": s["codec_name"],
            "sample_rate": int(s["sample_rate"]), "channels": s["channels"]}

def audio_segments(path, outdir):
    # Find silences, cut at their midpoints, write 16 kHz mono WAVs a transcriber can take one at a time.
    log = subprocess.run(["ffmpeg", "-hide_banner", "-i", path, "-af", "silencedetect=n=-40dB:d=1",
        "-f", "null", "-"], capture_output=True, text=True).stderr
    starts = [float(x) for x in re.findall(r"silence_start: ([\d.]+)", log)]
    ends = [float(x) for x in re.findall(r"silence_end: ([\d.]+)", log)]
    cuts = [round((a + b) / 2, 2) for a, b in zip(starts, ends)]
    os.makedirs(outdir, exist_ok=True)
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", path, "-ac", "1", "-ar", "16000",
        "-f", "segment", "-segment_times", ",".join(map(str, cuts)) or "99999",
        f"{outdir}/seg_%03d.wav"], check=True)
    segs = sorted(os.listdir(outdir))
    return {"segments": len(segs), "dir": outdir, "first": segs[0], "last": segs[-1],
            "bytes_total": sum(os.path.getsize(f"{outdir}/{s}") for s in segs)}

def csv_head(path, n=5):
    with open(path) as f:
        return {"lines": [next(f).rstrip("\n") for _ in range(int(n))]}

def sql(query, limit=20):
    import duckdb
    rel = duckdb.sql(query)
    rows = rel.limit(int(limit)).fetchall()
    return {"columns": rel.columns, "rows": [list(map(str, r)) for r in rows]}

if __name__ == "__main__":
    tool, *args = sys.argv[1:]
    print(json.dumps(globals()[tool](*args), separators=(",", ":")))
```

The `limit` in `sql` matters: a model that writes `SELECT *` still gets at most 20 rows.

## 3. What the agent gets back

We ran each tool through a wrapper that prints the output, its size and its run time inside the sandbox:

```bash
#!/bin/bash
# measure.sh <tool> <args...>
cd ~
s=$EPOCHREALTIME
out=$(./ftools.py "$@")
e=$EPOCHREALTIME
echo "$out"
printf '[%d bytes, %.2f s]\n' "$(printf '%s' "$out" | wc -c)" "$(awk "BEGIN{print $e - $s}")"
```

```bash
sbx exec $id --timeout 120 "~/measure.sh audio_probe inbox/interview.wav"
```

```text
$ file_info inbox/interview.wav
{"path":"inbox/interview.wav","bytes":52224078,"sha256":"e4b2d1cd…6736e","type":"RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, stereo 48000 Hz"}
[210 bytes, 0.10 s]
$ audio_probe inbox/interview.wav
{"duration_s":272.0,"codec":"pcm_s16le","sample_rate":48000,"channels":2}
[73 bytes, 0.12 s]
$ audio_segments inbox/interview.wav work/segments
{"segments":7,"dir":"work/segments","first":"seg_000.wav","last":"seg_006.wav","bytes_total":8704546}
[101 bytes, 1.12 s]
$ csv_head inbox/orders.csv 4
{"lines":["order_id,created_at,region,sku,qty,unit_price_cents,customer_id","1,2026-09-11T04:25:00Z,us-east,SKU-0037,9,6367,C095864",…]}
[249 bytes, 0.05 s]
```

The agent-side handler is a few lines with the Python SDK. Passing a list to `exec` quotes each argument, so a model's SQL can't break the shell command:

```python
import json
from sandboxer import Sandboxer

sandbox = Sandboxer.get("sb_wo9x0s9c5c")

def call(tool, *args):
    """What the agent's tool handler does: run one small tool on the file in the sandbox."""
    run = sandbox.exec(["/home/user/ftools.py", tool, *map(str, args)], timeout_seconds=120)
    if run.exit_code != 0:
        return {"error": run.stderr[-500:]}          # keep errors small too
    return json.loads(run.stdout)

call("sql", "SELECT region, count(*) AS orders, round(sum(qty * unit_price_cents) / 100) AS revenue_usd "
            "FROM 'inbox/orders.csv' GROUP BY region ORDER BY revenue_usd DESC")
call("sql", "SELECT sku, sum(qty) AS units FROM 'inbox/orders.csv' GROUP BY sku ORDER BY units DESC", 3)
call("sql", "SELECT count(*) AS trips, round(avg(trip_distance), 2) AS avg_miles, "
            "round(avg(total_amount), 2) AS avg_total FROM 'inbox/trips.parquet'")
```

```text
{"columns":["region","orders","revenue_usd"],"rows":[["ap-south","169590","212815376.0"],["us-east","168990","212597864.0"],…]}
[233 bytes, 0.31 s round trip]
{"columns":["sku","units"],"rows":[["SKU-0248","11354"],["SKU-0338","11305"],["SKU-0165","11260"]]}
[99 bytes, 0.28 s round trip]
{"columns":["trips","avg_miles","avg_total"],"rows":[["2964624","3.65","26.8"]]}
[80 bytes, 0.17 s round trip]
```

Round trips are from our machine and include the API call.

| Tool call | File size | Result size | Share of the file |
| - | - | - | - |
| `audio_probe` | 52,224,078 B | 73 B | 0.00014% |
| `audio_segments` | 52,224,078 B | 101 B | 0.00019% |
| `file_info` (sha256) | 52,224,078 B | 210 B | 0.0004% |
| `csv_head` 4 lines | 52,000,011 B | 249 B | 0.0005% |
| `sql` group by region | 52,000,011 B | 233 B | 0.00045% |
| `sql` over Parquet | 49,961,641 B | 80 B | 0.00016% |

## 4. Long steps: detach and poll

Transcribing seven segments takes longer than one `exec` should wait. Start the loop detached and give the agent a status tool. Our stub writes one JSON line per segment; the `text` field is where your transcriber's output goes (for timings with a real model, see [transcribe audio with whisper.cpp](/guides/transcribe-audio-with-whisper-cpp-in-a-sandbox)):

```bash
#!/bin/bash
# transcribe_all.sh <segments-dir> <out.jsonl>: one line per segment.
# Stub: swap the "text" line for your transcriber (for example whisper-cli -f "$f").
cd ~
: > "$2"
for f in "$1"/seg_*.wav; do
  dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
  vol=$(ffmpeg -hide_banner -i "$f" -af volumedetect -f null - 2>&1 | sed -n 's/.*mean_volume: \(.*\) dB/\1/p')
  text=null
  printf '{"segment":"%s","duration_s":%.1f,"mean_db":%s,"text":%s}\n' "$(basename "$f")" "$dur" "$vol" "$text" >> "$2"
done
echo "done: $(wc -l < "$2") segments"
```

```bash
sbx exec $id --detach "~/transcribe_all.sh work/segments work/transcript.jsonl"
# {"ok":true,"type":"command.started",…,"processId":1025,…}
sbx exec $id --status 1025
# {…,"exitCode":0,…,"status":"exited",…,"stdout":"done: 7 segments\n",…}
sbx exec $id "head -3 ~/work/transcript.jsonl; wc -c ~/work/transcript.jsonl"
```

```text
{"segment":"seg_000.wav","duration_s":43.5,"mean_db":-17.1,"text":null}
{"segment":"seg_001.wav","duration_s":45.0,"mean_db":-17.3,"text":null}
{"segment":"seg_002.wav","duration_s":45.0,"mean_db":-17.3,"text":null}
503 /home/user/work/transcript.jsonl
```

The stub finished in 0.9 seconds. The agent reads the transcript a few lines at a time, or runs a search tool over it, the same way it reads the CSV.

## 5. Return a derived file as a URL or a download

A 16 kHz mono Opus copy of the interview took 1.9 seconds and came out at 995,516 bytes. The agent returns where it is, not what's in it:

```bash
sbx exec $id --timeout 120 "cd ~ && mkdir -p out && ffmpeg -v error -y -i inbox/interview.wav \
  -ac 1 -ar 16000 -c:a libopus -b:a 24k out/interview-16k.opus"
sbx exec $id --detach "cd ~/out && python3 -m http.server 8000"
sbx host $id 8000
# {…,"url":"https://fresh-agile-dove-8000.box.sandboxer.com?_token=…",…}
```

The URL is private by default. Without the token, and with it:

```bash
curl -sS -o /dev/null -w "HTTP %{http_code} %{size_download} bytes\n" https://fresh-agile-dove-8000.box.sandboxer.com/interview-16k.opus
# HTTP 401 583 bytes
curl -sSL -c jar -b jar -o interview-16k.opus -w "HTTP %{http_code} %{size_download} bytes in %{time_total} s\n" "$url_with_token"
# HTTP 200 995516 bytes in 1.114867 s
```

The first tokened request answers `302` and sets a cookie, so use `curl -L` with a cookie jar. Treat the tokened URL like a password. Your code can also pull the file directly: `sandbox.files.download("out/interview-16k.opus")` returned all 995,516 bytes in 0.45 seconds (up to 50 MiB), and `sbx scp $id:/home/user/out/interview-16k.opus .` took 1.4 seconds. All three copies were byte-identical.

## 6. The same tools through MCP

If your agent uses the [Sandboxer MCP server](/docs/mcp), you don't need custom tool definitions. Its `exec` tool runs the same script on the path. These are real calls to the local `sbx mcp` server over stdio:

```json
{"name": "exec", "arguments": {"sandboxId": "sb_wo9x0s9c5c", "command": "~/ftools.py audio_probe inbox/interview.wav"}}
→ "Exit code 0.\n\nstdout:\n{\"duration_s\":272.0,\"codec\":\"pcm_s16le\",\"sample_rate\":48000,\"channels\":2}"
  (whole result with structuredContent: 336 bytes, 0.22 s)

{"name": "exec", "arguments": {"sandboxId": "sb_wo9x0s9c5c", "command": "~/ftools.py sql \"SELECT region, count(*) AS orders FROM 'inbox/orders.csv' GROUP BY 1 ORDER BY 2 DESC\" 2"}}
→ "Exit code 0.\n\nstdout:\n{\"columns\":[\"region\",\"orders\"],\"rows\":[[\"ap-south\",\"169590\"],[\"eu-west\",\"169341\"]]}"
  (368 bytes, 0.27 s)

{"name": "read_file", "arguments": {"sandboxId": "sb_wo9x0s9c5c", "path": "inbox/orders.csv"}}
→ isError: "Files over 5 MiB: use GET /sandboxes/{sandboxId}/artifacts (50 MiB) or scp. (file_too_large)"
```

`read_file` refusing the 52 MB file is the safety net working: the MCP server won't put it into the model's context. Tell the agent in its prompt to use `exec` on paths, and use `detached: true` with `command_status` for anything that runs past the timeout.

## Limits to plan around

| Limit | Value | What we saw |
| - | - | - |
| `sbx scp` | Your disk size | 52 MB in under 2 s |
| SDK / files API / MCP `read_file`, `write_file` | Up to 5 MiB per file | 4 MiB parts worked |
| SDK `files.download` | 50 MiB | 995,516 bytes in 0.45 s |
| `sbx exec` wait | 30 s default, 1–600 s with `--timeout` | Use `--detach` past that |
| MCP `exec` wait | 120 s default, at most 600 s | Use `detached: true` past that |
| Command output kept | 8 MiB per stream for waiting commands | Return paths or rows, not whole files |

Never let a tool print a whole file: return a path, a count or a few rows.

## FAQ

### Can't I just paste a large CSV into the prompt?

Not usefully. 52 MB of CSV is far more text than a context window holds, and even a slice crowds out the instructions. A `sql` tool returned the answer to a group-by over 846,139 rows in 233 bytes.

### How do I get a big file into a sandbox quickly?

If it has a URL, download it inside the sandbox: 50 MB took 0.69 seconds. From your machine, `sbx scp` moved 52 MB in under 2 seconds from ours; your upload speed sets the pace.

### Can the SDK upload a 50 MB file?

Not in one call: the files API takes up to 5 MiB per file. Writing 4 MiB parts and joining them with `cat` in the sandbox took 2.4 seconds for 52 MB, and the checksum matched. For anything large, `sbx scp` is simpler.

### Does the file survive if the sandbox stops?

Yes. Files under `/home/user` are in the snapshot that `sbx stop` writes, and `sbx resume` brings them back, so the agent can pick up the same path later.

### How does the agent send a processed file back to a user?

Host the folder and return the URL, or have your code call `files.download` (up to 50 MiB) or `sbx scp`. In our run a private hosted URL answered 401 without its token and served the 995,516-byte file with it.

## Related

- [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox)
- [Build a data-analysis agent with a sandbox](/guides/build-a-data-analysis-agent-with-a-sandbox)
- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [MCP server](/docs/mcp)
