How to give a Haystack agent a code execution tool
Most agent frameworks ask you for a callable. You write a function, you decorate it or wrap it, you hand it to the agent, done. Haystack does not think that way. Haystack 2.x is built around Pipelines made of Components wired together by connecting named outputs to named inputs, and that graph is the mental model you are supposed to be in. So when you go looking for where code execution fits, you find two plausible homes rather than one, and the docs will not pick for you.
That fork is the actual problem, and it is worth ten minutes before you write any code. I build PandaStack, so that is the sandbox in the examples below, but the shape works with any provider that gives you an isolated process over HTTP.
Component or tool
The distinction is about who decides. A Component runs because the graph says it runs. Data arrives on its input socket, it executes, it emits on its output socket, every single time. A Tool runs because a model chose to call it, which means it might run three times, or once, or never.
So the question is not really about code execution at all. It is: is this step deterministic, or is it a judgement call?
If you have a fixed transformation that every document goes through, that is a Component. Normalising a CSV, running a validation script against a schema, rendering a chart from a query result whose shape you already know. You do not want a model deciding whether normalisation happens. You want it to happen. Putting that behind a tool call adds a token round trip, a chance the model skips it, and a chance it rewrites the transformation slightly differently on Tuesday.
The Component version looks roughly like this. Haystack components are classes marked with a decorator, with their run method declaring what it emits so the pipeline can type-check the connections you draw:
# Illustrative. Check the current Haystack docs for exact signatures.
from haystack import component
from pandastack import Sandbox
@component
class SandboxTransform:
"""Deterministic step: every payload goes through this, always."""
def __init__(self, script_path: str):
self.script_path = script_path
@component.output_types(stdout=str, exit_code=int)
def run(self, payload: str):
sbx = Sandbox.create(template="code-interpreter", ttl_seconds=120)
try:
sbx.filesystem.upload(self.script_path, "/workspace/transform.py")
sbx.filesystem.write("/workspace/in.json", payload.encode())
r = sbx.exec("python /workspace/transform.py /workspace/in.json")
return {"stdout": r.stdout, "exit_code": r.exit_code}
finally:
sbx.kill()Note what is not happening there: no model writes that script. You wrote it. The sandbox is doing isolation for untrusted input or for a dependency you would rather not install next to your API process, not for untrusted code.
The Tool version is the one this series is about, and it is what you want the moment the code itself is generated. A question like give me the compound growth rate across these four quarters has no fixed transformation behind it. The model has to write the arithmetic, and the whole reason you are here is that a language model computing a growth rate by generating tokens produces a number that looks right and is not.
The tool
Haystack tools carry a name, a description, a JSON schema for their parameters, and the function to invoke. The description and schema are what the model actually sees, so they are prompt, not documentation. Write them for a reader who is deciding whether to call you.
# pip install pandastack haystack-ai
from haystack.tools import Tool
from pandastack import Sandbox
def make_code_tool(sandbox: Sandbox) -> Tool:
kernel = sandbox.create_code_context(language="python")
def run_python(code: str) -> str:
result = kernel.run_code(code, timeout_seconds=60)
if result.error:
return "ERROR:\n" + result.error
out = result.stdout.strip() or "(no output - did you print() it?)"
if result.stderr.strip():
out += "\n\nstderr:\n" + result.stderr.strip()
return out
return Tool(
name="run_python",
description=(
"Execute Python in an isolated sandbox and return stdout. "
"Variables, imports and loaded data persist between calls, so "
"you can build up an analysis over several steps. Use this for "
"any calculation, aggregation or data transformation. Never do "
"arithmetic yourself - compute it here."
),
parameters={
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Python source to execute. print() what you want to see.",
},
},
"required": ["code"],
},
function=run_python,
)Two things in there earn their place. The kernel is a persistent code context rather than a fresh process per call, so the model can load a dataframe in one turn and query it in the next, which is how anyone actually works with data. And errors come back as a return value instead of an exception. A traceback is the most useful thing you can hand a model mid-run: it reads the NameError, fixes the line, moves on. An exception thrown out of the tool ends the run instead.
Attaching it to an agent
Haystack's agent takes a chat generator and a list of tools, and it is itself a component, so it drops into a pipeline alongside your retriever and whatever else you have wired up.
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-4o"),
tools=[make_code_tool(sandbox)],
system_prompt=(
"You answer questions about the retrieved documents. "
"Do every calculation with run_python - never compute a number "
"in your head, even an easy one. State the values you are using "
"before you compute with them."
),
)
result = agent.run(
messages=[ChatMessage.from_user("Compound growth across the four quarters?")]
)That system prompt line about easy calculations is not padding. Left to itself a model will answer a numeric question directly whenever it judges the arithmetic simple, and simple is exactly where the confidently wrong answers cluster. Nobody gets a matrix inversion wrong in their head, because nobody tries.
Sandbox lifecycle versus pipeline lifecycle
This is the part that bites people, and it is specific to Haystack's shape rather than to sandboxes.
A pipeline is an object you build once and run many times. That is the whole point of the design: you assemble the graph at startup, hold it, and call run on every request. In a web service it is a module-level global, and depending on your server it is being run concurrently by several workers or several threads.
Now look at where the sandbox got created in the snippet above. Module scope. One sandbox, one kernel, shared by every request that pipeline ever serves. Which means two things, both bad. User A's dataframe is sitting in memory when user B's turn runs, so B's generated code can read it, and a model that has been told variables persist will absolutely try. And two concurrent runs execute in the same kernel, so they overwrite each other's variables mid-analysis and produce results that are wrong in a way no traceback will show you.
The naive fix is to create the sandbox inside the tool function instead, per call. That gets you isolation and throws away the persistent kernel, which was the reason the tool was useful. It also means the agent pays provisioning latency on every single tool call, and an agent doing real analysis makes five or six.
The honest resolution sits between the two: scope the sandbox to the run, not to the pipeline and not to the call. Build it when a request arrives, hand it to a tool instance made for that request, tear it down when the request ends.
def answer(question: str, session_id: str) -> str:
sandbox = Sandbox.create(
template="code-interpreter",
ttl_seconds=900,
metadata={"session": session_id},
)
try:
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-4o"),
tools=[make_code_tool(sandbox)],
system_prompt=SYSTEM_PROMPT,
)
result = agent.run(messages=[ChatMessage.from_user(question)])
return result["messages"][-1].text
finally:
sandbox.kill()The obvious objection is cost. Building an agent per request feels wasteful when the framework clearly wants you to build it once. In practice constructing the agent object is cheap; the expensive part is the sandbox, and that depends entirely on how your provider creates them. On PandaStack a create is a snapshot restore rather than a boot, which lands around 179ms at p50. That is well under the latency of the first model call it precedes, so it disappears into a round trip you were already paying for. If your sandbox provider boots a container per request and charges you three seconds for it, the arithmetic changes and you will want a pool.
The long-lived sandbox is still right in one case, and it is worth naming because the per-run rule is not absolute. A single-user batch job, where the shared state is the point: a nightly pipeline that loads a large dataset once and runs forty analytical steps against it. There is no second tenant to leak to and no concurrency to collide with, and rebuilding the dataframe forty times would be silly. Build it at pipeline construction, keep it for the life of the job, kill it in the same place you tear the pipeline down.
Cleanup, and what to do when it does not run
The finally block covers the ordinary case. It does not cover the worker that gets OOM-killed, the pod that gets evicted mid-run, or the user who closes the tab while your agent is on tool call four. Nothing in your process runs when your process stops existing.
That is what the TTL at creation is for. Set it slightly longer than a plausible run and let the platform reap anything your code fails to. Treat it as the backstop that caps your exposure, not as the mechanism you rely on, because a TTL of fifteen minutes means a crashed request bills for fifteen minutes.
The metadata field is worth filling in for the same reason. When you find sandboxes still running an hour after a bad deploy, being able to list them by session and see which request each belonged to is the difference between a clean sweep and guessing.
import contextlib
@contextlib.contextmanager
def session_sandbox(session_id: str):
sbx = Sandbox.create(
template="code-interpreter",
ttl_seconds=900,
metadata={"session": session_id, "app": "haystack-qa"},
)
try:
yield sbx
finally:
with contextlib.suppress(Exception):
sbx.kill()
with session_sandbox(session_id) as sbx:
agent = Agent(tools=[make_code_tool(sbx)], chat_generator=gen)
result = agent.run(messages=[ChatMessage.from_user(question)])Suppressing the exception on kill is deliberate. A teardown that raises because the sandbox was already reaped will mask the real error from the run it was wrapping, and you will spend an afternoon debugging the wrong stack trace.
What goes wrong
A missing package. Return the traceback with an explicit hint that the agent may install what it needs, and it fixes itself on the next turn. Swallow the error and it invents three increasingly baroque workarounds first.
A cell that never returns. Put a timeout on every execution. An agent inside a pipeline is two layers of abstraction away from your logs, and a hung kernel presents as a request that is merely slow until someone checks.
Data the model has to retype. If the pipeline retrieved documents and the agent needs to compute over them, write them into the sandbox as a file and tell the agent the path in the system prompt. Making the model paste values into the code it generates routes every number through tokenisation, which is where transcription errors come from, and puts the whole result set in the context window on every subsequent turn.
An agent that never calls the tool. Nearly always the description. Descriptions that say what a tool is leave the model to work out when it applies, and models are cautious about tools they are unsure of. Name the situations instead.
The short version
- Deterministic step that must always run: make it a Component in the pipeline.
- Model decides when to compute: make it a Tool and hand it to the agent.
- Persistent kernel, so state carries across the agent's tool calls.
- Scope the sandbox to the run, not to the pipeline object, unless it is a single-user batch job.
- Return tracebacks as strings; timeout every execution.
- TTL at creation plus a finally block, and suppress errors on teardown.
Underneath the framework wrapper this is the same twenty lines you would write for LangGraph or CrewAI: take code, run it somewhere isolated, return something readable. What Haystack adds is the question of where the step belongs in the graph, and the trap that a pipeline built once and run forever will happily share one sandbox with everyone who ever talks to it.
Frequently asked questions
Should code execution be a Haystack Component or a Tool?
It depends on who decides that it runs. A Component executes because the pipeline graph says so, every time data reaches it, which is what you want for a fixed transformation like normalising a file or running a validation script you wrote. A Tool executes because the model chose to call it, which is what you want when the code itself is generated in response to a question you cannot predict. Putting a deterministic step behind a tool call adds a round trip and a chance the model skips it entirely.
Can I create the sandbox in my component's constructor?
Only for a single-user batch job. A Haystack pipeline is normally constructed once and run many times, often concurrently, so a sandbox created in the constructor is shared by every request that pipeline ever serves. Two users then execute in the same kernel: one can read the other's variables, and concurrent runs overwrite each other mid-analysis. Scope the sandbox to the run instead. The exception is a nightly job with one user where the shared loaded dataset is exactly the point.
Is creating a sandbox per request too slow?
That depends on your provider, not on Haystack. If a create means booting a container you will feel every one of those seconds and will want a warm pool. If it means restoring a snapshot it is fast enough to disappear into the model call it precedes; on PandaStack that lands around 179ms at p50. Measure it against your first token latency rather than in isolation, because the sandbox is created in parallel with work you were already waiting on.
How do I get retrieved documents into the sandbox?
Write them to a file in the sandbox after retrieval and name the path in the system prompt, so the generated code loads it rather than reproducing it. Having the model paste retrieved values into the code it writes works for a handful of numbers and degrades badly beyond that. Every value passes through tokenisation, which is where transcription errors appear, and the whole result set then sits in the context window for every remaining turn of the conversation.
Why not just exec the generated code in the pipeline process?
Because a retrieval pipeline consumes text you did not write and emits code, which is the exact shape of a prompt-injection-to-execution chain. Anything that survives it runs with your service's credentials, filesystem and network access. You do not have to believe an attack is likely to want the boundary either: a model that writes a destructive filesystem call when it meant a read does identical damage without any adversary involved. An isolated sandbox reached over HTTP removes the entire class.
Keep reading
- Code interpreter sandboxes — Persistent kernels, isolated per session
- The same tool, in LlamaIndex
- The same tool, in CrewAI
- What tool calling actually is
- Why Docker is not a sandbox
49ms p50 cold start. Fork, snapshot, and scale to zero.