In the previous article we gave an AI agent a safe place to run Python. That answered where agent-written code executes. This follow-up answers a question that turns out to matter just as much: what data does the model actually need to see?

Usually the answer is much less than we give it.

The naive pattern

The obvious way to wire an agent to a business system goes like this:

  1. A tool queries the system and returns the result set as JSON.
  2. The rows land in the model’s context.
  3. The model reads them and answers: “the top five are…”, “the average is…”.

This works in demos with twenty rows. With real data it breaks in three ways:

  • Security. Every row the tool returns is now part of the transcript. Transcripts get stored, logged, replayed into later turns, sent to the model provider, picked up by telemetry and sometimes kept for evaluation. Data that never needed to leave your system has now been copied to every one of those places.
  • Cost and capacity. A row with ten fields is roughly 40 to 60 tokens. Five thousand rows is a few hundred thousand tokens: most or all of the context window, paid for on every turn that carries it.
  • Correctness. LLMs don’t calculate. They estimate. Ask a model to sum 5,000 numbers or rank groups by quarter-over-quarter change and you get a confident answer that is close but wrong. The same analysis is three lines of pandas and comes out exact.

So the model ends up doing what it is worst at (arithmetic over bulk data) using the resource it has least of (context), and it spreads the data around while doing it.

The pattern: data stays put, the model gets a handle

Turn it around. Tools that produce data don’t return the data. They store it server-side as an artifact and return a reference plus just enough description for the model to write code against it:

+--------------------------- MCP server ----------------------------+
|                                                                   |
|  fetch_*  ---> [ artifact store ] <---> run_python (sandbox)      |
|                   ^        ^                 |                    |
|                   |        +---- outputs ----+                    |
|                   +---- render_chart / export                     |
|                                                                   |
+------------------------------|------------------------------------+
                               | only: dataRef, schema, row count,
                               |       tiny sample, small results
                               v
                       +---------------+
                       |  LLM / agent  |
                       +---------------+

The model’s job changes from “read the data and answer” to “understand the data’s shape and write the code that answers”. It is good at that job.

Three kinds of tools use the store:

  • Producers (fetch_*) run the query under the caller’s permissions, store the result and return a description.
  • The compute tool (run_python) takes references as inputs. They show up inside the sandbox as DataFrames. Any DataFrames the script saves become new artifacts, and only a small JSON result goes back to the model.
  • Consumers (render_chart, export_csv) take a reference and turn it into something a human looks at. The rows go from the store to the user’s screen without passing through the model.

A minimal implementation

The examples use FastMCP and pandas, as in the previous article. They are deliberately small.

The store

import secrets
import time
from dataclasses import dataclass, field

import pandas as pd


@dataclass
class Artifact:
    owner: str
    df: pd.DataFrame
    created: float = field(default_factory=time.monotonic)


class ArtifactStore:
    def __init__(self, ttl_seconds: int = 24 * 3600, max_bytes: int = 50_000_000):
        self._items: dict[str, Artifact] = {}
        self._ttl = ttl_seconds
        self._max_bytes = max_bytes

    def put(self, owner: str, df: pd.DataFrame) -> str:
        if df.memory_usage(deep=True).sum() > self._max_bytes:
            raise ValueError("artifact exceeds size cap - narrow the query")
        ref = "data_" + secrets.token_urlsafe(12)
        self._items[ref] = Artifact(owner, df)
        return ref

    def get(self, owner: str, ref: str) -> pd.DataFrame:
        now = time.monotonic()
        self._items = {k: a for k, a in self._items.items() if now - a.created < self._ttl}
        artifact = self._items.get(ref)
        if artifact is None or artifact.owner != owner:
            raise KeyError(f"unknown or expired dataRef {ref} - fetch the data again")
        return artifact.df

Two details matter here. References are random, not sequential, and every read checks the owner. A reference that turns up in someone else’s transcript is useless to them. The error message also tells the agent how to recover.

Describe, don’t dump

import json


def describe(ref: str, df: pd.DataFrame, sample_rows: int = 5) -> dict:
    return {
        "dataRef": ref,
        "rows": len(df),
        "columns": {c: str(t) for c, t in df.dtypes.items()},
        "nulls": {c: int(n) for c, n in df.isna().sum().items() if n},
        "sample": json.loads(df.head(sample_rows).to_json(orient="records", date_format="iso")),
    }

Column names, types, null counts and a handful of rows are what a person needs to write a groupby. The model needs the same.

Producer and compute tools

from fastmcp import FastMCP, Context

mcp = FastMCP("data-sandbox")
store = ArtifactStore()


def owner_of(ctx: Context) -> str:
    ...  # resolve the authenticated caller from your auth layer


@mcp.tool
async def fetch_sales(since: str, ctx: Context) -> dict:
    """Load sales since a date into a server-side artifact.
    Returns a dataRef with schema and a small sample, NOT the rows.
    Analyse the data with run_python."""
    df = await query_sales(since=since)  # your system of record, under the caller's permissions
    return describe(store.put(owner_of(ctx), df), df)


@mcp.tool
async def run_python(code: str, inputs: dict[str, str], ctx: Context) -> dict:
    """Run Python in the sandbox. `inputs` maps variable names to dataRefs;
    each arrives as a pandas DataFrame. Store DataFrames in `outputs[name]`
    to keep them as new artifacts. Assign a SMALL JSON value to `result`."""
    owner = owner_of(ctx)
    with tempfile.TemporaryDirectory() as run:
        in_dir, out_dir = Path(run, "in"), Path(run, "out")
        in_dir.mkdir()
        out_dir.mkdir()
        for name, ref in inputs.items():
            if not name.isidentifier():
                raise ValueError(f"input name {name!r} must be a Python identifier")
            store.get(owner, ref).to_parquet(in_dir / f"{name}.parquet")

        res = await execute_sandboxed(code, in_dir, out_dir)  # the sandbox from part one

        new = {p.stem: pd.read_parquet(p) for p in out_dir.glob("*.parquet")}
        outputs = {name: describe(store.put(owner, df), df, sample_rows=3) for name, df in new.items()}

    return {"ok": res.ok, "result": cap_json(res.result), "stdout": truncate(res.stdout),
            "error": res.error, "outputs": outputs}

Inside the sandbox, a short prelude loads each in/*.parquet into a global variable and creates an empty outputs dict. An epilogue writes outputs to out/ and serialises result. The sandbox still has no network and can only see its own run directory, so the data can’t leave through the script either. (If your runtime has no pyarrow, CSV works too. You just lose the column types.)

What the transcript looks like

user:  Which five product lines lost the most revenue quarter over quarter?

agent -> fetch_sales(since="2025-01-01")
      <- {dataRef: "data_x7Kq...", rows: 48210, columns: {...}, sample: [5 rows]}

agent -> run_python(inputs={"sales": "data_x7Kq..."}, code="""
           q = sales.assign(q=sales.date.dt.to_period("Q"))
           pivot = q.pivot_table(index="line", columns="q", values="revenue", aggfunc="sum")
           delta = (pivot.iloc[:, -1] - pivot.iloc[:, -2]).sort_values()
           outputs["qoq"] = delta.rename("delta").reset_index()
           result = delta.head(5).round(2).to_dict()
         """)
      <- {result: {"Line A": -182340.5, ...}, outputs: {"qoq": {dataRef: "data_Pm2...", rows: 312}}}

agent: The five biggest drops were ...

... several turns later ...

user:  Chart all of them.
agent -> render_chart(dataRef="data_Pm2...", x="line", y="delta", kind="bar")

48,210 rows were involved. About two kilobytes reached the transcript. The answer is exact because pandas computed it, not the model.

Artifacts across steps and turns

Because a reference is a short string, it moves through the conversation like any other value:

  • Chained tool calls. One run_python call’s outputs become the next call’s inputs. A multi-step analysis (clean, join, aggregate, rank) is a chain of references. Intermediate tables never pass through the context.
  • Later turns. The reference from turn 2 is still in the transcript at turn 9. “Now break that down by region” reuses the stored artifact. Nothing is fetched again and the data isn’t pasted in again.
  • Expiry is part of the design. The TTL keeps the store bounded. An expired reference returns an error that tells the agent to fetch again, so a stale handle fails cleanly instead of quietly returning old data.

Tell the agent about this in its instructions: never ask for raw rows to answer a question; fetch into an artifact, compute with run_python, and chart by dataRef.

Rules that make it hold

  • Return the data’s shape, not its content. Schema, row count, null counts and a few sample rows. Make the sample size configurable, and set it to zero for sensitive datasets.
  • Keep everything that goes back to the model small, and enforce it. Cap the size of result and truncate stdout. Otherwise a print(df) sends the whole table back to the model.
  • Each reference belongs to one owner. Use random IDs and check the owner on every read. A reference is not a capability.
  • Producers keep the source system’s rules. The artifact holds exactly what the caller was allowed to fetch: same authorization, same row limits.
  • Limit the store. Byte cap per artifact, a TTL, and owner-scoped lookups. It holds working data for a while. It is not a data warehouse.
  • Let every display surface take references. Charts, tables and exports read from the store, so the “show me” step doesn’t push rows through the model either.

The honest trade-offs

This approach reduces exposure. It doesn’t eliminate it. Sample rows and aggregates are still data. A group of one is a single record, and a determined prompt can still ask the agent to print rows within the output cap. Treat the result cap and sample size as policy settings, and keep the source system’s authorization as the real boundary.

The model also can’t see the data directly any more. A five-row sample can be misleading: odd values, mixed units, a column that is 90% null. A cheap describe_data(dataRef) tool that returns summary statistics on request deals with most of that without bringing back the dump.

Wrap-up

The sandbox from part one made running agent code safe. Data artifacts make it worth running. The model plans and writes code, Python does the arithmetic, and the data stays in your system. The transcript ends up holding the question, the code and the answer: a reproducible record of the analysis that contains none of the data it ran on.