all posts

How to add code execution to the OpenAI Agents SDK

Ajay Kumar··8 min read

The OpenAI Agents SDK gives you @function_tool, which turns a typed Python function into something the model can call and handles the schema generation for you. Adding code execution is therefore a five-minute job — right up until you ask where that code actually runs.

Two options exist and neither is obviously right. The hosted Code Interpreter tool is one line of config and gives you a managed Python environment with no infrastructure at all; it is also a closed box, so you cannot install a private wheel, reach an internal service, or keep the working directory around after the run. Running the code yourself gives you all of that back and hands you the isolation problem.

This is the second path done properly. I build PandaStack, so the sandbox is ours; the structure works with any provider.

The function tool

# pip install pandastack openai-agents
import json
from agents import Agent, Runner, function_tool
from pandastack import Sandbox

_sandbox = None
_ctx = None

def _kernel():
    global _sandbox, _ctx
    if _ctx is None:
        _sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
        _ctx = _sandbox.create_code_context()
    return _ctx

@function_tool
def run_python(code: str) -> str:
    """Execute Python in a secure sandbox and return the result.

    Variables and imports persist across calls. Use print(...) for text
    output; the last expression's value is captured as a rich result.
    """
    ex = _kernel().run_code(code)
    out = {
        "stdout": ex.stdout,
        "stderr": ex.stderr,
        "error": ex.error,
        "result": ex.text,
    }
    return json.dumps({k: v for k, v in out.items() if v not in (None, "")})

agent = Agent(
    name="Analyst",
    instructions="Answer quantitative questions by writing and running Python "
                 "with the run_python tool.",
    tools=[run_python],
)

result = Runner.run_sync(agent, "What's the standard deviation of 4, 9, 11, 12, 17?")
print(result.final_output)

The docstring is the tool description the model reads, so the sentence about persistence is load-bearing. Without it, the model assumes a fresh process every call and defensively re-imports and re-loads everything, which costs tokens and turns.

The global is the bug

That module-level sandbox is fine in a script and wrong in a service. Two users hitting the same process share one kernel, which means user B can see the dataframe user A loaded, and the agent starts reasoning about variables it never defined.

The Agents SDK gives you a session concept for conversation history. Key the sandbox on the same identifier and the two lifecycles stay aligned.

from contextvars import ContextVar

_current_session: ContextVar[str] = ContextVar("session_id")
_kernels: dict[str, tuple] = {}

def _kernel():
    sid = _current_session.get()
    if sid not in _kernels:
        sb = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
        _kernels[sid] = (sb, sb.create_code_context())
    return _kernels[sid][1]

def end_session(sid: str) -> None:
    entry = _kernels.pop(sid, None)
    if entry:
        entry[0].kill()
Create the sandbox lazily, on the first code call rather than at session start. Most conversations never run code, and a sandbox created for every session is a sandbox billed for every session. Lazy creation only makes sense if startup is fast enough not to be felt mid-conversation — a few hundred milliseconds is invisible; ten seconds is not.

Returning results the model can use

A code tool that returns raw stdout throws away most of what the execution produced. A cell that renders a chart prints nothing; a cell whose last expression is a DataFrame prints nothing either. The model then concludes its code failed and rewrites it, usually worse.

Structured results fix this. Each cell comes back with stdout, stderr, an error string if it raised, and a list of typed rich outputs.

ex = ctx.run_code("df.describe()")

ex.stdout            # anything printed
ex.error             # formatted traceback, or None
ex.text              # plain-text repr of the last value
ex.results[0].html   # DataFrame rendered as an HTML table
ex.png               # base64 PNG if the cell drew a chart

One rule about the PNG: never return it to the model. Base64 image data is tens of thousands of characters and it lands directly in the context window on every plotting call. Hand the bytes to your UI out of band and return a short reference instead.

The guardrails worth adding

Three, in order of how often they matter.

A per-cell timeout, because a model that writes an accidental while-loop will otherwise hold your request open until something upstream gives up. A package-install hint, because ModuleNotFoundError is by far the most common failure and the model will happily fix it if told how. And returning errors rather than raising, because a traceback handed back to the model usually costs one extra turn and then works.

ex = _kernel().run_code(code, timeout_seconds=60)

if ex.error and "ModuleNotFoundError" in ex.error:
    return json.dumps({
        "error": ex.error,
        "hint": "Install it first: import subprocess; "
                "subprocess.run(['pip','install','<pkg>'])",
    })

Getting data in and results out

The sandbox starts empty, and the temptation is to have the agent fetch what it needs. Don't — it costs tokens, it gets authentication wrong at least once, and it makes the run non-reproducible. Put the file there before the run and name the path in the instructions.

sandbox.filesystem.upload("./orders.csv", "/workspace/orders.csv")
sandbox.filesystem.write("/workspace/config.json", '{"currency": "GBP"}')

agent = Agent(
    name="Analyst",
    instructions="The dataset is at /workspace/orders.csv. Use run_python "
                 "to analyse it; write any report to /workspace/report.md.",
    tools=[run_python],
)

report = sandbox.filesystem.read("/workspace/report.md").decode()

Reading the artefact back from a known path is more reliable than parsing it out of the agent's final message, and it scales to outputs — a cleaned dataset, a generated chart, a multi-page document — that were never going to survive a round trip through a text response.

Handoffs and the shared sandbox

The Agents SDK's handoff mechanism passes control between agents, and it raises a question the single-agent case doesn't: does the receiving agent get the same sandbox?

Usually it should. A researcher that loads and cleans a dataset, handing off to an analyst that queries it, only works well if the cleaned dataframe is still there. Keying the sandbox by session rather than by agent gives you that for free — the handoff changes which agent is talking, not which environment the code runs in.

The exception is a handoff to an agent operating on genuinely untrusted input, such as one summarising a document a user uploaded. There, a fresh sandbox is worth the cost: it means a prompt injection inside that document can't reach anything the previous agent left lying around, including credentials it may have been given for a legitimate reason.

When to use the hosted tool instead

Not every application needs its own sandbox, and pretending otherwise wastes your time. The hosted Code Interpreter is the right call when the work is self-contained arithmetic, plotting, and data munging over files the user uploads, with no dependency on anything inside your network.

Run it yourself when any of these are true: you need packages that aren't in the hosted image, or a private index; the code must reach a database or internal API of yours; you want the workspace to survive between sessions; you need to inspect or snapshot exactly what the agent did; or you need the execution to run in a specific region for data-residency reasons. That list is a lot less exotic than it looks — the first two cover most production agents.

The short version

  1. One @function_tool that takes code, runs it in a sandbox, returns JSON.
  2. Sandbox keyed by session, created lazily on the first code call, killed when the session ends.
  3. A TTL at creation so an abandoned session cleans itself up regardless.
  4. Structured results — stdout, error, text repr, rich outputs — not just stdout.
  5. Timeouts on every cell, errors returned rather than raised, image bytes kept out of the prompt.

The tool body is the same function you'd write for LangGraph or CrewAI. That portability is worth preserving: framework choices change more often than the shape of a code-execution tool does.

Frequently asked questions

Should I use OpenAI's hosted Code Interpreter or my own sandbox?

Use the hosted tool when the work is self-contained — arithmetic, charts, and analysis over user-uploaded files with no dependency on your systems. It is one line of configuration and there is no infrastructure to run. Bring your own sandbox when the code needs packages outside the hosted image or from a private index, needs to reach your database or internal APIs, needs a workspace that survives between sessions, needs to run in a particular region, or needs to be auditable at the level of what exactly executed. In practice the dependency and network requirements are what push most production agents onto their own sandbox.

How do I isolate sandboxes per user in the Agents SDK?

Key the sandbox by the same session identifier you use for conversation history, and store the mapping outside the tool function. A module-level global works in a script and silently shares one kernel across every concurrent user in a server, which leaks one user's variables and files into another user's agent run. Create the sandbox lazily on the first code call so conversations that never run code cost nothing, kill it explicitly when the session ends, and set a TTL at creation so an abandoned session is reaped even if your process dies before cleanup.

Why does the agent think its code failed when it produced a chart?

Because a plotting cell prints nothing, and a tool that returns only stdout therefore returns an empty string. The model reads that as failure and rewrites the code, often several times. Return structured results instead: stdout, stderr, an error field, the text repr of the last value, and a flag or reference indicating that a chart was produced. Do not put the base64 image itself in the return value — it is tens of thousands of characters of context on every call, and the model cannot do anything useful with it anyway.

What timeout should I set on a code cell?

Somewhere between thirty and ninety seconds for interactive agents, and the important part is that it exists at all. Models write accidental infinite loops, and without a wall clock a single bad cell holds the request open until an upstream proxy or the user gives up. Set the timeout on the execution call so it is enforced by the sandbox rather than by your application, and when a cell is cut short, return that fact to the model as an error message saying the code ran too long — it will usually respond by writing something cheaper, such as sampling the data instead of scanning all of it.

How much does a sandbox per session cost?

It depends on the billing model far more than the price. Under flat hourly provisioning, a sandbox that sits idle while the model thinks costs the same as one running flat out, and you will end up pooling sandboxes across users to control the bill — which reintroduces the isolation problem. Under per-second metering on active CPU and resident memory, idle time is nearly free and per-session isolation costs little. PandaStack meters sandboxes at $0.000015 per active vCPU-second and $0.0000045 per working-set GiB-second, so the seconds spent waiting on the model are close to zero.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.