OpenAI Agents SDK sandbox
The OpenAI Agents SDK runs a SandboxAgent's shell, file and patch tools inside a sandbox. A sandbox client supplies it. mandala_computer.openai_agents is that client for Mandala: each sandbox session is one Linux computer, made when the run needs it and deleted when the run ends.
The agent stays the same as with the SDK's local or Docker clients. Only the run config changes. The model loop runs in your process, against OpenAI. The commands the model asks for run on the computer.
This is not computer use. The agent gets a shell, not a screen. To drive the desktop with screenshots, see the OpenAI computer use guide.
Before you start
- A Mandala API key in
MANDALA_API_KEY(Settings → Credentials → API keys), or a savedmandala loginprofile. - An OpenAI key in
OPENAI_API_KEY. The Agents SDK reads it, not Mandala. - Python 3.10 or newer.
pip install "mandala-computer[openai-agents]"The extra installs openai-agents 0.24. That is the release line the client is tested against, so the extra pins >=0.24,<0.25.
A complete program
import asyncio
import sys
from agents import Runner
from agents.run import RunConfig
from agents.sandbox import SandboxAgent, SandboxRunConfig
from agents.sandbox.capabilities import Shell
from mandala_computer.openai_agents import (
MandalaSandboxClient,
MandalaSandboxClientOptions,
)
agent = SandboxAgent(
name="builder",
instructions="Work in the workspace. Run what you write, and say what it printed.",
capabilities=[Shell()],
)
async def main(task):
# The Mandala key comes from MANDALA_API_KEY or a saved profile.
# The model key is the Agents SDK's own: OPENAI_API_KEY.
async with MandalaSandboxClient() as client:
result = await Runner.run(
agent,
task,
max_turns=30,
run_config=RunConfig(
sandbox=SandboxRunConfig(
client=client,
options=MandalaSandboxClientOptions(size="small"),
),
),
)
return result.final_output
if __name__ == "__main__":
task = " ".join(sys.argv[1:]) or "Write a Python script that prints the first ten primes, and run it."
print(asyncio.run(main(task)))Run it with a task:
python builder.py "Write a script that counts the words in /etc/os-release, and run it."What happens:
- The runner asks the client for a session. The client creates a computer, named
agents-<session id>, and waits for it to answer. - The model's shell calls run on the computer as
root, in/workspace. - When the run ends, the runner saves the workspace to its snapshot store, and the client deletes the computer.
max_turns bounds the run: each model call is a turn. The agent uses the Agents SDK's default model. Set model= on the SandboxAgent to choose another.
MandalaSandboxClient() makes its own AsyncClient and closes it at the end of the async with. Pass one you already have, MandalaSandboxClient(client), and it stays yours to close. Closing the sandbox client also shuts down any session that was never closed, as its on_shutdown says.
Options
MandalaSandboxClientOptions decides what computer a session gets. Every field is optional, and options can be left out of SandboxRunConfig.
| Option | Default | What it does |
|---|---|---|
size | the template's | A catalogue shape from client.sizes.list(). Not with template, cpu, ram_mb or disk_gb. |
template, cpu, ram_mb, disk_gb | the template's | The same as for computers.create(). |
name | agents-<session> | The computer's name. The default names it after the session, so a computer left behind can be traced. |
workspace_id | none | The same as for computers.create(). |
ends_in_hours | 24 | A safety end time. The platform stops the computer then, keeping its disk, even if your process is gone. None for none. |
on_shutdown | "delete" | What happens to the computer when the session ends: "delete", "stop" or "suspend". |
exposed_ports | none | Ports the agent's tools may serve through public URLs. |
public_url_access | the plan's | "public" or "private" for those URLs. |
launch_timeout_s | 300 | How long creating or restarting a computer may take to answer. |
Unknown fields are refused. So is a size with explicit numbers, a port outside 1 to 65535, port 22, and an end time that is not between 0 and 8784 hours.
The end time is sent with the create. Even a computer your process leaves behind stops on its own.
Shutdown and resuming
When the runner owns the session, it saves the workspace snapshot, shuts the session down and calls the client's delete(). Shutting down applies on_shutdown once.
"delete", the default, deletes the computer and its disk, but only after a confirmed save of everything the session did. If the save failed, or a command came after it, the computer is stopped instead and keeps its disk."stop"keeps the disk. A resume starts the same computer again, and sets its end time again."suspend"keeps the disk and the memory, so running processes pick up where they were. A computer that cannot be suspended at that moment is stopped.
Files written by background processes after the last save are lost with the computer on a delete. Use "stop" when you mean to come back.
To keep one computer across runs, make the session yourself, save its state, and resume it later. The state holds the computer's id and how to make another. It holds no key, so another process with its own key can resume it.
The state does not hold the files. They live on the computer's disk, which "stop" keeps, and in the snapshot store, if you give the session one. Closing the session saves the workspace to that store. Without a store, deleting the computer loses the workspace for good, and a resume makes an empty one. So use on_shutdown="stop" when you mean to resume, and a snapshot store as well if you want the files back even after the computer is gone.
import json
from pathlib import Path
from agents.sandbox import LocalSnapshotSpec, Manifest
options = MandalaSandboxClientOptions(size="small", on_shutdown="stop")
snapshots = LocalSnapshotSpec(base_path=Path("snapshots")) # a directory on your machine
async with MandalaSandboxClient() as client:
session = await client.create(manifest=Manifest(), options=options, snapshot=snapshots)
async with session:
await Runner.run(agent, "Write notes.txt", run_config=RunConfig(sandbox=SandboxRunConfig(session=session)))
# Closing saved the workspace to snapshots/ and stopped the computer.
saved = json.dumps(client.serialize_session_state(session.state))
# Later, or in another process:
session = await client.resume(client.deserialize_session_state(json.loads(saved)))
async with session: # starts the same computer; notes.txt is still on its disk
await Runner.run(agent, "Read notes.txt", run_config=RunConfig(sandbox=SandboxRunConfig(session=session)))If the computer was deleted in between, the resume makes a new one from the same options and restores notes.txt from snapshots/.
A stopped computer keeps its disk on your plan until you delete it.
The disk wins. While the computer exists, its disk is the truth about the workspace. A resume onto the same computer never restores a snapshot over it, so files the agent edited stay edited. A snapshot is used only to rebuild a computer that is gone: a resume after "delete" makes a new computer from the same options and restores the workspace from the snapshot. With no snapshot store, that new computer starts with an empty workspace.
A resume reattaches a running computer as it is, starts a stopped or suspended one, and replaces one that is gone. If the platform cannot be reached, or the key cannot see the computer, the resume fails rather than making another.
A computer also suspends itself after the host's idle window, 30 minutes by default. The next command or file transfer resumes it in about a second.
Exposed ports
A port the agent serves can be reached through a public URL. Name it in exposed_ports, then resolve it from the session. The first resolve makes the URL, or reuses one the computer already has for that port. The endpoint is HTTPS on 443.
from agents.sandbox import Manifest
options = MandalaSandboxClientOptions(exposed_ports=(8000,))
async with MandalaSandboxClient() as client:
session = await client.create(manifest=Manifest(), options=options)
async with session:
await session.exec("nohup python3 -m http.server 8000 >/dev/null 2>&1 &")
endpoint = await session.resolve_exposed_port(8000)
print(endpoint.url_for("http")) # https://<label>.<base_domain>/
await client.delete(session)- The server must listen on
0.0.0.0, not127.0.0.1. - A private URL needs an account API key (
Authorization: Bearer) or a signed-in browser to open. - A port not in
exposed_portsis refused. A plan without public URLs, or one at its limit, raisesExposedPortUnavailableErrorwith the platform'scodein itscontext. - URLs the session made are removed when it shuts down. URLs that already existed are left alone.
What it supports and refuses
- Commands run as
root, with no display, in the workspace root (/workspaceunless the manifest sets another). A command may run longer than one HTTP request. A timeout or a cancellation stops it rather than leaving it running. - Files go through the computer's file API. A symlink that points out of the workspace is refused.
- Snapshots are a tar of the workspace, so any snapshot store the SDK offers works. A symlink that points outside the workspace, such as a virtualenv's link to the system Python, cannot be saved, and the save fails naming it. A workspace over 2 GiB is refused rather than saved.
- Refused before anything is created: manifest users and groups, mount entries, and path grants with a
host_path. - Refused when asked for: a
userother thanroot, and PTY sessions. The shell tool then runs each command once, without an interactive session. - Nothing ever falls back to running on your own machine.
Errors are the SDK's own. A failed create or resume is a WorkspaceStartError; a command that could not be run or read is an ExecTransportError; a timeout is an ExecTimeoutError; a missing file is a WorkspaceReadNotFoundError. Each keeps the platform's status and code in context.
A failure to stop or delete at shutdown is logged on the mandala_computer.openai_agents logger with the computer's id, not raised over the run's own result. The end time is the backstop for that.
See also: Troubleshooting.