PandaStack

Give a LangChain agent secure code execution

Replace LangChain's insecure PythonREPLTool with a sandboxed langchain code execution tool — a python agent that runs code inside a Firecracker microVM, not on your host.

LangChain's PythonREPLTool runs whatever the model emits on your host process, in-process, with exec. It lives in the unmaintained langchain_experimental package, ships behind an allow_dangerous_code=True opt-in, and is tied to CVE-2024-21513. A single hallucinated shutil.rmtree("/") is your machine, not a contained failure.

The fix is the same tool shape, different blast radius: a @tool that delegates to a PandaStack code interpreter microVM. The model still writes Python; it just runs inside an isolated Firecracker VM with netns-isolated egress. This recipe wires a run_python tool over a persistent code context — a long-lived Jupyter-style kernel where variables survive across tool calls — into LangChain v1's create_agent.

Setup

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

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

The tool: one persistent kernel, not in-process exec

PythonREPLTool shares one global namespace by running on your interpreter. We get the same statefulness — without the host risk — by creating one CodeContext up front and reusing it across every tool call. Imports, DataFrames, and intermediate results carry over from one run_python call to the next, exactly like a notebook.

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

Now define the tool. The body calls ctx.run_code(...) and returns the Execution's output as a plain string — LangChain auto-wraps that 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 an isolated, persistent sandbox kernel and return its output.

    State (variables, imports, DataFrames) persists across calls within this
    session, so you can build up an analysis incrementally. Use matplotlib or
    plotly normally — charts are captured automatically; never savefig or write
    files. On an exception the traceback is returned so you can self-correct.

    Args:
        code: The Python source to execute.
    """
    ex = ctx.run_code(code)
    if ex.error:
        return f"Error:\n{ex.error}"
    return ex.stdout or ex.text or "(no output)"

Two @tool rules matter here:

RequirementWhy
Type-annotated code: strDrives the auto-generated JSON args schema the model fills in
A docstring (or explicit description=)Becomes the tool description the model reads; @tool gives the model nothing without it

The tool name defaults to the function name (run_python) — keep it snake_case. Import the decorator from langchain_core.tools; langchain.tools re-exports it in v1, but the core path is version-stable.

CrewAI and LangGraph aside, the PandaStack call is symmetric across SDKs — if you build the same tool in a TypeScript agent framework, the kernel maps one-to-one:

ex = ctx.run_code(code)          # Execution: .stdout .stderr .error .text .png
const ex = await ctx.runCode(code);   // Execution: { stdout, stderr, error?, text?, png? }

Build the agent

Use create_agent from the langchain.agents package — the v1 factory. It returns a compiled graph that runs the full tool-calling loop for you. Pass a provider string ("openai:gpt-4o", "anthropic:claude-sonnet-4-5") or a BaseChatModel, your tools, and a system_prompt.

from langchain.agents import create_agent

agent = create_agent(
    model="openai:gpt-4o",  # or "anthropic:claude-sonnet-4-5", or a BaseChatModel instance
    tools=[run_python],
    system_prompt=(
        "You can run Python in a secure sandbox. Use run_python for any "
        "computation — never guess at numbers. The kernel keeps state between "
        "calls, so build up your work incrementally and prefer matplotlib for charts."
    ),
)

Do not reach for the legacy path. AgentExecutor, initialize_agent, and create_react_agent (from langchain.agents) are the old way — now langchain_classic. The v1 replacement is create_agent. And don't confuse it with langgraph.prebuilt.create_react_agent, which is a different function. One v1 gotcha: the kwarg is system_prompt, not prompt.

Invoke it

The input shape is a messages list; .invoke(...) returns a dict whose final "messages" entry is the agent's answer.

result = agent.invoke({
    "messages": [{
        "role": "user",
        "content": "Compute the 30th Fibonacci number, then plot the sequence up to it.",
    }]
})

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

Behind the scenes the model emits a run_python tool call, create_agent executes it (running the code in the microVM), appends a ToolMessage with the kernel's output, and the model reasons over it — looping until it has an answer. Because the kernel is persistent, a follow-up turn like "now zoom into the last 5 values" reuses the already-computed fib list instead of recomputing.

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 fix itself
ex.pngbase64 PNG of any chart the cell rendered — captured automatically

The wedge: charts as objects, not stdout

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 PythonREPLTool could only ever hand the model flattened text; here every Execution carries ex.png plus ex.results, a list of typed Result objects with .png / .jpeg / .svg, .html (DataFrames render as HTML tables), .json, and .markdown.

To surface a chart, capture the PNG in the tool and hand it back to a multimodal model as an image content block:

last_png: str | None = None

@tool
def run_python(code: str) -> str:
    """Execute Python in a persistent sandbox kernel and return its output."""
    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}"
    return ex.stdout or ex.text or "(no output)"

# ...after agent.invoke(...) produced a chart:
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)

Return ex.results[0].html from the tool instead when you want the model to reason over a DataFrame as a table rather than a flattened repr.

bind_tools, if you want manual dispatch

create_agent runs the loop for you. If you only want the model to request tool calls and dispatch them yourself, use bind_tools — but note it is not an agent: it returns a runnable that emits tool_calls and stops. You execute the tool and append a ToolMessage by hand, then re-invoke.

from langchain.chat_models import init_chat_model

model = init_chat_model("openai:gpt-4o")
model_with_tools = model.bind_tools([run_python])

ai_msg = model_with_tools.invoke("Compute 2**100 exactly.")
for call in ai_msg.tool_calls:          # [{"name": "run_python", "args": {...}, "id": "..."}]
    output = run_python.invoke(call["args"])
    # append a ToolMessage(content=output, tool_call_id=call["id"]) and re-invoke the model.

For anything beyond a single tool hop, prefer create_agent — or move to a graph runtime with the LangGraph recipe.

Cleanup

The kernel and its microVM are reaped when ttl_seconds 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

On this page