Run OpenAI Agents SDK Tools in a Cloud VM and Get Files Back
Updated October 27, 2026 .md
Short answer: Wrap sandboxer-sdk calls in @function_tool functions, so the agent's shell commands and file reads run in a cloud Ubuntu VM, and add a download tool that copies results to your machine. Or write no tool code at all: connect MCPServerStreamableHttp to https://sandboxer.com/mcp with a Bearer API key and get 18 tools.
This guide builds on the OpenAI Agents SDK docs page. It adds a file-download tool, a way to test your tools without an OpenAI key, and the hosted MCP server. We ran everything below on Sandboxer with openai-agents 0.24.0, sandboxer-sdk 0.1.1 and Python 3.13. We had no OpenAI key, so a scripted model stood in for the LLM. Section 4 explains exactly which part that covers.
Which way to connect#
Function tools (sandboxer-sdk) |
Hosted MCP (MCPServerStreamableHttp) |
Local MCP (MCPServerStdio + sbx mcp) |
|
|---|---|---|---|
| What the agent sees | Only the tools you write | 18 tools: create, exec, files, ports, snapshots, stop, delete | The same tools, plus handoff_session |
| Install | pip install sandboxer-sdk |
Nothing beyond openai-agents |
The sbx CLI on the host |
| Getting files back | Your own tool writes them to the host disk | read_file returns text into the model's context (up to 5 MiB) |
Same as hosted |
| Who decides the sandbox's size and lifetime | Your code | The model, through create_sandbox arguments |
sbx mcp --type --ttl defaults, which the model can override |
| Best for | Production agents with a fixed, narrow tool set | Trying it out, and agents that need the whole lifecycle | Desktop or CI hosts that already have sbx |
Version 0.24 of the Agents SDK also ships sandbox agents, with hosted clients for several providers (OpenAI docs). Sandboxer isn't one of those clients. Use function tools or MCP instead.
1. Install#
python3 -m venv .venv && . .venv/bin/activate
pip install openai-agents sandboxer-sdk
export SANDBOXER_API_KEY=sbx_live_…Install sandboxer-sdk, not sandboxer. The PyPI package named sandboxer is an unrelated project. To run a real model later, also set OPENAI_API_KEY.
2. Define the tools#
Save this as sandbox_tools.py. It creates one small sandbox (2 vCPU, 4 GB, $0.018/hour) the first time the agent calls a tool. Every later call reuses it.
import pathlib
from agents import Agent, function_tool
from sandboxer import AsyncSandboxer, SandboxerError
sandboxer = AsyncSandboxer() # reads SANDBOXER_API_KEY
sandbox = None # one sandbox per task, created on first use
async def get_sandbox():
global sandbox
if sandbox is None:
sandbox = await sandboxer.create(type="small", ttl_seconds=1800, name="agent-task")
return sandbox
def tool_error(ctx, error: Exception) -> str:
# The SDK's default hides the reason ("An error occurred while running the tool").
# Sandboxer errors are safe to show the model, so it can fix the path or command.
if isinstance(error, SandboxerError):
return f"error: {error}"
return "error: the tool failed"
@function_tool(failure_error_function=tool_error)
async def run_shell(command: str) -> str:
"""Run a bash command in the cloud sandbox (Ubuntu 24.04, cwd /home/user). Returns exit code, stdout and stderr."""
sb = await get_sandbox()
result = await sb.exec(command, timeout_seconds=300)
return f"exit code {result.exit_code}\n{result.stdout}{result.stderr}".strip()
@function_tool(failure_error_function=tool_error)
async def read_file(path: str) -> str:
"""Read a UTF-8 text file from the sandbox (up to 5 MiB). Relative paths are under /home/user."""
sb = await get_sandbox()
return await sb.files.read(path)
@function_tool(failure_error_function=tool_error)
async def download_file(path: str) -> str:
"""Copy a file (up to 50 MiB) from the sandbox into ./artifacts on the host and return where it was saved."""
sb = await get_sandbox()
data = await sb.files.download(path)
out = pathlib.Path("artifacts") / pathlib.Path(path).name # basename only: the model can't pick a host path
out.parent.mkdir(exist_ok=True)
out.write_bytes(data)
return f"saved {out} ({len(data)} bytes)"
agent = Agent(
name="Analyst",
instructions="Do all work in the sandbox with run_shell. Save results as files and return them with download_file.",
tools=[run_shell, read_file, download_file],
)How the tools split the work:
read_fileputs a file's text into the model's context. Use it for logs and small outputs the model must reason about.download_filewrites the bytes to your disk and gives the model only a one-line receipt. Use it for reports, CSVs, images and archives. The model never sees the contents, so they cost no tokens.tool_errorexists because, by default, the SDK hides why a tool failed. Without it, the model sees only "An error occurred while running the tool. Please try again." With it, the model sees Sandboxer's error message and can fix its next call.- The host path stays under your control.
download_filekeeps only the file's basename and saves it under./artifacts, so the model can't write anywhere else on your machine.
3. Test the tools without a model#
A FunctionTool has an on_invoke_tool(ctx, json_args) method. The Runner calls the same method, so calling it yourself tests the real tool code path with no LLM:
import asyncio
import json
from agents.tool_context import ToolContext
import sandbox_tools
from sandbox_tools import download_file, read_file, run_shell
async def call(tool, **args):
raw = json.dumps(args)
ctx = ToolContext(context=None, tool_name=tool.name, tool_call_id="manual-1", tool_arguments=raw)
return await tool.on_invoke_tool(ctx, raw) # the same entry point the Runner uses
async def main() -> None:
try:
print(await call(run_shell, command=(
"python3 -c \"import csv,platform,os; w=csv.writer(open('report.csv','w')); "
"w.writerow(['key','value']); w.writerow(['python',platform.python_version()]); "
"w.writerow(['cpus',os.cpu_count()])\" && ls -l report.csv"
)))
print(await call(read_file, path="report.csv"))
print(await call(download_file, path="report.csv"))
print(await call(read_file, path="missing.txt"))
finally:
if sandbox_tools.sandbox is not None:
print("sandbox", sandbox_tools.sandbox.id)
await sandbox_tools.sandbox.delete()
asyncio.run(main())$ python call_tools.py
exit code 0
-rw-r--r-- 1 user user 34 Oct 27 02:08 report.csv
key,value
python,3.12.3
cpus,2
saved artifacts/report.csv (34 bytes)
error: sandbox_direct_failed: no such file: /home/user/missing.txt (status 502) [request_id=req_SqZPHoFraw2G7JosNvx9]
sandbox sb_jrdd1ft5arThe whole script took about 8 seconds in our single run on October 27, 2026, including creating and deleting the sandbox. artifacts/report.csv is now on your machine. The last call shows what the model gets when it asks for a file that doesn't exist. Since a fix later on October 27, 2026, that call returns file_not_found with status 404 and isn't marked retryable (see API error codes). Python inside the sandbox is 3.12, whatever version runs on your host.
4. Run the agent#
With an OpenAI key, run the agent the usual way:
result = await Runner.run(agent, "Check the machine and save when you ran.")We had no OpenAI key, so we didn't run a real model. Instead we ran the Runner with the SDK's ScriptedModel (in agents.testing), which plays back the tool calls a model would make. The Runner, the tool dispatch, the sandbox and the download were all real. Only the model's decisions were scripted.
import asyncio
from agents import Runner
from agents.testing import ScriptedModel, assistant_message, function_call
import sandbox_tools
from sandbox_tools import agent
# Stands in for the LLM: each step is one model turn.
agent.model = ScriptedModel([
[function_call("run_shell", {"command": "uname -sr && nproc && free -h | head -2 && date -u > ran-at.txt"}, call_id="call_1")],
[function_call("download_file", {"path": "ran-at.txt"}, call_id="call_2")],
[assistant_message("Done. The timestamp file is in artifacts/ran-at.txt.")],
])
async def main() -> None:
result = await Runner.run(agent, "Check the machine and save when you ran.")
for item in result.new_items:
if item.type == "tool_call_output_item":
print(f"--- tool output\n{item.output}")
print("--- final_output\n" + result.final_output)
print("--- sandbox", sandbox_tools.sandbox.id)
asyncio.run(main())$ python scripted_run.py
--- tool output
exit code 0
Linux 6.18.56-sandboxer
2
total used free shared buff/cache available
Mem: 3.8Gi 304Mi 3.1Gi 352Ki 624Mi 3.5Gi
--- tool output
saved artifacts/ran-at.txt (32 bytes)
--- final_output
Done. The timestamp file is in artifacts/ran-at.txt.
--- sandbox sb_qdxatv1dvg
OPENAI_API_KEY is not set, skipping trace export
$ cat artifacts/ran-at.txt
Tue Oct 27 02:08:36 AM UTC 2026The last line of output is the SDK's tracing. With no OpenAI key it skips sending the trace. ScriptedModel is also a cheap way to test your tools in CI.
5. Get bigger outputs back, then clean up#
download_file handles single files up to 50 MiB. For whole folders or anything bigger, pull them with the sbx CLI once the run ends. This sandbox was left running on purpose:
sbx scp sb_qdxatv1dvg:/home/user/ran-at.txt ./
sbx scp -r sb_qdxatv1dvg:/home/user/out ./
sbx delete sb_qdxatv1dvg --yessbx scp prints nothing on success. The -r copy brought back an out/ folder (a 23,893-byte file we had made in the sandbox with sbx exec). In your own code, end each task in a finally block:
await sandbox.delete()removes the sandbox.await sandbox.stop()keeps its files and stops billing.
The ttl_seconds=1800 in get_sandbox is the safety net: if your process crashes, the sandbox stops itself after 30 minutes.
Use the hosted MCP server instead#
If you'd rather not write tools, point the SDK at Sandboxer's MCP server. It needs no install and no sbx binary on the host, which suits containers and serverless functions.
import asyncio
import os
from agents.mcp import MCPServerStreamableHttp
async def main() -> None:
async with MCPServerStreamableHttp(
name="sandboxer",
params={
"url": "https://sandboxer.com/mcp",
"headers": {"Authorization": f"Bearer {os.environ['SANDBOXER_API_KEY']}"},
},
client_session_timeout_seconds=900, # create_sandbox and long exec calls outlast the 5 s default
) as sandboxer:
tools = await sandboxer.list_tools()
print(len(tools), "tools")
for tool in tools:
print(f"{tool.name:16} {(tool.description or '').split('.')[0]}")
asyncio.run(main())$ python hosted_mcp.py
18 tools
create_sandbox Create a cloud sandbox (an Ubuntu microVM with root, Docker, git, Node and Python) and wait until it is ready
list_sandboxes List your sandboxes with their state, URL and labels
get_sandbox Show one sandbox: state, URL, when it stops automatically, labels and stored bytes
exec Run a shell command (bash -c) in the sandbox
command_status Status of a detached command: running or its exit code, and its output
kill_command Stop a detached command
read_file Read a file from the sandbox (up to 5 MiB)
write_file Create or overwrite a file in the sandbox (up to 5 MiB)
host_port Serve a port of the sandbox on a stable public HTTPS URL
…
delete_sandbox Delete a sandbox permanently: its disk, memory and automatic snapshots go (named snapshots saved from it stay)
save_snapshot Save the sandbox as a named snapshot to create new sandboxes from, and wait until it is saved
list_snapshots List your named snapshots: name, version, labels, created time, stored bytes (what counts toward your storage) and disk size (logical)
delete_snapshot Delete one named snapshot (one version, ePass the server to an agent with Agent(..., mcp_servers=[sandboxer]) inside the async with block. To offer only some tools, add a filter. This version lists exactly five:
from agents.mcp import create_static_tool_filter
MCPServerStreamableHttp(
...,
tool_filter=create_static_tool_filter(
allowed_tool_names=["create_sandbox", "exec", "read_file", "write_file", "stop_sandbox"]
),
)
# list_tools() → ['create_sandbox', 'exec', 'read_file', 'write_file', 'stop_sandbox']Over MCP, read_file returns a file's text to the model, which is fine for small outputs. To get binary files or large results onto your disk, pair MCP with a download_file function tool from section 2, or use sbx scp after the run. For a headless agent, use a scoped API key (sbx api-key create agent --preset ci: it can create sandboxes and delete only the ones it created), so the agent can't touch the rest of your account.
FAQ#
Can I test OpenAI Agents SDK tools without an OpenAI API key?#
Yes. Call tool.on_invoke_tool(ToolContext(...), json_args) to run one tool directly. To run the full Runner loop, use ScriptedModel from agents.testing, which plays back tool calls you script. Both run your real tool code against a real sandbox.
How large a file can the agent send back?#
files.download in sandboxer-sdk returns up to 50 MiB. files.read and the MCP read_file tool return up to 5 MiB. For folders or bigger files, use sbx scp -r from the host, which copies over SSH instead of the file API.
Why does my tool error just say "An error occurred while running the tool"?#
That's the Agents SDK's default failure_error_function, which hides exception details from the model. Pass your own function to @function_tool(failure_error_function=...) to return a message the model can act on.
Does the hosted MCP server work without installing the sbx CLI?#
Yes. MCPServerStreamableHttp connects to https://sandboxer.com/mcp over HTTPS with your API key in the Authorization header. Only openai-agents needs to be installed.
What does a run cost?#
A small sandbox is $0.018/hour and a default is $0.036/hour, billed per second while running. A stopped sandbox costs nothing. Model usage is billed by OpenAI on your own key.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.