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.
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.
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.
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}"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.
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()OpenAI API
Same loop, different envelope: tool_calls on the assistant message, results as role: "tool" messages.
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()Vercel AI SDK
The framework runs the loop for you - hand it the executor and a step limit.
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; userestrictedwhen the model should be able topip install. See Networking & egress. - Cap every exec. Model-written code loops forever more often than yours does - keep per-call
timeout_secondstight and catchExecTimeout. - 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 - a data-analysis tool that pip-installs pandas on every conversation wastes the first tool call.