PandaStack

Run code from a LangGraph agent

Give a LangGraph ReAct agent a self-hosted code interpreter in Python — a tool that runs inside a Firecracker microVM and hands back charts as objects, not stdout.

A LangGraph agent that needs to compute — crunch numbers, parse a CSV, plot a trend — has to run code somewhere. Running it on your host is a liability: a hallucinated os.system call is now your problem. The usual fix is E2B; the PandaStack version is the same shape but self-hosted Firecracker isolation, and the tool can hand the model back a chart object instead of stdout it has to parse.

This recipe gives a ReAct agent a run_python tool backed by a persistent code context — a long-lived Jupyter-style kernel inside a microVM. State survives across tool calls, so the agent can define a DataFrame in one step and plot it in the next, exactly like a notebook.

Setup

export PANDASTACK_API_KEY=pds_...
pip install pandastack langgraph langchain langchain-openai

The SDK reads PANDASTACK_API_KEY from the environment — never hardcode it. langchain-openai is one model provider; swap in langchain-anthropic or any chat model and the rest of the recipe is unchanged.

The tool: one persistent kernel, reused across calls

The win here is persistence. Instead of spinning up a fresh sandbox per tool call (and losing every variable between them), create one CodeContext up front and reuse it. The agent's imports, DataFrames, and intermediate results carry over from one run_python call to the next.

from pandastack import Sandbox

# One sandbox + one persistent kernel for the whole agent session.
sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=3600)
ctx = sandbox.create_code_context()  # Jupyter-style kernel; state persists

# Carries any chart the last run_python call produced, for the post-run hop.
last_png: str | None = None

Now wire the tool. The body just calls ctx.run_code(...) and returns the Execution's text or formatted traceback. A plain str return is auto-wrapped into a ToolMessage keyed to the model's tool call.

from langchain_core.tools import tool

@tool
def run_python(code: str) -> str:
    """Execute Python in a persistent sandbox kernel and return its output.

    State (variables, imports, DataFrames) persists across calls within
    this session. Use matplotlib/plotly normally — charts are captured
    automatically; you do not need to savefig or write files.
    """
    global last_png
    ex = ctx.run_code(code)
    last_png = ex.png  # remember the chart (if any) for surfacing later
    if ex.error:
        return f"Error:\n{ex.error}"
    out = ex.stdout or ex.text or "(no output)"
    return out

The docstring is mandatory@tool raises at definition time without one, and it becomes the tool description the model reads. The type-annotated code: str parameter becomes the JSON args schema.

In TypeScript (LangGraph is Python-first, but the SDK surface is symmetric):

import { Sandbox } from "@pandastack/sdk";

const sandbox = await Sandbox.create({ template: "code-interpreter", ttlSeconds: 3600 });
const ctx = await sandbox.createCodeContext();

// In a LangGraph.js tool body:
//   const ex = await ctx.runCode(code);
//   return ex.error ?? ex.stdout ?? ex.text;

Build the agent

Use create_agent from the langchain package — the current (v1.0) factory. It takes a model string or instance, a list of tools, and a system_prompt.

from langchain.agents import create_agent

agent = create_agent(
    model="openai:gpt-5.1",  # or e.g. "anthropic:claude-opus-4-5", or a chat-model instance
    tools=[run_python],
    system_prompt=(
        "You are a data analyst. Use the run_python tool to compute answers "
        "and to plot results. The kernel keeps state between calls, so build "
        "up your analysis incrementally. Prefer matplotlib for charts."
    ),
)

Classic path. If you're on an older stack, from langgraph.prebuilt import create_react_agent still works (deprecated in v1.0, removal targeted v2.0). One difference: it names the parameter prompt, not system_prompt. Don't mix the two.

Invoke it

The input and output shapes are identical for both factories. Pass a messages list; the final answer is the content of the last message.

result = agent.invoke({
    "messages": [{
        "role": "user",
        "content": (
            "Compute the first 15 Fibonacci numbers, then plot them on a "
            "log-scale line chart. Tell me the 15th value."
        ),
    }]
})

print(result["messages"][-1].content)

Behind the scenes the agent emits a tool call, LangGraph executes run_python (which runs the code in the microVM), appends a ToolMessage with the kernel's output, and the model reasons over it. Because the kernel is persistent, a follow-up turn like "now zoom into the last 5 points" reuses the already-computed fib list — no recomputation, no re-upload.

FieldWhat it carries
result["messages"]full transcript: HumanMessageAIMessage (tool calls) → ToolMessage → final AIMessage
result["messages"][-1].contentthe agent's final natural-language answer
ex.stdout / ex.textwhat the tool returns to the model as a ToolMessage
ex.errorformatted traceback when a cell raises (returned so the model can self-correct)
ex.pngbase64 PNG of any chart the cell rendered — captured automatically

Surfacing the chart back to the model

This is the wedge. The code-interpreter template bakes IPython, matplotlib, plotly, kaleido, and pandas, so a plt.show() or a bare DataFrame as the last expression is captured as a rich result — no savefig, no temp file, no second download. The agent gets a chart object (ex.png), not pixels-as-text.

A multimodal model can see that chart. After the run, hand the captured PNG back as an image content block and ask a follow-up:

# `last_png` was set by the tool during the run above.
if last_png:
    followup = agent.invoke({
        "messages": [
            *result["messages"],
            {"role": "user", "content": [
                {"type": "text", "text": "Describe the trend in this chart in one sentence."},
                {"type": "image_url",
                 "image_url": {"url": f"data:image/png;base64,{last_png}"}},
            ]},
        ]
    })
    print(followup["messages"][-1].content)

For richer outputs, every Execution exposes .results, a list of typed Result objects with .png / .jpeg / .svg, .html (DataFrames render as HTML tables), .json, .markdown, and .text. Return ex.results[0].html from the tool when you want the model to reason over a table rather than a flattened repr.

Cleanup

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

ctx.close()        # or: with sandbox.create_code_context() as ctx: ...
sandbox.kill()

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.

Next steps

  • Code interpreter — the pillar guide: code contexts, rich results, one-shot exec, and file uploads.
  • Build a data-analyst agent — upload a CSV, let the agent explore it across many tool calls, and stream charts to a UI.
  • CrewAI code execution — the same execution wedge wired into a CrewAI tool.
  • AI agents — sandbox-per-turn, fork-on-explore, and egress isolation patterns.

On this page