# Build a Data-Analysis Agent That Runs Pandas in a Sandbox

> Give the agent one tool that runs Python in a Sandboxer microVM. Upload the CSV with the Python SDK, install pandas and matplotlib once per sandbox, let the tool run the model's code, then pull each chart PNG back with files.read_bytes and return the printed table. Reuse the sandbox for follow-up questions.

Source: https://sandboxer.com/guides/build-a-data-analysis-agent-with-a-sandbox. Updated 2026-10-27.

**Short answer:** Give the agent one tool that runs Python in a Sandboxer microVM. Upload the CSV with the Python SDK, install pandas and matplotlib once per sandbox, let the tool run the model's code, then pull each chart PNG back with files.read_bytes and return the printed table. Reuse the sandbox for follow-up questions.

## At a glance

| Step | Call | Measured in our run (single run, October 27, 2026) |
| - | - | - |
| Create a `small` sandbox (2 vCPU, 4 GB) | `client.create(type="small", ttl_seconds=1800)` | Part of an 11.5 s end-to-end script |
| Install pandas and matplotlib, once | `uv pip install` into `~/.venv` with `sandbox.exec` | 1.1 s |
| Upload the CSV (82 KB) | `sandbox.files.write("data/sales.csv", bytes)` | |
| First question: table and chart | the `run_analysis` tool | 5.5 s, chart 88,207 bytes, 960 x 540 |
| Follow-up question, same sandbox | the `run_analysis` tool again | 1.3 s, no reinstall |
| New sandbox with the stack already installed | `client.create(from_="<named snapshot>")` | Ready in 2.6 s |

## What we ran, and what we didn't

We have no LLM key on the test account, so no model wrote the analysis code here. We wrote the code a model would send for "Which region grew the most? Show me a chart" and called the tool function directly. Everything after that is real: the CSV upload, the install, the pandas run in a microVM on production, the PNG download and the cleanup. Versions: `sandboxer-sdk` 0.1.1 on your side; pandas 3.1.0 and matplotlib 3.11.2 inside the sandbox. To use a real model, register `run_analysis` as a tool in your framework (its docstring is the description) and pass the model's `code` argument through.

## 1. Install and make a dataset

```bash
python3 -m venv .venv && . .venv/bin/activate
pip install sandboxer-sdk
export SANDBOXER_API_KEY=sbx_live_…
```

Install `sandboxer-sdk`, not `sandboxer`: that PyPI name belongs to an unrelated project. Your machine doesn't need pandas. Only the sandbox does.

We generated nine months of synthetic orders for four regions with this script (`make_data.py`):

```python
import csv, random
from datetime import date, timedelta

random.seed(7)
regions = {"North": 1.00, "South": 0.85, "East": 1.10, "West": 0.95}
growth = {"North": 0.010, "South": 0.035, "East": 0.005, "West": 0.020}  # monthly
products = {"Starter": 19.0, "Team": 49.0, "Business": 129.0}
with open("sales.csv", "w", newline="") as f:
    w = csv.writer(f)
    w.writerow(["order_id", "date", "region", "product", "units", "unit_price"])
    oid = 10000
    d = date(2026, 1, 1)
    while d <= date(2026, 9, 30):
        m = d.month - 1
        for region, base in regions.items():
            for _ in range(random.randint(1, 3)):
                product = random.choices(list(products), weights=[5, 3, 1])[0]
                units = max(1, int(random.gauss(4, 2) * base * (1 + growth[region]) ** m))
                oid += 1
                w.writerow([oid, d.isoformat(), region, product, units, products[product]])
        d += timedelta(days=1)
```

```text
$ python3 make_data.py && wc -l sales.csv && head -3 sales.csv
2187 sales.csv
order_id,date,region,product,units,unit_price
10001,2026-01-01,North,Business,3,129.0
10002,2026-01-01,North,Team,4,49.0
```

## 2. The workspace and the tool

Save this as `analyst.py`.

```python
import json
import struct
import sys
import time
from pathlib import Path

from sandboxer import Sandboxer

client = Sandboxer()  # reads SANDBOXER_API_KEY
PY = "/home/user/.venv/bin/python"  # the sandbox's analysis venv


def open_workspace(name: str):
    """Create a sandbox and install the analysis stack once."""
    sandbox = client.create(type="small", ttl_seconds=1800, name=name)
    t = time.time()
    sandbox.exec(
        "uv venv -q ~/.venv && uv pip install -q --python ~/.venv pandas matplotlib",
        timeout_seconds=300,
        check=True,
    )
    print(f"installed pandas + matplotlib in {time.time() - t:.1f}s")
    return sandbox


def png_size(data: bytes):
    """Width and height from a PNG header."""
    return struct.unpack(">II", data[16:24])


def run_analysis(sandbox, code: str) -> dict:
    """Tool for the agent: run Python (pandas, matplotlib) in the sandbox.
    The dataset is at data/sales.csv. Save charts to out/*.png.
    Returns stdout, stderr, exit code and the charts it saved."""
    sandbox.exec("mkdir -p out && rm -f out/*.png")
    sandbox.files.write("analysis.py", code)
    r = sandbox.exec(f"MPLBACKEND=Agg {PY} analysis.py", timeout_seconds=120)
    charts = []
    listing = sandbox.exec("ls out/*.png 2>/dev/null").stdout.split()
    for path in listing:
        data = sandbox.files.read_bytes(path)  # up to 5 MiB
        Path(path).parent.mkdir(exist_ok=True)
        Path(path).write_bytes(data)
        w, h = png_size(data)
        charts.append({"path": path, "bytes": len(data), "width": w, "height": h})
    return {
        "exit_code": r.exit_code,
        "stdout": r.stdout[-4000:],  # keep the model's context small
        "stderr": r.stderr[-2000:],
        "charts": charts,
    }


# The code a model would write for "Which region grew the most, and show me a chart".
ANALYSIS = r'''
import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("data/sales.csv", parse_dates=["date"])
df["revenue"] = df["units"] * df["unit_price"]
monthly = (df.groupby([df["date"].dt.to_period("M"), "region"])["revenue"]
             .sum().unstack())
q1 = monthly.loc["2026-01":"2026-03"].sum()
q3 = monthly.loc["2026-07":"2026-09"].sum()
summary = pd.DataFrame({
    "orders": df.groupby("region").size(),
    "revenue": df.groupby("region")["revenue"].sum().round(0).astype(int),
    "q1_rev": q1.round(0).astype(int),
    "q3_rev": q3.round(0).astype(int),
    "q3_vs_q1": ((q3 / q1 - 1) * 100).round(1).astype(str) + "%",
}).sort_values("q3_rev", ascending=False)
print(f"{len(df)} orders, {df['date'].min():%Y-%m-%d} to {df['date'].max():%Y-%m-%d}")
print(summary.to_string())

ax = monthly.plot(figsize=(8, 4.5), marker="o")
ax.set_title("Monthly revenue by region, 2026")
ax.set_xlabel("")
ax.set_ylabel("Revenue (USD)")
plt.tight_layout()
plt.savefig("out/revenue_by_region.png", dpi=120)
'''

if __name__ == "__main__":
    sandbox = open_workspace(sys.argv[1] if len(sys.argv) > 1 else "aeo-data-1")
    print("sandbox", sandbox.id)
    try:
        sandbox.exec("mkdir -p data")
        sandbox.files.write("data/sales.csv", Path("sales.csv").read_bytes())
        print("uploaded", sandbox.exec("wc -c < data/sales.csv").stdout.strip(), "bytes")

        t = time.time()
        result = run_analysis(sandbox, ANALYSIS)
        print(f"--- call 1 ({time.time() - t:.1f}s)")
        print(json.dumps(result, indent=2))

        # A follow-up question reuses the same sandbox: no reinstall, the CSV is still there.
        t = time.time()
        result = run_analysis(sandbox, r'''
import pandas as pd
df = pd.read_csv("data/sales.csv")
df["revenue"] = df["units"] * df["unit_price"]
print(df.pivot_table(index="region", columns="product", values="revenue", aggfunc="sum").round(0).astype(int).to_string())
''')
        print(f"--- call 2 ({time.time() - t:.1f}s)")
        print(json.dumps(result, indent=2))
    finally:
        sandbox.delete()
        print("deleted", sandbox.id)
```

A few choices in this tool matter for agents:

- **One argument for the model.** The model only writes `code`. Your app owns the sandbox handle, so the model can't point the tool at someone else's sandbox.
- **A venv, not `pip install --user`.** The sandbox is Ubuntu with `python3`, `pip` and `uv` pre-installed. Installing into `~/.venv` keeps the analysis stack apart from system Python, and `uv` makes the install fast.
- **`MPLBACKEND=Agg`.** There's no display, so matplotlib renders straight to PNG.
- **A fixed output folder.** The tool clears `out/` before each run and returns every PNG found there, so the model only has to save to `out/`.
- **Trimmed output.** A `print(df)` on a big frame can flood the model's context. The tool keeps the last 4,000 characters of stdout.

## 3. Run it

```bash
python3 analyst.py
```

```text
installed pandas + matplotlib in 1.1s
sandbox sb_2nfdglgxhl
uploaded 82402 bytes
--- call 1 (5.5s)
{
  "exit_code": 0,
  "stdout": "2186 orders, 2026-01-01 to 2026-09-30\n        orders  revenue  q1_rev  q3_rev q3_vs_q1\nregion                                          \nWest       558    87224   24631   34157    38.7%\nEast       575    94246   28690   33031    15.1%\nSouth      523    73808   20425   27146    32.9%\nNorth      530    82896   27228   26698    -1.9%\n",
  "stderr": "",
  "charts": [
    {
      "path": "out/revenue_by_region.png",
      "bytes": 88207,
      "width": 960,
      "height": 540
    }
  ]
}
--- call 2 (1.3s)
{
  "exit_code": 0,
  "stdout": "product  Business  Starter   Team\nregion                           \nEast        28251    24149  41846\nNorth       30960    21850  30086\nSouth       24252    20254  29302\nWest        29670    21147  36407\n",
  "stderr": "",
  "charts": []
}
deleted sb_2nfdglgxhl
```

The whole script took 11.5 seconds in this single run (October 27, 2026), including creating and deleting the sandbox. The summary table the agent gets back:

| region | orders | revenue | q1_rev | q3_rev | q3_vs_q1 |
| - | - | - | - | - | - |
| West | 558 | 87224 | 24631 | 34157 | 38.7% |
| East | 575 | 94246 | 28690 | 33031 | 15.1% |
| South | 523 | 73808 | 20425 | 27146 | 32.9% |
| North | 530 | 82896 | 27228 | 26698 | -1.9% |

So the answer to "which region grew the most" is West, up 38.7% from Q1 to Q3. The chart is now a local file, `out/revenue_by_region.png` (88,207 bytes, 960 x 540), ready to show in your UI or attach to a chat message. The follow-up question ran in 1.3 seconds because the packages and the CSV were already in the sandbox.

## Install once, then reuse

Installing per question wastes time and invites version drift. There are two ways to install only once.

**Keep one sandbox per conversation.** Create it on the first question and keep the handle (or store `sandbox.id` and reopen it with `client.get(sandbox_id)`). The second call above reused the packages and the uploaded CSV. Between conversations, `sandbox.stop()` ends compute billing, and `resume()` brings back the disk and, on a machine with the same CPU type, the memory. If none is free within 2 minutes, the resume boots from disk: files are kept, running processes are not (see [snapshots](/docs/snapshots#resume)). The `ttl_seconds=1800` on create stops the sandbox even if your process crashes.

**Save the stack as a named snapshot.** Install once, save the disk under a name, and create every new sandbox from it. We ran this on production:

```python
# sandbox: a sandbox where the uv install above already ran
t = time.time()
snap = sandbox.snapshot("aeo-data-stack")
print(f"snapshot {snap.name} saved in {time.time() - t:.1f}s")

t = time.time()
fresh = client.create(type="small", ttl_seconds=1800, name="aeo-data-3", from_="aeo-data-stack")
print(f"sandbox {fresh.id} from template, ready in {time.time() - t:.1f}s")
r = fresh.exec("~/.venv/bin/python -c 'import pandas, matplotlib; print(pandas.__version__, matplotlib.__version__)'")
print("no install needed:", r.exit_code, r.stdout.strip())
```

```text
snapshot aeo-data-stack saved in 2.1s
sandbox sb_c687tbxo3f from template, ready in 2.6s
no install needed: 0 3.1.0 3.11.2
```

The new sandbox imported pandas without installing anything. Named snapshots have no separate charge, but what they store counts toward your plan's included storage. Remove one with `sbx snapshot rm aeo-data-stack`. See [snapshots](/docs/snapshots) and [turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template).

## File size limits

| Call | Limit | Use it for |
| - | - | - |
| `files.write` | 5 MiB per file | The CSV or a small dataset, generated code |
| `files.read` / `files.read_bytes` | 5 MiB per file | Charts, summary JSON, small result files |
| `files.download` | 50 MiB per file | Larger outputs such as a Parquet file or a zip of charts |
| `sbx scp` | No cap from Sandboxer | Datasets bigger than 5 MiB, in either direction |
| `exec` output | 8 MiB per stream kept per command | What `print` returns; trim it before it reaches the model |

Above 5 MiB, `read_bytes` fails with `413 file_too_large` and the SDK raises. We checked with a 6 MiB file:

```python
sandbox.exec("mkdir -p out && head -c 6291456 /dev/urandom > out/big.parquet")
try:
    sandbox.files.read_bytes("out/big.parquet")
except SandboxerError as e:
    print("read_bytes:", e.status, e.code, "-", e.message)
data = sandbox.files.download("out/big.parquet")
print("download:", len(data), "bytes")
```

```text
read_bytes: 413 file_too_large - Files over 5 MiB: use GET /sandboxes/{sandboxId}/artifacts (50 MiB) or scp.
download: 6291456 bytes
```

For a dataset over 5 MiB, upload it with `sbx scp ./sales.csv <id>:/home/user/data/`, or have the sandbox fetch it itself with `curl` or `git clone`. Paths for the files API must be under `/home/user` or `/tmp`; relative paths start at `/home/user`. A typical chart PNG is well under the 5 MiB read limit; ours was 86 KiB.

## FAQ

### Can the model see the chart, not only the table?

Yes, if your model accepts images. The tool returns the PNG bytes on your side, so you can attach them to the next model message. Through the [MCP server](/docs/mcp), the agent can read the file itself with `read_file`, which takes an `encoding` option and returns up to 5 MiB.

### Why run pandas in a sandbox instead of in my app?

The model writes the code, and it can be wrong or unsafe: it can read files, open network connections or loop forever. In a sandbox it runs in its own microVM with its own disk, and `timeout_seconds` on each `exec` (1 to 600 seconds) stops a runaway script without hanging your app.

### What size sandbox does a data agent need?

We used `small` (2 vCPU, 4 GB of RAM), which handled this dataset easily. For data frames in the gigabytes, pick a bigger size; see [machines](/docs/machines) for the sizes.

### What does one analysis session cost?

Billing is per second while the sandbox runs. A `default` sandbox is $0.036 an hour, and `small` is half that. Our 11.5-second run cost a fraction of a cent. A stopped sandbox costs nothing for compute; its disk counts toward your plan's [included storage](/docs/billing#storage).

## Related

- [Python SDK](/docs/sdk-python)
- [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox)
- [Give a LangGraph agent a Python sandbox](/guides/give-a-langgraph-agent-a-python-sandbox)
- [Turn a sandbox into a reusable template](/guides/turn-a-sandbox-into-a-reusable-template)
