PandaStack

CrewAI code execution after CodeInterpreterTool

Build a CrewAI code execution sandbox tool with PandaStack after the built-in CodeInterpreterTool was deprecated and removed.

CrewAI removed its built-in CodeInterpreterTool from crewai-tools, and the allow_code_execution / code_execution_mode flags on Agent are deprecated too. The recommended path now is a dedicated sandbox service. This recipe wires a CrewAI code execution sandbox tool backed by a PandaStack code interpreter microVM — a drop-in custom tool your agent calls to run code in isolation.

Migrating from CodeInterpreterTool

If you have from crewai_tools import CodeInterpreterTool in your codebase, it no longer imports in current CrewAI versions. The replacement is the same shape it always should have been: a custom tool that delegates to an external sandbox. Instead of E2B or Modal, point it at a PandaStack code-interpreter sandbox. The migration is one tool class — your Agent, Task, and Crew wiring is unchanged, minus the deprecated allow_code_execution=True.

Before (removed)After (this recipe)
from crewai_tools import CodeInterpreterToolfrom crewai.tools import BaseTool + your own subclass
Agent(..., allow_code_execution=True)Agent(..., tools=[PandaStackCodeTool()])
In-process / Docker fallbackFirecracker microVM, netns-isolated egress

Install

The PandaStack SDK reads PANDASTACK_API_KEY from the environment — never hardcode it in the tool.

export PANDASTACK_API_KEY=pds_...        # your API key
pip install pandastack crewai

The custom tool

Subclass BaseTool (from crewai.tools, not the separate crewai_tools package). The _run method must return a string — that string is exactly what the agent sees. The key move is to return stderr and any error traceback alongside stdout so the model can read its own failure and self-correct on the next turn.

Because BaseTool is a Pydantic v2 model, the long-lived kernel handle is declared as a PrivateAttr — a plain _ctx: ... = None annotation would be treated as a model field and misbehave.

from typing import Type, Optional

from crewai.tools import BaseTool
from pydantic import BaseModel, Field, PrivateAttr

from pandastack import Sandbox, CodeContext


class RunPythonInput(BaseModel):
    """Input schema for the PandaStack code-execution tool."""
    code: str = Field(..., description="Python source to execute in the sandbox.")


class PandaStackCodeTool(BaseTool):
    name: str = "Execute Python"
    description: str = (
        "Run Python code in a secure, isolated sandbox and return the output. "
        "State persists across calls within this crew run, so variables and "
        "imports from earlier executions are still available. Use this whenever "
        "you need to compute, analyze data, or verify a result by running code."
    )
    args_schema: Type[BaseModel] = RunPythonInput

    # Lazily-created persistent kernel, shared across every _run call.
    _ctx: Optional[CodeContext] = PrivateAttr(default=None)

    def _kernel(self) -> CodeContext:
        if self._ctx is None:
            sandbox = Sandbox.create(
                template="code-interpreter",
                ttl_seconds=3600,
                metadata={"tool": "crewai-code-exec"},
            )
            self._ctx = sandbox.create_code_context(language="python")
        return self._ctx

    def _run(self, code: str) -> str:
        ex = self._kernel().run_code(code)

        # Hand the agent everything it needs to self-correct.
        if ex.error:
            return f"ERROR:\n{ex.error}\n\nstdout:\n{ex.stdout}".strip()

        parts = []
        if ex.stdout:
            parts.append(ex.stdout.rstrip())
        if ex.stderr:
            parts.append(f"[stderr]\n{ex.stderr.rstrip()}")
        if ex.text and ex.text not in (ex.stdout or ""):
            parts.append(f"[result] {ex.text}")
        if ex.png:
            # A chart came back as a base64 PNG object — not stdout to parse.
            parts.append("[chart] PNG produced (base64 available via ex.png)")

        return "\n\n".join(parts) or "(no output)"

    def close(self) -> None:
        if self._ctx is not None:
            self._ctx.close()
            self._ctx = None

CrewAI is Python-only, so there is no TypeScript equivalent for the tool wiring. The underlying PandaStack call maps one-to-one if you build the same pattern in a TS agent framework:

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

Why a code context, not exec

create_code_context() is a long-lived Jupyter-style kernel. Imports and variables survive across run_code calls, so the agent can build up state over a multi-step Task instead of re-importing pandas every turn. And on the code-interpreter template — which bakes IPython, matplotlib, plotly, kaleido, and pandas — rich outputs are captured automatically: a plt.show() or a bare DataFrame as the last expression comes back as ex.png / ex.results[0].html objects, with no savefig, no save-to-disk, and no second download. See the code interpreter guide for the full result model.

Execution fieldWhat the agent gets
stdout / stderrcaptured streams (feed both back so it can self-correct)
errorformatted traceback if the cell raised
textconvenience: the last text repr (a bare expression result)
pngconvenience: first chart as a base64 PNG object
resultsfull list of rich outputs (.html, .json, .svg, .markdown, …)

Wire it into a Crew

The tool is a normal CrewAI tool object — drop it into Agent(tools=[...]). No allow_code_execution flag, no Docker config.

from crewai import Agent, Crew, Task, Process

code_tool = PandaStackCodeTool()

analyst = Agent(
    role="Data Analyst",
    goal="Answer quantitative questions by writing and running Python.",
    backstory=(
        "You are a rigorous analyst. You never guess at numbers — you compute "
        "them by running code in the sandbox, and you read errors carefully to "
        "fix your own mistakes before answering."
    ),
    tools=[code_tool],
    verbose=True,
)

task = Task(
    description=(
        "Generate 1,000 samples from a normal distribution (mean 50, std 8) "
        "with numpy. Report the sample mean and the 95th percentile, then plot "
        "a histogram of the samples with matplotlib (call plt.show())."
    ),
    expected_output="The computed sample mean, the 95th percentile, and a histogram.",
    agent=analyst,
)

crew = Crew(
    agents=[analyst],
    tasks=[task],
    process=Process.sequential,
    verbose=True,
)

try:
    result = crew.kickoff()
    print(result)
finally:
    code_tool.close()   # tear down the kernel + sandbox

The agent calls Execute Python to run the numpy snippet, reads the printed values back from the tool's return string, and — because the task asks for a plot — its final run_python call produces a chart that comes back as ex.png. The tool reports [chart] PNG produced, and the base64 image is on the Execution ready to render in your UI. If the agent writes broken code, the ERROR: prefix and traceback go straight back into its context and it retries — the self-correction loop the deprecated tool used to give you, now on an isolated Firecracker VM.

Lightweight alternative: the @tool decorator

If you don't need a typed args_schema, the @tool decorator (also from crewai.tools) is the terser form. The docstring becomes the tool description, and the return value is the string the agent receives. Create the kernel lazily so merely importing the module doesn't spin up a live sandbox:

from crewai.tools import tool
from pandastack import Sandbox

_ctx = None

def _kernel():
    global _ctx
    if _ctx is None:
        _ctx = Sandbox.create(template="code-interpreter", ttl_seconds=3600).create_code_context()
    return _ctx


@tool("Execute Python")
def run_python(code: str) -> str:
    """Run Python in an isolated sandbox; returns stdout, or the error traceback."""
    ex = _kernel().run_code(code)
    if ex.error:
        return f"ERROR:\n{ex.error}"
    return ex.stdout or ex.text or "(no output)"

Use this in tools=[run_python] exactly like the class version, and call _ctx.close() when the crew finishes. The BaseTool subclass is preferred when you want typed inputs or to manage the kernel lifecycle explicitly.

Notes

  • Isolation. Each sandbox runs in its own Firecracker microVM with netns-isolated egress — a model that writes rm -rf / or tries to read host files is contained to the VM.
  • One kernel per crew run. The tool above shares a single CodeContext across all _run calls so state persists within a kickoff(). For concurrent crews, create one tool instance per crew, or per Agent, to avoid sharing a kernel.
  • Always tear down. Call code_tool.close() (or _ctx.close()) in a finally so the kernel and sandbox don't linger. Idle kernels are reaped on TTL regardless, but explicit cleanup is cheaper.
  • Charts as objects. When the agent's code produces a plot, ex.png is a ready-to-render base64 PNG — surface it in your UI rather than asking the model to describe it.

Next steps

On this page