PandaStack

Vercel AI SDK code execution tool (TypeScript)

Build a Vercel AI SDK code execution sandbox in TypeScript — a tool() that runs model-written code inside a self-hosted Firecracker microVM and hands back charts as objects.

A Vercel AI SDK agent that needs to compute — crunch numbers, parse a CSV, plot a trend — has to run the model's code somewhere. Running it in your Node process is a liability: a hallucinated child_process.exec is now your server's problem. Vercel's own answer is ai-sdk-tool-code-execution backed by Vercel Sandbox. This recipe is the same tool shape, but the execution backend is a self-hosted Firecracker microVM you own — portable, no per-vendor lock-in — and the tool can hand the model back a chart object instead of stdout it has to parse. (For the head-to-head, see PandaStack vs Vercel Sandbox.)

The wiring is a single tool() with a Zod inputSchema and an execute that runs code in a persistent PandaStack code context, plus a generateText loop bounded by stepCountIs. This audience is TypeScript-native, so TypeScript is primary throughout.

Setup

export PANDASTACK_API_KEY=pds_...
npm i ai @ai-sdk/openai @pandastack/sdk zod

The SDK reads PANDASTACK_API_KEY from the environment — never hardcode it. @ai-sdk/openai is one model provider; swap in @ai-sdk/anthropic or any AI SDK provider and the rest of the recipe is unchanged.

The tool: one persistent kernel, reused across steps

The win here is persistence. Instead of spinning up a fresh sandbox per tool call (and losing every variable between them), create one CodeContext up front and reuse it across every step of the agent loop. The model's imports, DataFrames, and intermediate results carry over from one execute call to the next, exactly like a notebook.

import { Sandbox } from "@pandastack/sdk";

// One sandbox + one persistent kernel for the whole agent session.
const sandbox = await Sandbox.create({
  template: "code-interpreter",
  ttlSeconds: 3600,
});
const ctx = await sandbox.createCodeContext(); // Jupyter-style kernel; state persists

// Carries the chart (if any) the most recent step produced, for surfacing later.
let lastPng: string | undefined;

Now the tool itself. The schema field is inputSchema (a Zod schema — not parameters), and execute receives the validated, typed input. The body calls ctx.runCode(...) and returns the Execution's output or formatted traceback as a string the model reads on the next step.

import { tool } from "ai";
import { z } from "zod";

const runPython = tool({
  description:
    "Execute Python in a persistent sandbox kernel and return its output. " +
    "State (variables, imports, DataFrames) persists across calls within this " +
    "session, so build up your analysis incrementally. Use matplotlib/plotly " +
    "normally — charts are captured automatically; do not savefig or write files.",
  inputSchema: z.object({
    code: z.string().describe("Python source to execute in the sandbox."),
  }),
  execute: async ({ code }) => {
    const ex = await ctx.runCode(code);
    lastPng = ex.png; // remember the chart object for the post-run hop

    if (ex.error) return `Error:\n${ex.error}`;

    const parts: string[] = [];
    if (ex.stdout) parts.push(ex.stdout.trimEnd());
    if (ex.stderr) parts.push(`[stderr]\n${ex.stderr.trimEnd()}`);
    if (ex.text && ex.text !== ex.stdout) parts.push(`[result] ${ex.text}`);
    if (ex.png) parts.push("[chart] PNG produced (available as an object, not text)");
    return parts.join("\n\n") || "(no output)";
  },
});

Returning stderr and the error traceback alongside stdout is deliberate: it feeds the model its own failure so it can self-correct on the next step instead of guessing. The description is what the model reads to decide when to call the tool — keep it specific about persistence and chart capture.

Execution fieldWhat the model gets
stdout / stderrcaptured streams (feed both back so it can self-correct)
errorformatted traceback when the cell raises
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, …)

Why a code context, not a one-shot exec

createCodeContext() is a long-lived kernel. Imports and variables survive across runCode calls, so the agent can define a DataFrame in one step and plot it in the next without re-importing pandas every turn. The one-shot sandbox.runCode(code) spawns fresh each call and keeps no state — fine for an isolated snippet, wrong for a multi-step agent. See the code interpreter guide for both paths.

The agent loop

generateText with a tools record runs the multi-step loop: the model calls runPython, the SDK runs execute, feeds the string result back, and the model continues until it answers or hits the stop condition. tools is a record keyed by tool name (not an array), and the loop is bounded by stopWhen: stepCountIs(n) — the v5+ replacement for the old maxSteps.

import { generateText, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai";

const result = await generateText({
  model: openai("gpt-5.1"),
  tools: { runPython }, // keyed by the name the model invokes
  stopWhen: stepCountIs(8), // bound the tool loop explicitly
  system:
    "You are a data analyst. Use the runPython tool to compute answers and to " +
    "plot results. The kernel keeps state between calls, so build up your " +
    "analysis incrementally. Prefer matplotlib for charts.",
  prompt:
    "Compute the first 15 Fibonacci numbers, then plot them on a log-scale " +
    "line chart. Tell me the 15th value.",
});

console.log(result.text);

Behind the scenes the model emits a tool call, the SDK runs runPython (which executes the code in the microVM), appends the string result, and the model reasons over it — looping until it produces a final answer or stepCountIs(8) trips. Because the kernel is persistent, a follow-up like "now zoom into the last 5 points" reuses the already-computed fib list — no recomputation.

FieldWhat it carries
modela provider model instance (here openai("gpt-5.1")) or a gateway string
toolsrecord of { name: tool({...}) } the model may call
stopWhenloop bound — stepCountIs(n), hasToolCall(name), or an array of conditions
result.textthe model's final natural-language answer
result.stepsfull step transcript, including every tool call and its result

Version note. Always set stopWhen explicitly. The pre-v5 API used maxSteps, which is removed; and the multi-step default changed across majors (effectively single-step in v5, stepCountIs(20) in v6). Pinning the bound in the call is version-robust and makes the loop ceiling obvious. stepCountIs is a named export of "ai" — don't import it from anywhere else.

Surfacing the chart back to the model

This is the wedge. 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 model's code produces a chart object (ex.png), not pixels-as-text.

A multimodal model can see that chart. After the run, hand the captured PNG back as an image content part and ask a follow-up:

import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";

if (lastPng) {
  const followup = await generateText({
    model: openai("gpt-5.1"),
    messages: [
      {
        role: "user",
        content: [
          { type: "text", text: "Describe the trend in this chart in one sentence." },
          { type: "image", image: `data:image/png;base64,${lastPng}` },
        ],
      },
    ],
  });
  console.log(followup.text);
}

For richer outputs, every Execution exposes .results, a list of typed Result objects with .png / .jpeg / .svg, .html (DataFrames render as HTML tables), .json, .markdown, and .text. Return ex.results[0].html from execute when you want the model to reason over a table rather than a flattened repr — or render any of these straight in your UI.

Cleanup

The kernel and its microVM are reaped when the ttlSeconds budget expires, but close them explicitly when a session ends:

await ctx.close();
await 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. Forking a warm kernel also lets each branch of an exploration start from the same loaded state.

Notes

  • Isolation. Each sandbox runs in its own Firecracker microVM with netns-isolated egress — model-written code that tries rm -rf / or to read host files is contained to the VM, not your server.
  • One kernel per session. The tool above closes over a single CodeContext, so state persists across every step of one generateText run. For concurrent requests, create one sandbox + context per request to avoid sharing a kernel across users.
  • inputSchema, not parameters. The Zod schema field is inputSchema in current ai versions. Zod is the idiomatic default, but any Standard-Schema validator (Valibot) or jsonSchema() also works.
  • Charts as objects. When the model's code produces a plot, ex.png is a ready-to-render base64 PNG — surface it in your UI or feed it back to a multimodal model rather than asking for a text description.

Next steps

On this page