Agents

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.

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 on the code-interpreter template and reuse it across tool calls: every call runs in the same persistent interpreter, so the dataframe the model loaded in call one is still there in call five. Return the exception when the code raises - models fix their own bugs when they can see the traceback - and remember the kernel keeps its state through an exception.

executor
from orkestr import Sandbox

# One sandbox per conversation, on the code-interpreter template: every
# tool call runs in the SAME Python interpreter, so variables, imports
# and dataframes persist across calls - a real REPL for the model.
sbx = Sandbox.create(template="code-interpreter", network="restricted")

def run_python(code: str) -> str:
    execution = sbx.run_code(code, timeout_seconds=60)
    if execution.error:  # the code raised; state up to the raise is kept
        return f"{execution.error.name}: {execution.error.value}\n" + \
            "\n".join(execution.error.traceback[-5:])
    parts = [execution.stdout, execution.text or ""]
    return "\n".join(p for p in parts if p) or "(no output)"

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.

anthropic
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.

openai
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.

vercel-ai
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();

LangChain

Wrap the executor in a @tool (Python) or tool() (JS) and hand it to any LangChain or LangGraph agent - the docstring/description does the same job the tool definition does above.

langchain
from langchain_core.tools import tool
from orkestr import Sandbox

sbx = Sandbox.create(template="code-interpreter", network="restricted")

@tool
def run_python(code: str) -> str:
    """Execute Python in a persistent sandboxed interpreter. Variables
    and imports persist across calls. Returns stdout and the value of
    the final expression; on an exception, returns the traceback."""
    execution = sbx.run_code(code, timeout_seconds=60)
    if execution.error:
        return f"{execution.error.name}: {execution.error.value}"
    return (execution.stdout + (execution.text or "")) or "(no output)"

# Works with any LangChain/LangGraph agent:
#   from langgraph.prebuilt import create_react_agent
#   agent = create_react_agent(model, tools=[run_python])

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.
  • Cap every call. Model-written code loops forever more often than yours does - keep per-call timeout_seconds tight. On the interpreter a timeout interrupts the cell but keeps the kernel's variables, so the model can retry without losing its state.
  • 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. The code-interpreter template ships pandas, NumPy and matplotlib already; for anything beyond that, bake it into a custom template rather than pip-installing on every conversation.
Reading with an agent? This page is also plain markdown at /docs/sandboxes/llm-tools.md, and the full docs index lives at /docs/llms.txt.