Code Interpreter

Stateful, streaming code execution for LLM tool calls, a thin, opinionated layer over Sandal sandboxes.


Overview#

The code interpreter isn't a separate product, it's a run_code convenience layered directly on top of a Sandal sandbox, tuned for the specific shape of LLM tool-calling: short-lived, stateful, back-and-forth code execution where you want stdout streamed back as it happens and rich outputs (dataframes, plots, JSON) returned as structured data rather than scraped from a terminal.

If you already know sandboxes, you already know this: run_code is what you reach for when the sandbox's job is "execute code an LLM just wrote," and exec / fs / expose are what you reach for when it's anything else (installing dependencies, running a build, serving a port). Both live on the same instance, so you can mix them freely.

Why not just exec?#

exec("python script.py") runs a fresh process every call, no shared variables, no shared imports, no persistent kernel. run_code keeps a live interpreter process (Python, Node, or a Jupyter-style kernel) attached to a context, so each call sees everything the previous ones defined.

import { Sandal } from "@sandal/sdk";

const sandal = new Sandal({ apiKey });
const sb = await sandal.sandboxes.create({ template: "code-interpreter-python" });

await sb.runCode("import pandas as pd\ndf = pd.read_csv('sales.csv')");
const result = await sb.runCode("df.groupby('region').sum()");

console.log(result.text);   // rendered repr, like a REPL
console.log(result.results); // structured outputs, see below

df from the first call is still in scope for the second. This is the entire point: an LLM iterating on an analysis doesn't want to re-load the CSV every turn.

Stateful contexts#

A sandbox can hold multiple independent interpreter contexts, useful when one agent thread is running Python analysis and another is running a scratch Node script, or when you want a clean slate without tearing down the sandbox.

const ctxA = await sb.contexts.create({ language: "python" });
const ctxB = await sb.contexts.create({ language: "python" });

await sb.runCode("x = 1", { context: ctxA.id });
await sb.runCode("x = 2", { context: ctxB.id });

await sb.runCode("print(x)", { context: ctxA.id }); // 1
await sb.runCode("print(x)", { context: ctxB.id }); // 2
from sandal import Sandal

sandal = Sandal(api_key=api_key)
sb = sandal.sandboxes.create(template="code-interpreter-python")

ctx = sb.contexts.create(language="python")
sb.run_code("x = 1", context=ctx.id)
result = sb.run_code("x + 1", context=ctx.id)
print(result.text)  # "2"

Contexts persist for the sandbox's lifetime. Reset one when you want to discard state without paying for a new sandbox:

await sb.contexts.reset(ctxA.id);

Streaming stdout#

For long-running cells, training loops, large queries, anything that prints progressively, subscribe to the stream instead of waiting for the final result:

const run = sb.runCode("for i in range(5):\n    print(i)\n    time.sleep(1)", {
  context: ctx.id,
  stream: true,
});

for await (const chunk of run) {
  if (chunk.type === "stdout") process.stdout.write(chunk.text);
  if (chunk.type === "stderr") process.stderr.write(chunk.text);
  if (chunk.type === "result") console.log("final:", chunk.results);
}

This is the same event shape you'd forward directly into a chat UI as the model's tool call executes, no buffering the whole run before the user sees anything.

Rich outputs#

run_code captures more than text. Anything the interpreter would normally render, a matplotlib figure, a pandas DataFrame, a plotly chart, raw JSON, comes back as a typed result you can hand to a UI or to the model itself.

const result = await sb.runCode(`
import matplotlib.pyplot as plt
plt.plot([1, 2, 3], [4, 1, 2])
plt.title("demo")
plt.show()
`, { context: ctx.id });

for (const r of result.results) {
  console.log(r.type); // "image/png" | "text/html" | "application/json" | "text/plain"
  if (r.type === "image/png") {
    // r.data is base64, render directly, e.g. <img src={`data:image/png;base64,${r.data}`} />
  }
}

if (result.error) {
  console.log(result.error.name, result.error.message, result.error.traceback);
}

Errors are structured too, name, message, and a traceback array, so a supervising agent can decide whether to retry, patch the code, or surface the failure to the user without re-parsing stderr text.

LLM tool-calling integration#

run_code is designed to sit behind a single tool definition. A typical loop: give the model a run_code tool, execute whatever it returns, feed the structured result back as the tool result, repeat.

import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic();
const sandal = new Sandal({ apiKey });
const sb = await sandal.sandboxes.create({ template: "code-interpreter-python" });
const ctx = await sb.contexts.create({ language: "python" });

const tools = [
  {
    name: "run_code",
    description: "Execute Python code in a persistent, stateful interpreter and return its output.",
    input_schema: {
      type: "object",
      properties: { code: { type: "string" } },
      required: ["code"],
    },
  },
];

let messages = [{ role: "user", content: "Load sales.csv and tell me the top region by revenue." }];

while (true) {
  const response = await anthropic.messages.create({
    model: "claude-sonnet-4-5",
    max_tokens: 1024,
    tools,
    messages,
  });

  const toolUse = response.content.find((b) => b.type === "tool_use");
  if (!toolUse) break; // model produced a final answer

  const result = await sb.runCode(toolUse.input.code, { context: ctx.id });

  messages.push({ role: "assistant", content: response.content });
  messages.push({
    role: "user",
    content: [
      {
        type: "tool_result",
        tool_use_id: toolUse.id,
        content: result.error
          ? `Error: ${result.error.name}: ${result.error.message}`
          : result.text,
      },
    ],
  });
}

The same pattern works with OpenAI function calling, Gemini function declarations, or any framework that expects a plain (name, args) => result tool implementation, run_code is intentionally provider-agnostic, so the sandbox side never changes when you switch models.

Templates#

The interpreter ships as a set of pre-warmed templates so cold start doesn't dominate a tool call:

  • code-interpreter-python, Python 3, pandas, numpy, matplotlib, requests preinstalled
  • code-interpreter-node, Node 24, a scratch TypeScript context
  • code-interpreter-r, R with tidyverse

Pick any base sandbox template with sandal.sandboxes.create({ template }) and call runCode on it, the templates above just come pre-warmed with the common data-science stack so the first cell doesn't spend three seconds on pip install.

Everything else is a sandbox#

Because run_code is sugar over a sandbox, the full sandbox surface is available on the same instance whenever the model's tool call needs to reach outside the interpreter:

await sb.fs.write("/workspace/sales.csv", csvBuffer);
await sb.exec("pip install lightgbm");
const url = await sb.expose(8000); // e.g. the model started a local server to visualize results
await sb.pause();                  // stop billing between turns without losing context state

See Sandboxes for the full API, including fork, snapshots, and pause/resume semantics, everything there applies here unchanged.

Next steps#

  • Sandboxes, the underlying compute primitive
  • Simulators, for tool calls that need a real mobile device instead of a REPL
  • Pricing, per-second sandbox billing, paused = $0