all posts

How to give a CrewAI agent a code execution tool

Ajay Kumar··8 min read

CrewAI ships a code interpreter that runs generated Python inside a Docker container. It's a reasonable default and it breaks in two predictable places: the machine running your crew needs a working Docker daemon, which rules out most serverless and managed hosts; and if Docker isn't available, execution can fall back to running on the host directly, which is the outcome you were trying to avoid.

A custom BaseTool sidesteps both. Execution moves to a sandbox reached over HTTP, so the machine running your crew needs nothing but network access. I build PandaStack, so that's the sandbox here — the tool shape works with any provider.

The tool

CrewAI tools are pydantic models, which matters more than it sounds: any attribute you set becomes part of the schema unless you keep it off. That's the one non-obvious detail in the code below.

# pip install pandastack crewai
import json
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from pandastack import Sandbox

class _CodeInput(BaseModel):
    code: str = Field(..., description="Python source to execute in the sandbox.")

class PandaStackCodeTool(BaseTool):
    name: str = "Code Interpreter"
    description: str = (
        "Execute Python in a secure, isolated sandbox and return stdout plus "
        "any rich result. Variables persist across calls. Use this for any "
        "calculation, data analysis, or code the task requires."
    )
    args_schema: type[BaseModel] = _CodeInput

    # Kept off the pydantic schema — these are runtime handles, not inputs.
    _sandbox: Sandbox | None = None
    _ctx: object | None = None

    def _kernel(self):
        if self._ctx is None:
            object.__setattr__(self, "_sandbox",
                Sandbox.create(template="code-interpreter", ttl_seconds=1800))
            object.__setattr__(self, "_ctx", self._sandbox.create_code_context())
        return self._ctx

    def _run(self, code: str) -> str:
        ex = self._kernel().run_code(code, timeout_seconds=60)
        out = {"stdout": ex.stdout, "stderr": ex.stderr,
               "error": ex.error, "result": ex.text}
        return json.dumps({k: v for k, v in out.items() if v not in (None, "")})

    def close(self) -> None:
        if self._sandbox is not None:
            self._sandbox.kill()

Handing it to the crew

from crewai import Agent, Task, Crew

tool = PandaStackCodeTool()

analyst = Agent(
    role="Data Analyst",
    goal="Answer quantitative questions with evidence, by running code.",
    backstory="You never estimate a number you could compute.",
    tools=[tool],
)

task = Task(
    description="Load /workspace/orders.csv and report monthly revenue growth.",
    expected_output="A short paragraph with the growth rate and the numbers behind it.",
    agent=analyst,
)

try:
    print(Crew(agents=[analyst], tasks=[task]).kickoff())
finally:
    tool.close()
The try/finally is not optional. A crew that raises partway through leaves the sandbox running, and nothing else in the process will clean it up. The TTL set at creation is your backstop, not your primary mechanism — it caps the damage at thirty minutes rather than preventing it.

One kernel, several agents

This is the part that's genuinely different in a multi-agent framework. If you pass the same tool instance to several agents, they share one kernel — meaning a researcher agent can load and clean a dataframe, and an analyst agent can query it in a later task without reloading anything.

That's usually what you want. It cuts token usage substantially, because the second agent doesn't have to regenerate the loading and cleaning code, and it removes an entire class of error where two agents parse the same file slightly differently.

It also means the agents can trample each other. If two of them both write to a variable named df, the second one wins and the first one's work vanishes silently. Two mitigations, and you probably want the first:

  • Say so in the tool description — tell agents that state is shared and to use prefixed names for anything they intend to keep.
  • Give each agent its own tool instance, and therefore its own sandbox, when tasks are genuinely independent and you'd rather pay for isolation than debug a name collision.

Getting data in

The sandbox starts empty. If the crew is analysing a file, put it there before the run rather than making an agent write download code — which it will get wrong at least once, and which spends tokens on something you can do in one line.

sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=1800)

sandbox.filesystem.upload("./orders.csv", "/workspace/orders.csv")
sandbox.filesystem.write("/workspace/config.json", '{"currency": "GBP"}')

# ... crew runs ...

report = sandbox.filesystem.read("/workspace/report.md").decode()

Then say where the file is, in the task description. Agents are much better at analysing a file they've been told the path of than at discovering one.

Knowing what the crew actually ran

Multi-agent runs are hard to review after the fact. The final answer is a paragraph of text, the intermediate reasoning is scattered across several agents, and the thing you usually want to inspect — what code actually executed — is buried in tool call arguments if it was captured at all.

Two habits make this tractable. Tag the sandbox with the run identifier at creation, so you can find it later from a list rather than by remembering an id. And keep an append-only record of every cell inside the sandbox itself, which costs one line in the tool and gives you a replayable transcript of the run.

sandbox = Sandbox.create(
    template="code-interpreter",
    ttl_seconds=1800,
    metadata={"crew": "revenue-report", "run": run_id},
)

# ... and in _run, before returning:
self._sandbox.filesystem.write(f"/workspace/.cells/{n:03d}.py", code)

When a crew produces a confidently wrong number, this is the difference between a two-minute review and an afternoon of re-running things with print statements added.

A note on hierarchical crews

If you're using a manager agent to delegate, be deliberate about who holds the code tool. Giving it to the manager tends to produce a manager that does the work itself rather than delegating, because running code is easier than coordinating. Giving it only to the specialists keeps the division of labour intact, and the manager's role stays what you designed it to be.

The same logic decides how many sandboxes you create. A manager and three specialists working on one problem should share a kernel — that's the whole benefit. Four independent research tasks that happen to run under one crew should not, because their only interaction through a shared kernel is the opportunity to overwrite each other's variables.

What goes wrong, and what to do about it

Three failures account for nearly everything.

A missing package. Return the traceback plus an explicit installation hint, and the agent fixes it on the next turn. Silently swallowing the error instead means the agent tries three increasingly creative workarounds and burns half your budget.

A cell that never finishes. Set a timeout on every execution. Multi-agent runs make this worse than single-agent ones because the crew can be several tasks deep before anyone notices nothing is progressing.

An agent that ignores the tool. Usually the description is the culprit — a description that says what the tool is rather than when to use it. Naming the situations explicitly (any calculation, any data analysis, any code the task requires) changes behaviour more reliably than adjusting the agent's backstory.

The short version

  1. A BaseTool with a pydantic args schema, with the sandbox handles kept off that schema.
  2. One sandbox created lazily on first use, with a TTL, closed in a finally block.
  3. Shared kernel across agents by default — say so in the description so they don't collide on variable names.
  4. Upload input files before the run; read outputs back afterwards.
  5. Timeout every cell, return tracebacks with install hints rather than swallowing them.

None of this is CrewAI-specific below the surface. The tool body — take code, run it somewhere isolated, return structured results — is identical to the one you'd write for LangGraph or the OpenAI Agents SDK. Only the class wrapper changes.

Frequently asked questions

Why not use CrewAI's built-in code interpreter?

It requires a Docker daemon on the machine running your crew, which rules out most serverless and managed hosting, and it can fall back to executing on the host when Docker is unavailable — which is precisely the failure you were guarding against. A custom BaseTool that calls a sandbox over HTTP removes both problems: the crew host needs only network access, and there is no fallback path that quietly runs model-written code next to your application. The built-in tool remains a reasonable choice for local development on a workstation where Docker is already running.

Should every agent in a crew get its own sandbox?

Usually not. Sharing one tool instance across agents means they share a kernel, so a researcher can load and clean a dataframe and an analyst can query it in a later task without regenerating any of that code. That saves tokens and eliminates a class of inconsistency where two agents parse the same file differently. The cost is that agents can overwrite each other's variables silently. Say so in the tool description and ask for prefixed names, and give an agent its own instance only when its task is genuinely independent.

How do I get a CSV into the sandbox for the crew to analyse?

Upload it before the crew starts, using the SDK's filesystem API, and then name the path in the task description. Making an agent write download code costs tokens, fails at least once on authentication or a redirect, and produces a run that is harder to reproduce. The same applies in reverse for outputs: have the agent write its artefact to a known path and read the file back after kickoff returns, rather than trying to extract a large result from the final text output.

Why does my CrewAI agent ignore the code tool?

Almost always the description. Descriptions that state what a tool is — 'a Python code interpreter' — leave the model to infer when it applies, and models are conservative about calling tools they are unsure of. Descriptions that name the situations get used: say to use it for any calculation, any data analysis, and any code the task requires, and say that state persists between calls. This changes behaviour more reliably than rewriting the agent's role or backstory, and it is worth checking before you conclude the model is at fault.

What happens to the sandbox if my crew crashes?

Nothing cleans it up unless you arranged for that in advance, which is why the TTL matters. Set a time-to-live when creating the sandbox so the platform reaps it regardless of what happens in your process, and close the tool in a finally block so the normal path releases it immediately rather than waiting out the TTL. Treat the TTL as the backstop that caps your exposure and the explicit close as the mechanism you actually rely on — a crew that crashes on task three should not leave a sandbox billing for the next half hour.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.