How to give a LangChain agent a code execution tool
Every LangChain agent that does anything numerical eventually gets a code tool. The default path is PythonREPLTool, and it works immediately, which is exactly the problem — it executes whatever the model writes inside your own process, with your environment variables, your credentials, and your filesystem. LangChain's own docstring says it can execute arbitrary code and to use it with caution. That warning is doing a lot of work.
The fix isn't complicated. Move execution somewhere that isn't your process, keep it stateful so the agent can build up work across turns, and return structured results so the model doesn't have to parse a wall of text. Here's how that looks end to end. I build PandaStack, so the sandbox calls are ours — the shape applies to any sandbox provider.
The tool, in about thirty lines
LangChain's @tool decorator turns a plain function into something the model can call. The only interesting part is what happens inside the body: instead of exec(), the code goes to a microVM with its own kernel.
# pip install pandastack langgraph langchain-core
import json
from langchain_core.tools import tool
from pandastack import Sandbox
_sandbox = None
_ctx = None
def _kernel():
"""One sandbox + one persistent kernel, created lazily."""
global _sandbox, _ctx
if _ctx is None:
_sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
_ctx = _sandbox.create_code_context()
return _ctx
@tool
def run_python(code: str) -> str:
"""Execute Python in a secure sandbox and return the result.
Variables and imports persist across calls, so you can build up work
step by step. Use print(...) for text output.
"""
ex = _kernel().run_code(code)
payload = {"stdout": ex.stdout, "stderr": ex.stderr, "error": ex.error}
if ex.text:
payload["result"] = ex.text
return json.dumps({k: v for k, v in payload.items() if v})That docstring is not decoration — it is the tool description the model reads when deciding whether to call it. Telling the model that state persists is what makes it stop re-importing pandas in every single cell.
Wiring it into the graph
from langgraph.prebuilt import create_react_agent
from langchain_anthropic import ChatAnthropic
agent = create_react_agent(
ChatAnthropic(model="claude-sonnet-4-5"),
tools=[run_python],
)
result = agent.invoke({
"messages": [("user", "Fit a linear trend to [3,7,9,14,20] and give me the slope.")]
})
print(result["messages"][-1].content)Why the persistent kernel matters more than it sounds
A stateless code tool — one that spawns a fresh process per call — forces the agent into a specific, bad pattern. It cannot load a dataframe in one step and query it in the next, so it either rewrites the whole pipeline every turn or it writes everything to disk and re-reads it. Both burn tokens and both produce longer, more fragile generated code.
With a real kernel, the agent works the way a person works in a notebook:
ctx = sandbox.create_code_context()
ctx.run_code("import pandas as pd")
ctx.run_code("df = pd.read_csv('/workspace/sales.csv')")
ex = ctx.run_code("df.groupby('region').revenue.sum()")
ex.text # the plain-text repr
ex.results[0].html # DataFrame as an HTML table
ex.results[0].png # base64 PNG if the cell drew a chartThe results being typed is the second half of it. A cell that plots something gives you a PNG, not a line of text saying a figure was created. You can send that straight to a UI without a save-to-disk-and-download dance.
Don't put base64 in the prompt
This one catches people out. A chart comes back as a base64 PNG, and if the tool returns it as a string, that string goes into the model's context. A modest chart is tens of thousands of characters of noise, and it happens on every plotting call.
# Wrong: the model now reads 40,000 characters of base64
return json.dumps({"image": ex.png})
# Right: tell the model an image exists, keep the bytes out of band
if ex.png:
image_id = store_for_ui(ex.png) # your own storage
payload["image"] = f"chart rendered (id={image_id})"Handling the failures the model can fix
Most code-tool errors are the model's fault and the model can fix them, but only if it sees them. Return the traceback rather than raising, and it usually corrects itself on the next turn.
The two that a retry will not fix are worth handling separately. A missing package should be installed, not worked around — so say so explicitly in the error you return. And a runaway cell needs a wall clock, because a model that writes an accidental infinite loop will otherwise hold your request open until something upstream times out.
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 with: import subprocess; "
"subprocess.run(['pip','install','<pkg>'])",
})Give it the data, don't make it fetch the data
A recurring pattern in agent traces: the model writes a requests call to download a dataset, gets a 403, tries again with a different header, and gives up three tool calls later. All of that was avoidable by putting the file in the sandbox before the run started.
sandbox.filesystem.upload("./orders.csv", "/workspace/orders.csv")
# Then say where it is, in the system prompt:
# "A dataset is at /workspace/orders.csv. Use run_python to analyse it."The same applies to results. An agent asked to produce a report should write it to a path you then read, rather than emitting it through the chat response — which truncates, reformats, and occasionally paraphrases the thing you actually wanted.
Two tools beat one tool with a mode flag
Once the agent can run Python, the next request is always shell access — to install a package, run a CLI, inspect a directory. The tempting design is one tool with a language parameter. It reliably produces worse tool selection, because the model now makes two decisions where it used to make one, and it gets the second one wrong under load.
Two tools with clearly different descriptions work better. Give the shell tool a description that says what it is for and, just as importantly, what it is not for — installing packages and inspecting files, not analysis.
@tool
def run_shell(command: str) -> str:
"Run a shell command in the sandbox. Use for installing packages, "
"inspecting files, and running CLI tools - not for data analysis."
res = _sandbox.exec(command, timeout=120)
return json.dumps({
"stdout": res.stdout,
"stderr": res.stderr,
"exit_code": res.exit_code,
})Both tools hit the same sandbox, so a package installed through the shell tool is importable from the kernel immediately afterwards — which is what a developer would expect, and the reason this split costs you nothing.
Cleaning up
A sandbox that nobody kills is a sandbox you are paying for. Two mechanisms cover it: a TTL set at creation so an abandoned conversation cleans itself up, and an explicit teardown on the path where the conversation ends normally.
# Belt: the platform reaps it even if your process dies
sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
# Braces: explicit teardown on the happy path
try:
...
finally:
sandbox.kill()
# Or let the context manager do it
with Sandbox.create(template="code-interpreter") as sandbox:
ctx = sandbox.create_code_context()
ctx.run_code("print('hello')")The short checklist
- Execution happens outside your process — not exec(), not a subprocess on the same host.
- One sandbox per conversation, keyed by thread id, not one global.
- A persistent kernel, so the agent can build up state instead of rewriting its pipeline every turn.
- Errors returned to the model as text, so it can self-correct; timeouts enforced so it can't hang you.
- Images and large blobs kept out of the prompt — return a reference, not the bytes.
- A TTL at creation plus explicit teardown, so abandoned runs don't bill forever.
None of this is LangChain-specific, which is the point. Swap create_react_agent for a CrewAI crew or the OpenAI Agents SDK and the tool body doesn't change at all — it's still one function that takes code, runs it somewhere isolated, and returns structured results.
Frequently asked questions
Is LangChain's PythonREPLTool safe to use in production?
No, and the library says as much in its own documentation. PythonREPLTool executes model-generated code in the same Python process as your application, which means the code inherits your environment variables, your database credentials, your filesystem, and your network position. A prompt injection in a document the agent reads becomes arbitrary code execution on your server. It is a genuinely useful tool for local experimentation, and a liability the moment untrusted input can reach the model. Move execution to an isolated sandbox and the tool interface stays identical.
Should each conversation get its own sandbox?
Yes, in almost every case. A shared kernel means one user's variables, files, and installed packages are visible to the next user, which is both a privacy problem and a correctness problem — the agent starts reasoning about state it did not create. Key the sandbox by thread or conversation id, create it lazily on the first code call so conversations that never run code cost nothing, and tear it down when the conversation ends. The only reason people share sandboxes is slow startup; if creating one takes a fraction of a second, the trade-off disappears.
How do I stop the agent from re-importing pandas on every call?
Tell it the state persists, in the tool's docstring. The docstring is the tool description the model sees, and models are conservative by default — absent any statement about persistence, they assume a fresh process and defensively re-import and re-load everything each turn. One sentence saying variables and imports carry over between calls changes the generated code immediately, and cuts both token usage and the number of steps a multi-part task takes.
What should a code tool return when the code raises an exception?
Return the traceback as a normal tool result rather than raising inside your framework. Models are good at reading a Python traceback and fixing the line that caused it, so a returned error usually costs one extra turn and then succeeds. Raising, by contrast, either crashes the run or produces a generic framework error message that carries none of the information needed to correct the code. Two cases deserve special handling: a missing module, where you should tell the model how to install it, and a timeout, where you should say the code was stopped for running too long so the model writes something cheaper.
How much does it cost to run a sandbox per conversation?
It depends entirely on whether you are billed for provisioned time or for time actually used. If a sandbox bills a flat hourly rate while it exists, one per conversation is expensive and you will be pushed toward pooling. If it bills per second on active CPU and resident memory, an idle sandbox waiting on a model response costs almost nothing, and per-conversation isolation becomes the cheap option as well as the safe one. On PandaStack a sandbox meters at $0.000015 per active vCPU-second and $0.0000045 per working-set GiB-second, so the seconds spent waiting for the model to think are nearly free.
Keep reading
- Sandboxes on PandaStack — Firecracker microVMs with a persistent kernel
- The best sandbox APIs for Python coding agents
- Sandboxing LLM tool calls
- How to stream command output from a sandbox
- Agent tool timeouts and cancellation
49ms p50 cold start. Fork, snapshot, and scale to zero.