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 belowdf 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 }); // 2from 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 preinstalledcode-interpreter-node, Node 24, a scratch TypeScript contextcode-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 stateSee 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