PandaStack

smolagents secure code execution with PandaStack

Give a Hugging Face smolagents agent a run_python tool for secure code execution in an isolated PandaStack sandbox microVM.

smolagents agents are good at writing Python — but that code has to run somewhere. Run it on your host and a hallucinated os.system call is now your problem. This recipe gives a smolagents agent a run_python tool backed by a PandaStack code interpreter microVM, so the smolagents Hugging Face code execution happens inside a secure sandbox — an isolated Firecracker VM — and the agent gets a chart object back, not stdout it has to parse.

Tool, not a custom executor

A quick clarification, because it's the first thing you'll reach for. smolagents has an executor_type knob on CodeAgent, but it is a closed enum"local", "e2b", "docker", "blaxel", "modal", "wasm" — with no public registration hook. There is no supported executor_type="pandastack"; the executor is private internals where smolagents runs the agent's own generated action loop.

The documented, stable extension point is a Tool. So we surface PandaStack as a tool the agent calls: the smolagents reasoning loop runs wherever you like (locally is fine), and any code it wants to actually execute goes to an isolated microVM. Clean separation — reasoning here, untrusted execution over there.

smolagents executor_typeA PandaStack @tool (this recipe)
Public extension point?No — closed enum, no registration APIYes — @tool / Tool are first-class
What it runsThe agent's own generated action loopCode the agent explicitly hands you
IsolationDepends on the enum valueFirecracker microVM, netns-isolated egress

Install

The PandaStack SDK reads PANDASTACK_API_KEY from the environment — never hardcode it in the tool.

export PANDASTACK_API_KEY=pds_...           # your API key, pds_ prefix
pip install pandastack 'smolagents[toolkit]'

The [toolkit] extra ships smolagents' default tools (web search, etc.). For OpenAI/Anthropic models via LiteLLM, also pip install 'smolagents[litellm]'.

The tool: one persistent kernel, reused across calls

The win is persistence. Instead of a fresh sandbox per call (losing every variable between them), create one CodeContext — a long-lived Jupyter-style kernel inside the microVM — and reuse it. The agent's imports, DataFrames, and intermediate results carry over from one run_python call to the next, exactly like a notebook.

A smolagents @tool has two load-bearing requirements: type hints on every parameter and the return, and a docstring with an Args: section describing each argument. Both get baked into the system prompt; missing either raises at decoration time. Create the kernel lazily so merely importing the module doesn't spin up a live VM.

from smolagents import tool
from pandastack import Sandbox, CodeContext

_ctx: CodeContext | None = None

def _kernel() -> CodeContext:
    """Lazily create one persistent sandbox kernel for the whole session."""
    global _ctx
    if _ctx is None:
        sandbox = Sandbox.create(
            template="code-interpreter",
            ttl_seconds=3600,
            metadata={"tool": "smolagents-run-python"},
        )
        _ctx = sandbox.create_code_context(language="python")
    return _ctx


@tool
def run_python(code: str) -> str:
    """
    Execute Python in a secure, isolated sandbox kernel and return its output.
    State (variables, imports, DataFrames) persists across calls within this
    session, so build up your analysis incrementally. Use matplotlib/plotly
    normally — charts are captured automatically; do not savefig or write files.

    Args:
        code: The Python source to execute in the sandbox.
    """
    ex = _kernel().run_code(code)

    # Hand the agent everything it needs to self-correct.
    if ex.error:
        return f"ERROR:\n{ex.error}\n\nstdout:\n{ex.stdout}".strip()

    parts = []
    if ex.stdout:
        parts.append(ex.stdout.rstrip())
    if ex.stderr:
        parts.append(f"[stderr]\n{ex.stderr.rstrip()}")
    if ex.text and ex.text not in (ex.stdout or ""):
        parts.append(f"[result] {ex.text}")
    if ex.png:
        # A chart came back as a base64 PNG object — not stdout to parse.
        parts.append("[chart] PNG produced (available as ex.png on the Execution)")

    return "\n\n".join(parts) or "(no output)"

Returning stderr and the formatted traceback alongside stdout is deliberate: the agent reads its own failure and fixes the next turn. That self-correction loop is the whole point of giving a model an executor.

Why a code context, not exec

create_code_context() is a persistent kernel; sandbox.exec() is a one-shot shell command with no state between calls. For an agent that iterates — define a DataFrame in one step, plot it in the next — you want the kernel. And on the code-interpreter template, which bakes IPython, matplotlib, plotly, kaleido, and pandas, rich outputs are captured automatically: a plt.show() or a bare DataFrame as the last expression comes back as ex.png / ex.results[0].html objects — no savefig, no save-to-disk, no second download.

Execution fieldWhat the agent gets
stdout / stderrcaptured streams (feed both back so it can self-correct)
errorformatted traceback if the cell raised
textconvenience: the last text repr (a bare expression result)
pngconvenience: first chart as a base64 PNG object
resultsfull list of rich Result outputs (.html, .json, .svg, .markdown, …)

Wire it into an agent

Two choices. A CodeAgent writes Python to orchestrate its tools and would run that in its own executor — useful, but here the actual computation already lives in the sandbox, so a ToolCallingAgent (which emits plain JSON tool calls, no second interpreter) is the leaner fit. Both take the same tools=[...] and model=.

from smolagents import ToolCallingAgent, InferenceClientModel
# from smolagents import LiteLLMModel   # for OpenAI/Anthropic via LiteLLM

model = InferenceClientModel()          # Hugging Face Inference Providers default
# model = LiteLLMModel(model_id="anthropic/claude-3-5-sonnet-latest")  # needs ANTHROPIC_API_KEY

agent = ToolCallingAgent(
    tools=[run_python],
    model=model,
)

InferenceClientModel() is the current default model class (it's not HfApiModel — that older name is gone). A CodeAgent is the alternative: it runs the agent's own generated orchestration code in its executor_type (the closed enum above), with run_python still handing the actual computation to the microVM. Note that the tool already sandboxes the code it executes — the executor_type only governs the agent's own action loop, not your tool, so there's no need to stack a second sandbox under it. additional_authorized_imports=[...] is a CodeAgent-only kwarg and has no effect on ToolCallingAgent.

Run it

result = agent.run(
    "Generate 1,000 samples from a normal distribution (mean 50, std 8) with "
    "numpy. Report the sample mean and the 95th percentile, then plot a "
    "histogram of the samples with matplotlib (call plt.show())."
)
print(result)

The agent emits a run_python tool call, smolagents executes it (the numpy code runs in the microVM), and the tool's return string — the computed mean and percentile — goes back into the agent's context. Because the kernel is persistent, a follow-up like "now overlay the mean as a vertical line" reuses the already-generated samples array; no recomputation. If the agent writes broken code, the ERROR: prefix and traceback land back in its context and it retries.

To inspect what happened step by step, agent.logs holds the structured trace and agent.write_memory_to_messages() renders it as a message list — handy for surfacing the agent's reasoning in a UI.

Surfacing the chart back to the model

This is the wedge. Because a chart comes back as ex.png — a base64 PNG object, captured automatically — you hold the image directly, with no savefig and no save-to-disk round trip. Run chart code in the kernel and the PNG is right there on the Execution:

ex = _kernel().run_code(
    "import matplotlib.pyplot as plt; plt.plot([1, 2, 3]); plt.show()"
)
chart_b64 = ex.png   # base64 PNG object — no savefig, no file written

From here chart_b64 is yours to render: write it to a file, embed it in a UI, or pass it to whatever multimodal model you use, following that model's own image API. (The run_python tool above already signals the agent when a chart is available; this snippet shows where the bytes live.)

For tables, look at ex.results for the result whose .html is set — that renders a DataFrame as an HTML table you can return from the tool when you want the model to reason over structured data instead of a flattened repr. See the code interpreter guide for the full result model.

Cleanup

The kernel and its microVM are reaped when ttl_seconds expires, but close them explicitly when a session ends:

if _ctx is not None:
    _ctx.close()        # tears down the lazily-created kernel held by the _ctx global

To make agent sessions resumable across process restarts, snapshot the sandbox instead of killing it and restore on the next turn — the kernel state comes back with it. To run many agents in parallel without sharing one kernel, give each its own tool instance (one CodeContext per agent), or fork a warm kernel so each branch starts from the same loaded state.

TypeScript note. smolagents is Python-only, so there's no TS agent wiring. The underlying PandaStack call maps one-to-one if you build the same tool pattern in a TS framework: const ex = await ctx.runCode(code) returns { stdout, stderr, error?, text?, png? }, mirroring the Python Execution.

Next steps

On this page