# Sandboxed code execution as a model tool The most common sandbox integration: give a model a `run_python` tool whose implementation writes the generated code into an isolated VM, runs it, and returns the output. The model gets a real computer to verify its own answers on; your process never executes model-generated code. > **The shape is always the same** > > Whatever the provider or framework: define a tool with a `code` parameter, implement it as `files.write` + `exec` against a sandbox, loop until the model stops calling tools. Below is that pattern for the Anthropic API, the OpenAI API, and the Vercel AI SDK. ## The tool definition A single required `code` string. The description matters - it is what tells the model *when* to reach for the tool. **Python:** ```python RUN_PYTHON_TOOL = { "name": "run_python", "description": ( "Execute Python code in an isolated sandbox and return stdout, " "stderr and the exit code. Use this whenever the answer requires " "computation, data processing, or verifying code actually runs." ), "input_schema": { "type": "object", "properties": { "code": {"type": "string", "description": "Python source to execute"} }, "required": ["code"], }, } ``` **JavaScript:** ```javascript const RUN_PYTHON_TOOL = { name: "run_python", description: "Execute Python code in an isolated sandbox and return stdout, " + "stderr and the exit code. Use this whenever the answer requires " + "computation, data processing, or verifying code actually runs.", input_schema: { type: "object", properties: { code: { type: "string", description: "Python source to execute" }, }, required: ["code"], }, }; ``` ## The executor Create one sandbox per conversation and reuse it across tool calls - installed packages and files persist, so the model can build on its own previous steps. Return stderr and the exit code too: models fix their own bugs when they can see the traceback. **Python:** ```python from orkestr import Sandbox # One sandbox for the whole conversation: pip installs and files # persist across tool calls, and state accumulates like a REPL's. sbx = Sandbox.create(template="python-3.12", network="restricted") def run_python(code: str) -> str: sbx.files.write("/workspace/tool_call.py", code) r = sbx.exec("python /workspace/tool_call.py", timeout_seconds=60) return f"exit_code: {r.exit_code}\nstdout:\n{r.stdout}\nstderr:\n{r.stderr}" ``` **JavaScript:** ```javascript import { Sandbox } from "orkestr"; // One sandbox for the whole conversation: npm/pip installs and files // persist across tool calls, and state accumulates like a REPL's. const sbx = await Sandbox.create({ template: "python-3.12", network: "restricted", }); async function runPython(code) { await sbx.files.write("/workspace/tool_call.py", code); const r = await sbx.exec("python /workspace/tool_call.py", { timeoutSeconds: 60, }); return `exit_code: ${r.exitCode}\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`; } ``` ## Anthropic API Claude returns `tool_use` blocks and `stop_reason: "tool_use"`; you run the code and reply with a `tool_result` for each block, all in a single user message. **Python:** ```python import anthropic client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY messages = [{"role": "user", "content": "What is the 1000th prime number? Verify by computing it."}] while True: response = client.messages.create( model="claude-opus-4-8", max_tokens=16000, tools=[RUN_PYTHON_TOOL], messages=messages, ) if response.stop_reason != "tool_use": break # Echo the assistant turn, run each requested tool, send results back. messages.append({"role": "assistant", "content": response.content}) results = [] for block in response.content: if block.type == "tool_use" and block.name == "run_python": results.append({ "type": "tool_result", "tool_use_id": block.id, "content": run_python(block.input["code"]), }) messages.append({"role": "user", "content": results}) print(next(b.text for b in response.content if b.type == "text")) sbx.terminate() ``` **JavaScript:** ```javascript import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic(); // reads ANTHROPIC_API_KEY const messages = [ { role: "user", content: "What is the 1000th prime number? Verify by computing it.", }, ]; let response; while (true) { response = await client.messages.create({ model: "claude-opus-4-8", max_tokens: 16000, tools: [RUN_PYTHON_TOOL], messages, }); if (response.stop_reason !== "tool_use") break; // Echo the assistant turn, run each requested tool, send results back. messages.push({ role: "assistant", content: response.content }); const results = []; for (const block of response.content) { if (block.type === "tool_use" && block.name === "run_python") { results.push({ type: "tool_result", tool_use_id: block.id, content: await runPython(block.input.code), }); } } messages.push({ role: "user", content: results }); } console.log(response.content.find((b) => b.type === "text").text); await sbx.terminate(); ``` ## OpenAI API Same loop, different envelope: `tool_calls` on the assistant message, results as `role: "tool"` messages. **Python:** ```python import json from openai import OpenAI client = OpenAI() # reads OPENAI_API_KEY tools = [{ "type": "function", "function": { "name": "run_python", "description": RUN_PYTHON_TOOL["description"], "parameters": RUN_PYTHON_TOOL["input_schema"], }, }] messages = [{"role": "user", "content": "What is the 1000th prime number? Verify by computing it."}] while True: response = client.chat.completions.create( model="gpt-4o", tools=tools, messages=messages, ) msg = response.choices[0].message if not msg.tool_calls: break messages.append(msg) for call in msg.tool_calls: code = json.loads(call.function.arguments)["code"] messages.append({ "role": "tool", "tool_call_id": call.id, "content": run_python(code), }) print(msg.content) sbx.terminate() ``` **JavaScript:** ```javascript import OpenAI from "openai"; const client = new OpenAI(); // reads OPENAI_API_KEY const tools = [ { type: "function", function: { name: "run_python", description: RUN_PYTHON_TOOL.description, parameters: RUN_PYTHON_TOOL.input_schema, }, }, ]; const messages = [ { role: "user", content: "What is the 1000th prime number? Verify by computing it.", }, ]; let msg; while (true) { const response = await client.chat.completions.create({ model: "gpt-4o", tools, messages, }); msg = response.choices[0].message; if (!msg.tool_calls?.length) break; messages.push(msg); for (const call of msg.tool_calls) { const { code } = JSON.parse(call.function.arguments); messages.push({ role: "tool", tool_call_id: call.id, content: await runPython(code), }); } } console.log(msg.content); await sbx.terminate(); ``` ## Vercel AI SDK The framework runs the loop for you - hand it the executor and a step limit. **JavaScript:** ```javascript import { generateText, tool } from "ai"; import { anthropic } from "@ai-sdk/anthropic"; import { z } from "zod"; import { Sandbox } from "orkestr"; const sbx = await Sandbox.create({ template: "python-3.12" }); const { text } = await generateText({ model: anthropic("claude-opus-4-8"), tools: { run_python: tool({ description: "Execute Python code in an isolated sandbox and return the output.", parameters: z.object({ code: z.string() }), execute: async ({ code }) => { await sbx.files.write("/workspace/tool_call.py", code); const r = await sbx.exec("python /workspace/tool_call.py", { timeoutSeconds: 60, }); return `exit_code: ${r.exitCode}\n${r.stdout}${r.stderr}`; }, }), }, maxSteps: 8, prompt: "What is the 1000th prime number? Verify by computing it.", }); console.log(text); await sbx.terminate(); ``` ## Hardening checklist - **Network off unless the code needs it.** The default `network="off"` is right for pure computation; use `restricted` when the model should be able to `pip install`. See [Networking & egress](https://orkestr.eu/docs/sandboxes/networking). - **Cap every exec.** Model-written code loops forever more often than yours does - keep per-call `timeout_seconds` tight and catch `ExecTimeout`. - **Truncate huge outputs before they reach the model.** A print in a loop can produce megabytes; clip the tool result to what the model needs. - **Terminate in a finally block** (or use the context manager / `withTemp`) so a crashed loop doesn't leave a metered sandbox running. - **Preinstall heavy stacks with a** [custom template](https://orkestr.eu/docs/sandboxes/templates) - a data-analysis tool that pip-installs pandas on every conversation wastes the first tool call.