# Run OpenAI Agents SDK Tools in a Cloud VM and Get Files Back

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

Source: https://sandboxer.com/guides/run-openai-agents-sdk-tools-in-a-cloud-vm. Updated 2026-10-27.

**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](/docs/openai-agents). 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](https://openai.github.io/openai-agents-python/sandbox/clients/)). Sandboxer isn't one of those clients. Use function tools or MCP instead.

## 1. Install

```bash
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.

```python title="sandbox_tools.py"
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_file`** puts a file's text into the model's context. Use it for logs and small outputs the model must reason about.
- **`download_file`** writes 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_error`** exists 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_file` keeps 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:

```python title="call_tools.py"
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())
```

```text
$ 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_jrdd1ft5ar
```

The 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](/guides/sandboxer-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:

```python
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.

```python title="scripted_run.py"
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())
```

```text
$ 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 2026
```

The 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](/docs/cli) once the run ends. This sandbox was left running on purpose:

```bash
sbx scp sb_qdxatv1dvg:/home/user/ran-at.txt ./
sbx scp -r sb_qdxatv1dvg:/home/user/out ./
sbx delete sb_qdxatv1dvg --yes
```

`sbx 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](/docs/mcp). It needs no install and no `sbx` binary on the host, which suits containers and serverless functions.

```python title="hosted_mcp.py"
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())
```

```text
$ 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, e
```

Pass 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:

```python
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](/docs/api-keys) (`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

- [OpenAI Agents SDK](/docs/openai-agents)
- [MCP server](/docs/mcp)
- [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox)
- [Python SDK](/docs/sdk-python)
