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-openaiThe 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 persistsNow 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:
| Requirement | Why |
|---|---|
Type-annotated code: str | Drives 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 .pngconst 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, andcreate_react_agent(fromlangchain.agents) are the old way — nowlangchain_classic. The v1 replacement iscreate_agent. And don't confuse it withlanggraph.prebuilt.create_react_agent, which is a different function. One v1 gotcha: the kwarg issystem_prompt, notprompt.
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.
| Field | What it carries |
|---|---|
result["messages"] | full transcript: HumanMessage → AIMessage (tool calls) → ToolMessage → final AIMessage |
result["messages"][-1].content | the agent's final natural-language answer |
ex.stdout / ex.text | what the tool returns to the model as a ToolMessage |
ex.error | formatted traceback when a cell raises — returned so the model can fix itself |
ex.png | base64 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
- Code interpreter — the pillar guide: code contexts, rich results, one-shot exec, and file uploads.
- Run code from a LangGraph agent — the same
run_pythonwedge wired into a LangGraph ReAct graph. - CrewAI code execution — the sandbox tool after CrewAI removed its built-in
CodeInterpreterTool. - Build a data-analyst agent — upload a CSV, explore it across many tool calls, and stream charts to a UI.
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.
CrewAI code execution after CodeInterpreterTool
Build a CrewAI code execution sandbox tool with PandaStack after the built-in CodeInterpreterTool was deprecated and removed.