Just like OpenRouter gives you a single API across LLM providers, bespokelabs-sandbox gives you a unified interface across sandbox providers. Write your code once, swap backends with a single parameter.
- No lock-in — Your code works across all backends. Switch providers without rewriting a single line.
- Easily move between providers — If one provider has an outage or capacity issue, change one string and keep running.
- Cost tracking — Track Claude Code token usage, estimated sandbox compute cost, and compare backend pricing.
- Automatic scheduling to lowest cost provider — Let the library route your workloads to the cheapest available backend. (coming soon)
pip install bespokelabs-sandboxWith a specific backend:
pip install bespokelabs-sandbox[docker]
pip install bespokelabs-sandbox[daytona]
pip install bespokelabs-sandbox[tensorlake]
pip install bespokelabs-sandbox[modal]
pip install bespokelabs-sandbox[e2b]
pip install bespokelabs-sandbox[ray]
pip install bespokelabs-sandbox[all]The RunPod backend has no Python extra; it uses the system OpenSSH client. The Safehouse backend also has no Python extra. Install its CLI separately on macOS:
brew install eugene1g/safehouse/agent-safehouseNo API keys, no cloud accounts. Just works.
| Backend | Extra | Requires |
|---|---|---|
| Local subprocess | (none) | Python installed |
| Agent Safehouse | (none) | macOS + safehouse CLI |
| Docker | [docker] |
Docker daemon running |
| Ray | [ray] |
Ray installed (local or remote cluster) |
| Backend | Extra | Auth |
|---|---|---|
| Daytona | [daytona] |
DAYTONA_API_KEY |
| Tensorlake | [tensorlake] |
tl login |
| Modal | [modal] |
MODAL_TOKEN_ID + MODAL_TOKEN_SECRET |
| RunPod | (none) | RUNPOD_API_KEY + registered SSH key |
| E2B | [e2b] |
E2B_API_KEY |
You only need to install the backend you use. The others are lazily imported.
from bespokelabs.sandbox import Sandbox
# Zero setup — runs locally
with Sandbox("local") as sb:
result = sb.execute_code('print("hello")')
print(result.stdout)
# Or use Safehouse on macOS
with Sandbox("safehouse") as sb:
result = sb.execute_code('print("hello from safehouse")')
print(result.stdout)
# Or use Docker
with Sandbox("docker") as sb:
result = sb.execute_code('print("hello from a container")')
print(result.stdout)
# Or any cloud provider — same interface
with Sandbox("e2b") as sb:
result = sb.execute_code('print("hello from the cloud")')
print(result.stdout)Switch backends by changing one string:
for backend in [
"local", "safehouse", "docker", "modal", "runpod",
"e2b", "daytona", "tensorlake", "ray",
]:
options = {"gpu": "NVIDIA L4"} if backend == "runpod" else {}
with Sandbox(backend, **options) as sb:
sb.execute_code('print("same code, any backend")')The optional control plane lets a customer use one Bespoke API key across all of the providers you operate. Provider credentials remain on the server, and every execution is automatically attributed to the customer's organization and sandbox.
Install only the provider adapters enabled by this deployment. For a local and E2B development server:
pip install 'bespokelabs-sandbox[server,e2b]'From a source checkout, use:
pip install -e '.[server,e2b]'Provider credentials belong on the server. Customers never receive them.
export BESPOKE_API_KEY_PEPPER='replace-with-a-long-random-secret'
export BESPOKE_CONTROL_PLANE_ADMIN_TOKEN='replace-with-another-random-secret'
export BESPOKE_CONTROL_PLANE_DB='/absolute/path/to/bespoke-control-plane.db'
export BESPOKE_ALLOWED_BACKENDS='local,e2b'
export BESPOKE_CUSTOMER_MARKUP='1.20'
export BESPOKE_SUPERVISION_INTERVAL_SECS='30'
export BESPOKE_SESSION_COOKIE_SECURE='1'
export BESPOKE_DASHBOARD_SESSION_TTL_SECS='28800'
# Provider credential — server-side only.
export E2B_API_KEY='your-e2b-api-key'
bespokelabs-sandbox-apiVerify that it is running:
curl http://127.0.0.1:8000/healthz
# {"status":"ok"}BESPOKE_API_KEY_PEPPER and BESPOKE_CONTROL_PLANE_DB are persistent server
identity. Keep both values unchanged across restarts. Changing the pepper or
pointing at another database makes previously issued product keys invalid.
The backend allowlist is also read at startup, so restart the server after
changing BESPOKE_ALLOWED_BACKENDS.
Provider configuration is server-owned. The control plane reads the standard
Daytona, E2B, Modal, Runpod, and Tensorlake credential variables into memory;
it never returns their names or values through the product API. Scoped
provider-health responses expose only the backend, configured state, health
state, check time, and a fixed safe message. A configured provider remains
unchecked unless a deployment-supplied health checker actually runs; only a
successful check is reported as healthy.
The stock bespokelabs-sandbox-api CLI wires provider settings for safe
configuration visibility only. It does not construct provider health
checkers, reconcilers, or out-of-process terminators. Consequently, stock-CLI
provider health remains unchecked, reconciliation is unavailable, and a
restarted process cannot reclaim a real cloud resource whose in-memory runtime
was lost. A production deployment requiring those capabilities must instantiate
ControlPlane in deployment code and inject provider-specific health-check,
reconciler, and terminator adapters after testing them against that provider.
Issue an organization's initial product key through the protected bootstrap
endpoint. The returned secret is shown only in this response:
curl -X POST http://127.0.0.1:8000/v1/organizations \
-H "X-Control-Plane-Admin: $BESPOKE_CONTROL_PLANE_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Acme"}'Copy the returned value beginning with bsk_live_. That is the customer-facing
product key. It is different from E2B_API_KEY and works across every backend
enabled by the server.
Set the product key in the client environment:
export BESPOKE_SANDBOX_KEY='bsk_live_...'Then create and use a sandbox through the remote client:
import os
from bespokelabs.sandbox import RemoteSandboxClient
client = RemoteSandboxClient(
"http://127.0.0.1:8000",
os.environ["BESPOKE_SANDBOX_KEY"],
)
with client.create(
"e2b",
timeout_secs=60,
idempotency_key="example-launch-001",
) as sandbox:
result = sandbox.execute_code(
'print("hello from E2B")',
idempotency_key="example-job-001",
)
print(result.stdout)
print(result.usage)
# The same key can create a different provider's sandbox.
with client.create("local") as sandbox:
result = sandbox.execute_command("python", ["--version"])
print(result.stdout)
costs = client.costs(group_by="backend")
print(costs)The context manager destroys each sandbox automatically. Reusing an
idempotency_key on either creation or execution returns the original logical
operation without creating, running, or billing it twice. Reusing a creation
key with a different request is rejected.
Provider failures expose a stable error contract in both HTTP responses and
RemoteSandboxError: code, backend, op, retryable, outcome, and a
small, redacted context. Sandbox responses include attempt_count,
latest_error, retry_status, cleanup_status, and provider_resource_id.
An ambiguous create has outcome="unknown" and
retry_status="blocked_cleanup_unknown"; do not issue a new provider create
until the possible orphan has been reconciled manually.
Sandbox records also expose requested, provisioning, running, stopping,
terminated, failed, and last-provider-observation timestamps. Lifecycle cost
uses the rate, currency, and pricing-source snapshot captured at creation and
accrues across the whole provider-billable window, including failed setup and
cleanup. GET /v1/reconciliation returns tenant-scoped health; an operator can
run a configured provider reconciler with
POST /v1/reconciliation/{backend} when the deployment injected that backend's
reconciler. Provider-reported cost replaces the
estimate through an idempotent delta ledger, so repeated observations do not
double-count spend. Billable-time deltas are split at UTC day boundaries, so
refreshes and later provider-cost adjustments do not move historical usage
between reporting days.
Sandbox responses also include the creating API-key ID/name and a numeric
version. GET /v1/sandboxes/{id}/detail returns the tenant-scoped creation
attempts, executions, lifecycle cost, and provider-observation history. A
termination request may send that version in If-Match; stale versions are
rejected with 409, while repeating a successful termination remains
idempotent. GET /v1/session reports the current key's scopes and whether the
dashboard may show termination controls.
Organization guardrails are managed with GET and PUT requests to
/v1/policies/current. They can cap concurrent sandboxes, rolling-hour and
UTC-day customer spend, allowed backends, allowed GPU types, and requested
sandbox lifetime. Each create request reserves its concurrency slot and checks
all limits in one database transaction before any provider call. A denial is a
stable, non-retryable policy_denied response and is attributed to the API key
that made the request. GET /v1/policy-summary returns current quota/spend and
recent denials. Redacted provider health is available from GET /v1/providers
and POST /v1/providers/{backend}/health-check.
The control-plane supervisor terminates expired sandboxes even when the client
disconnects. Its durable claim state is safe across process restarts, and
configured provider terminators can clean up resources whose in-memory runtime
was lost. Reconciliation watchdogs also remove provider resources that do not
belong to any recorded sandbox, with durable idempotency so a confirmed cleanup
is not repeated. Set BESPOKE_SUPERVISION_INTERVAL_SECS to tune the scan
interval. These restart-recovery and reconciliation behaviors require injected
terminator/reconciler adapters; the stock CLI does not supply them.
Open http://127.0.0.1:8000/dashboard and
enter the bsk_live_... product key. The initial organization key has every
scope. A read-only dashboard key needs both usage:read and
sandboxes:read.
The dashboard provides:
- operational cards for active resources, failed launches, unreconciled spend, and resources nearing their TTL;
- budget/quota progress, backend/GPU/lifetime policy, provider health, and recent policy denials when the key has the corresponding read scopes;
- status, provider, and creation-date filtering with pagination;
- creator key, provider resource, compute/GPU, rate, age/TTL, cleanup, and reconciliation state for every sandbox;
- a detail view for lifecycle, attempts, executions, errors, cost, and provider observations;
- confirmed, revision-safe termination for keys with
sandboxes:terminate; legacysandboxes:writekeys remain compatible; and - automatic refresh while the tab is visible, plus manual refresh.
The production dashboard exchanges the product key for an expiring, HTTP-only,
Secure, SameSite=Strict cookie and never stores that key in browser storage.
State-changing requests carry a session-bound CSRF token. For an explicitly
enabled local-only workflow, set BESPOKE_ENABLE_LOCAL_DASHBOARD_LOGIN=1, set
BESPOKE_SESSION_COOKIE_SECURE=0 only when serving plain HTTP on loopback, and
open /dashboard/local; that development route keeps the key in the current
tab's sessionStorage. Never expose that route on a shared network. Local
sandboxes record runtime but have a provider cost of $0; use a priced fake or
cloud backend to exercise spend counters.
The Activity view shows durable alerts and append-only audit history. Operators
with exports:read can download paginated CSV pages for usage, lifecycle costs,
and ledger entries. Alert thresholds and retention periods are tenant-owned and
available through the API.
API keys, browser session cookies, and CSRF tokens are HMAC-hashed at rest.
Runtime scopes are sandboxes:read,
sandboxes:create, sandboxes:execute, and sandboxes:terminate; governance
uses usage:read, policies:read, policies:write, providers:read, and
providers:write; reconciliation uses providers:reconcile; and key issuance
uses keys:write. Operational access uses alerts:read, alerts:write,
audit:read, exports:read, retention:read, and retention:write. The legacy
sandboxes:write scope grants the four sandbox write operations and provider
reconciliation for compatibility. Runtime events and provider/customer costs
are written to an idempotent ledger. All responses carry a restrictive content
security policy and browser security headers. See the
operations runbook and
hosted API contract before deployment.
Never commit the API-key pepper, admin token, provider credentials, or product keys. Revoke and replace a product key if it appears in source code, logs, or chat. Environment-variable ownership is:
| Value | Where it belongs |
|---|---|
E2B_API_KEY and other provider credentials |
Control-plane server only |
BESPOKE_API_KEY_PEPPER |
Control-plane server only; stable across restarts |
BESPOKE_CONTROL_PLANE_ADMIN_TOKEN |
Administrative bootstrap tooling only |
bsk_live_... / BESPOKE_SANDBOX_KEY |
Customer client or dashboard |
Common errors:
| Error | Resolution |
|---|---|
backend is not enabled: e2b |
Add e2b to BESPOKE_ALLOWED_BACKENDS and restart the server. |
invalid API key |
Confirm the full bsk_live_... value, database path, and original pepper. |
| Provider authentication failure | Install that provider's extra and configure its credential on the server. |
from bespokelabs.sandbox import Sandbox
sb = Sandbox(
backend, # "local" | "safehouse" | "docker" | "ray" | "daytona" | "tensorlake" | "modal" | "runpod" | "e2b"
*,
preset=None, # Preset name or SandboxPreset object
cpu=1.0, # vCPUs (Tensorlake, Modal, Docker, Daytona, RunPod)
memory_mb=1024, # RAM in MB (Tensorlake, Modal, Docker, Daytona, RunPod)
disk_mb=None, # Disk in MB (Daytona, RunPod)
gpu=None, # GPU type/count (Modal, RunPod)
timeout_secs=600, # Max lifetime / subprocess timeout
image=None, # Container image (Docker, Modal, Daytona, RunPod)
template=None, # Template ID (E2B, RunPod)
env_vars=None, # dict of environment variables
allow_internet=True, # Network access (Docker, Tensorlake, Daytona)
app_name=None, # App name (Modal)
snapshot_id=None, # Restore from snapshot (Tensorlake, Modal)
workdir=None, # Sandbox root or command working directory
backend_options=None, # dict merged into the backend's native create call
files=None, # {path: bytes|str} written into the sandbox on create
git_repo=None, # repo URL cloned into the sandbox on create
git_ref=None, # branch/tag for git_repo
workspace=None, # Manifest of files/dirs/repos to materialize on create
)Not every backend uses every parameter. Most unsupported parameters are ignored; a backend rejects values it cannot safely honor, such as network isolation on RunPod.
Modal GPU sandboxes accept Modal's GPU reservation strings, including a GPU
type such as gpu="L4" or a type and count such as gpu="H100:2":
with Sandbox("modal", gpu="A100") as sb:
result = sb.execute_command("nvidia-smi")
print(result.stdout)GPU sandboxes can be preempted, so GPU workloads should tolerate interruption.
RunPod uses exact GPU type IDs and requires a GPU selection. Its official PyTorch image is used by default because it includes SSH support:
with Sandbox(
"runpod",
gpu="NVIDIA H100 80GB HBM3:2",
backend_options={"ssh_private_key_path": "~/.ssh/id_ed25519"},
) as sb:
result = sb.execute_command("nvidia-smi")
print(result.stdout)For capacity fallback, pass RunPod's native creation fields through
backend_options, such as a gpuTypeIds list containing "NVIDIA L40S" and
"NVIDIA RTX A6000", with gpuTypePriority="availability". Custom images and
templates must run an SSH daemon on port 22. Add the matching public key to the
RunPod account before creating a sandbox. ssh_private_key_path is optional
when the key is already discoverable by OpenSSH.
timeout_secs is a command timeout on Local, Ray, and RunPod; a sandbox
timeout on E2B and Modal; and on Daytona a wall-clock ttl_minutes deadline.
RunPod creation has a separate 10-minute default readiness timeout, adjustable
with backend_options={"create_timeout_secs": ...}. Daytona
destroys the sandbox when that deadline elapses in whatever state it is in,
running work included; no activity resets the clock, and the clock starts at
creation, so image pull and boot count against it. The mapping happens only
when you pass timeout_secs yourself. Omit it — including when a preset
supplies its own recommended value — and the Daytona sandbox gets no TTL at
all, leaving it bounded only by Daytona's default 15-minute idle auto-stop.
Constructing a Sandbox creates the underlying sandbox immediately. To launch
many sandboxes on one backend, or to use async/await, see
Reusing a client across many sandboxes
and Async.
result = sb.execute_code('print(1 + 1)', language="python")
print(result.stdout) # "2"
print(result.stderr) # ""
print(result.exit_code) # 0language defaults to "python". Daytona also supports "typescript", "javascript", "ruby", and "go". Safehouse, Docker, Tensorlake, Modal, Local, and Ray accept any installed binary name.
result = sb.execute_command("ls -la /tmp")
result = sb.execute_command("grep", args=["-r", "TODO", "/app"])# List files
files = sb.list_files("/home")
for f in files:
print(f.path, f.is_dir, f.size)
# Read / write in-memory content
sb.write_file("/tmp/config.json", '{"key": "value"}')
data = sb.read_file("/tmp/config.json") # returns bytes
# Upload a local file into the sandbox
sb.upload_file("./local_data.csv", "/home/user/data.csv")
# Download a file from the sandbox to local disk
sb.download_file("/home/user/results.json", "./results.json")
# Move a whole directory tree in or out (preserves structure + executable bits).
# Defaults to a single tar.gz transfer, falling back to a per-file loop.
sb.upload_dir("~/.claude/skills/my-skill", ".claude/skills/my-skill")
sb.download_dir("/workspace/output", "./results")Directory transfer is built on the single-file primitives above, so it works on every backend. To seed a tree before the sandbox boots (e.g. so preset setup sees it), use
build_files_map(local, remote)withSandbox(files=...). Seeexamples/move_files_into_sandbox.py.
files=, git_repo=, and upload_dir() cover the imperative cases. For
anything richer, pass a Manifest — a typed, ordered map of destination →
source materialized at creation (built on the primitives above, so it works on
every backend):
from bespokelabs.sandbox import Sandbox, Manifest, GitRepo, LocalDir, LocalFile, File
with Sandbox("daytona", workspace=Manifest(entries={
"repo": GitRepo("https://github.com/org/proj", ref="main"),
".claude/skills/pirate": LocalDir("~/.claude/skills/talk-like-a-pirate"),
"data/seed.csv": LocalFile("./seed.csv"),
"config.json": File('{"env": "prod"}'),
})) as sb:
...Entries materialize in insertion order, so a later one overlays an earlier one.
GitRepo clones, LocalDir / LocalFile upload (preserving the executable
bit), and File writes in-memory content. Subclass WorkspaceEntry for custom
sources, or call manifest.apply(sb) on a live sandbox.
Every failure is a SandboxError subclass carrying a machine-readable code,
the backend and op in flight, a retryable flag, and a context dict — so
you can branch on it instead of parsing message strings:
from bespokelabs.sandbox import Sandbox, SandboxError
try:
with Sandbox("daytona") as sb:
sb.execute_command("…")
except SandboxError as e:
if e.retryable: # transient (timeout / connection) — back off and retry
...
print(e.code, e.backend, e.op, e.context)Subtypes include SandboxConfigurationError, SandboxCreationError,
CommandFailedError (with exit_code / stdout / stderr),
SandboxTimeoutError and SandboxConnectionError (both retryable),
WorkspaceError, BackendNotInstalledError, and FeatureNotSupportedError.
Sandboxes can also be bound to agents without replacing the low-level sandbox API. Agent placement is explicit:
inside: the agent process runs inside the sandbox, useful for CLI agents such as Codex CLI, Claude Code, or a custom inference runner.outside: the agent process runs outside the sandbox and drives it through capability-checked sandbox tools.
Inside-sandbox agent:
from bespokelabs.sandbox import AgentSpec, Sandbox
with Sandbox(
"docker",
preset="codex",
git_repo="https://github.com/bespokelabsai/sandbox",
) as sb:
agent = sb.agent(AgentSpec.inside(
name="codex",
command=["codex", "exec"],
cwd="/sandbox",
))
result = agent.run("Run the eval suite and summarize failures")
print(result.stdout)Outside-driver agent:
from bespokelabs.sandbox import AgentSpec, Sandbox
def run_eval(ctx, prompt: str) -> str:
ctx.write_file("/workspace/task.txt", prompt)
result = ctx.shell("python3", ["/workspace/eval.py"])
return result.stdout
with Sandbox(
"docker",
files={"/workspace/eval.py": "print('ok')"},
) as sb:
agent = sb.agent(AgentSpec.outside(
name="eval-runner",
capabilities=["shell", "files"],
runner=run_eval,
))
print(agent.run("Evaluate this input"))For a complete Claude Code outside-sandbox example, see
examples/claude_code_outside.py. It starts
Claude Code on the host, clones a repository into a sandbox with git_repo=...,
and gives Claude a localhost bridge for running commands inside the sandbox. The
example asks Claude to count regular files directly in the cloned repository
root.
For outside agent frameworks, use agent_tools(...) directly:
with Sandbox("docker") as sb:
tools = sb.agent_tools(capabilities=["shell", "files", "patch"])
tools.write_file("/workspace/input.txt", "hello")
print(tools.shell("cat", ["/workspace/input.txt"]).stdout)The generic context currently exposes shell, files, and patch operations.
This keeps basic evaluation and inference usage stable while making the agent
runtime boundary visible.
run_agent(...) runs Claude Code on a prompt and reports what the run cost.
It drives the CLI with JSON output under the hood, so it can return both the
assistant's text answer and a Usage breakdown — LLM token counts and dollar
cost (from Claude Code's own total_cost_usd), plus an estimate of the sandbox
compute the call consumed (elapsed wall-clock × the backend's per-second price).
with Sandbox("local", preset="claude-code") as sb:
result = sb.run_agent("Review /code.py and suggest fixes.")
print(result.text) # the assistant's answer
u = result.usage # usage for this call
print(u.input_tokens, u.output_tokens) # token counts
print(u.llm_cost_usd) # tokens, in dollars
print(u.compute_cost_usd) # sandbox compute, in dollars
print(u.total_cost_usd) # llm + compute
# Continue the conversation; usage accumulates on the sandbox.
sb.run_agent("Now apply those fixes.", resume=True)
print(sb.usage.total_cost_usd) # running total across both calls
print(sb.usage.total_tokens)sb.usage is the running total across every run_agent call in the sandbox's
lifetime (its compute component sums the agent-call durations, not idle time
between calls). Pass extra CLI flags with extra_args=[...]. Only Claude Code
is supported today; token counts and llm_cost_usd reflect exactly what its
JSON output reports, so llm_cost_usd can be 0 under subscription auth that
omits total_cost_usd (the token counts are still captured). The async
AsyncSandbox exposes the same run_agent(...) / usage. This is distinct
from bespokelabs.sandbox.pricing, which prices sandbox compute on its own.
For standalone sandbox compute estimates, use the bundled pricing helpers:
from bespokelabs.sandbox.pricing import cost_per_second, get_backend_pricing
print(get_backend_pricing("modal")) # raw bundled pricing metadata
print(cost_per_second("modal", vcpu=2.0)) # estimated $/sec for a sandboxPricing data is best-effort and local to the installed package. To compare
available backends for a real workload, see
examples/find_cheapest.py, which benchmarks
cold-start and execution time, then estimates cost with the same pricing data.
Presets are predefined sandbox configurations with setup commands that run after creation.
The built-in presets are intentionally focused on agent CLIs: codex, claude-code, and claude-code-codex.
Both assume the sandbox image already includes Node.js and npm when setup commands are used as a fallback.
Built-in presets have prebuilt OCI images published to GitHub Container Registry:
ghcr.io/bespokelabsai/sandbox/<preset>:v2
Docker, Daytona, and Modal use these images automatically when you pass a preset, then skip the preset's setup commands because the tools are already baked into the image.
The main advantage is that prebuilt images move setup work from sandbox startup time to image build time:
- Sandboxes start faster because they do not reinstall the same tools for every run.
- Startup is more reliable because it depends less on package registry availability during sandbox creation.
- Preset environments are more reproducible because images use pinned tags instead of a moving
latesttag.
For example, this Docker sandbox starts from ghcr.io/bespokelabsai/sandbox/codex:v2 and does not run npm install -g @openai/codex at startup:
with Sandbox("docker", preset="codex") as sb:
sb.execute_command("codex --version")You can still override the image explicitly when you need a custom base image:
with Sandbox("docker", preset="codex", image="my-registry/codex-tools:v3") as sb:
sb.execute_command("codex --version")The Dockerfiles live under images/<preset>/. Local, Safehouse, Ray, and other
backends that cannot use the prebuilt image still fall back to the preset setup
commands. RunPod deliberately does this on its SSH-ready default image.
Tensorlake image names are project-scoped, so you can build/register equivalent
images from the same Dockerfiles when you need Tensorlake-specific preset
images.
# Sandbox with Codex CLI installed
with Sandbox("docker", preset="codex") as sb:
sb.execute_command("codex --version")
# Sandbox with Claude Code installed
with Sandbox("docker", preset="claude-code") as sb:
sb.execute_command("claude --version")
# Sandbox with both Claude Code and Codex CLI installed
with Sandbox("docker", preset="claude-code-codex") as sb:
sb.execute_command("claude --version")
sb.execute_command("codex --version")Built-in presets:
| Preset | What it installs | Defaults |
|---|---|---|
claude-code |
@anthropic-ai/claude-code via npm |
2GB RAM, 30min timeout |
claude-code-codex |
@anthropic-ai/claude-code and @openai/codex via npm |
2GB RAM, 30min timeout |
codex |
@openai/codex via npm |
2GB RAM, 30min timeout |
The preset controls which CLI is installed in the sandbox. It does not grant the CLI permission to use its own web tools. When you run an agent inside a remote sandbox such as Daytona, preconfigure the CLI for non-interactive runs instead of waiting for an in-terminal approval prompt.
For Claude Code, WebFetch and WebSearch are permission-gated tools. Use
--permission-mode dontAsk with the narrowest --allowedTools entries that
fit the task:
agent = sb.agent(AgentSpec.inside(
name="claude",
command=[
"claude",
"-p",
"--permission-mode",
"dontAsk",
"--allowedTools",
"WebFetch(domain:github.com)",
"WebSearch",
],
input_mode="argv",
))
result = agent.run("Summarize https://github.com/bespokelabsai/sandbox")For Codex CLI, use codex exec with explicit approval, sandbox, and search
settings. For read-only website summaries, keep the Codex sandbox read-only,
disable approval prompts, and enable live search:
agent = sb.agent(AgentSpec.inside(
name="codex",
command=[
"codex",
"exec",
"--ask-for-approval",
"never",
"--sandbox",
"read-only",
"--search",
],
input_mode="argv",
))
result = agent.run("Summarize https://github.com/bespokelabsai/sandbox")Use broader modes only when the outer sandbox is the trust boundary. For
example, codex exec --sandbox workspace-write --ask-for-approval never lets
Codex edit files without pausing, and Claude Code's --permission-mode bypassPermissions skips most permission prompts. Those modes are best kept to
isolated sandboxes with scoped credentials.
Create your own:
from bespokelabs.sandbox import Sandbox, SandboxPreset
Sandbox.register_preset(SandboxPreset(
name="my-stack",
description="My custom environment",
setup_commands=["pip install my-library", "npm install -g my-tool"],
cpu=2.0,
memory_mb=4096,
))
with Sandbox("docker", preset="my-stack") as sb:
...Explicit kwargs always override preset defaults.
Populate the sandbox at creation instead of scripting uploads afterwards.
files are written after any git_repo clone, and both land before preset
setup commands run.
files needs nothing special and works on every backend and image:
with Sandbox(
"docker",
image="python:3.12-slim",
files={"/work/run.py": "print('ready')"},
) as sb:
print(sb.execute_command("python", ["/work/run.py"]).stdout)files values may be str or bytes.
git_repo runs git clone inside the sandbox, so git must be present in
the image. All prebuilt preset images include git, so git_repo works with
any preset on Docker/Daytona/Modal. For a custom image, make sure git is
installed; the host-based backends (local, safehouse) use the host's git.
with Sandbox(
"local",
git_repo="https://github.com/psf/requests",
git_ref="main", # optional branch/tag
) as sb:
entries = sb.list_files("/requests") # repo is cloned to /<repo-name>
print(f"cloned {len(entries)} entries")backend_options is an escape hatch: the dict is merged into the backend's
native creation call, so you can reach provider features the unified API
doesn't model — without waiting for a new keyword. It is forwarded to Docker
containers.run, Modal Sandbox.create, E2B Sandbox.create, Tensorlake
create_and_connect, Daytona's create params, and the RunPod REST creation
payload; ignored by local, safehouse, and ray.
# e.g. set the container hostname (a Docker-only knob)
with Sandbox("docker", backend_options={"hostname": "build-box"}) as sb:
sb.execute_command("hostname")On Daytona, env_vars given here is merged over the env_vars= parameter
rather than replacing it, and create_timeout (seconds) is consumed by the SDK
adapter to bound the create call itself instead of being passed as a sandbox
parameter.
On RunPod, transport-only options are consumed by the adapter rather than sent
to the REST API: create_timeout_secs, api_timeout_secs,
poll_interval_secs, ssh_private_key_path, ssh_user,
ssh_connect_timeout_secs, ssh_strict_host_key_checking, and
ssh_known_hosts_file. Other keys use RunPod's native camelCase field names.
A snapshot saves state to restore later; session state is a lightweight, serializable handle that reattaches to a sandbox that is still running — including from another process or machine:
sb = Sandbox("e2b", timeout_secs=600)
sb.execute_command("echo hi > /tmp/work.txt")
state = sb.session_state()
blob = state.to_json() # JSON-safe; stash in a queue/DB/file
# ... do NOT destroy sb — the sandbox must stay alive to reattach ...
# Elsewhere — another worker, another process:
from bespokelabs.sandbox import Sandbox, SandboxSessionState
sb2 = Sandbox.resume(SandboxSessionState.from_json(blob))
print(sb2.read_file("/tmp/work.txt")) # b"hi\n"SandboxClient("e2b").resume(state) is equivalent and reuses a pooled client.
Resume returns the sandbox as-is — preset setup and files/git_repo
materialization are skipped.
| Backend | Resume by | session_state payload |
|---|---|---|
| Docker | container id (containers.get) |
container_id |
| E2B | sandbox id (Sandbox.connect) |
sandbox_id |
| Modal | sandbox id (Sandbox.from_id) |
sandbox_id |
| Tensorlake | sandbox id (client.connect) |
sandbox_id |
| Daytona | sandbox id (client.get) |
sandbox_id, workdir (when set) |
| RunPod | Pod id (GET /pods/{id}) |
pod_id, workdir and SSH transport settings |
| Local, Safehouse | host workdir | workdir, env overlay |
| Ray | — (not supported) | raises FeatureNotSupportedError |
snap = sb.snapshot()
print(snap.snapshot_id)
# Restore later
sb2 = Sandbox("tensorlake", snapshot_id=snap.snapshot_id)| Backend | Snapshot support |
|---|---|
| Docker | Yes (container.commit()) |
| Tensorlake | Yes (filesystem + memory) |
| Modal | Yes (filesystem) |
| Daytona, E2B, Local, Ray, RunPod, Safehouse | No |
# Context manager (recommended) — auto-destroys on exit
with Sandbox("local") as sb:
sb.execute_code("print('hi')")
# Manual cleanup
sb = Sandbox("docker")
sb.execute_code("print('hi')")
sb.destroy()
# Check state
sb.is_alive # True/False
sb.backend_name # "docker"Sandbox(backend, ...) builds a fresh provider connection per sandbox. When
launching many sandboxes on one backend, create a SandboxClient once and
reuse it — provider-level state (the Docker daemon connection, Daytona auth,
the Ray runtime) is shared across create() calls:
from bespokelabs.sandbox import SandboxClient
client = SandboxClient("docker")
for task in tasks:
with client.create(image="python:3.12-slim") as sb:
sb.execute_code(task)client.create(...) accepts the same keyword arguments as Sandbox(...)
and returns a regular Sandbox session. SandboxClient(backend) validates
the backend name and SDK availability up front — it raises
BackendNotInstalledError immediately if the backend's extra isn't
installed — but performs no network I/O until create().
AsyncSandboxClient / AsyncSandbox mirror the sync API with coroutine
methods, so you can create and drive many sandboxes concurrently from one
event loop:
import asyncio
from bespokelabs.sandbox import AsyncSandbox, AsyncSandboxClient
async def run_snippet(client: AsyncSandboxClient, code: str) -> str:
async with await client.create(image="python:3.12-slim") as sb:
result = await sb.execute_code(code)
return result.stdout
async def main():
client = AsyncSandboxClient("daytona")
outputs = await asyncio.gather(*(run_snippet(client, c) for c in snippets))
asyncio.run(main())One-step creation works too: sb = await AsyncSandbox.create("local").
Backend SDKs are synchronous, so async calls are offloaded to worker
threads — the event loop is never blocked. Note that the missing-SDK check
(BackendNotInstalledError) surfaces at the first await client.create()
rather than at AsyncSandboxClient(...) construction, which does no I/O.
| Feature | Local | Safehouse | Docker | Ray | Daytona | Tensorlake | Modal | RunPod | E2B |
|---|---|---|---|---|---|---|---|---|---|
execute_code |
Any binary | Any binary | Any binary | Any binary | Python, TS, JS, Ruby, Go | Any binary | Any binary | Any binary | Python |
execute_command |
Shell | Shell | Shell | Shell | Shell | Shell | Shell | SSH | Shell |
list_files |
Native | Native | find / ls |
Native | Native SDK | via ls |
Native SDK | via SSH | Native SDK |
read_file |
Native | Native | get_archive |
Native | Native SDK | via cat |
Native SDK | via SSH | Native SDK |
write_file |
Native | Native | put_archive |
Native | Native SDK | via base64 | Native SDK | via SSH | Native SDK |
upload_file |
shutil.copy |
shutil.copy |
put_archive |
ray.put |
Native SDK | via base64 | Native SDK | via SSH | Native SDK |
download_file |
shutil.copy |
shutil.copy |
get_archive |
ray.get |
Native SDK | via base64 | Native SDK | via SSH | Native SDK |
snapshot |
No | No | Yes | No | No | Yes | Yes | No | No |
| Resource limits | No | No | cpu, memory | cpu (Ray) | cpu, memory, disk | cpu, memory | cpu, memory, gpu | cpu, memory, disk, gpu | Tier-based |
| Network control | No | No | Yes | No | Firewall, VPN | Yes | Tunnels | No | No |
| Isolation | Process-level | macOS sandbox-exec |
Container | Process | Full VM | Container | Container | Container | Full VM |
| GPU | No | No | No | Via Ray | No | No | Yes | Yes | No |
| Needs install | Nothing | safehouse CLI |
Docker daemon | ray |
API key | tl login |
API key | API key + OpenSSH | API key |
from bespokelabs.sandbox import (
SandboxError, # Base class for all errors
SandboxCreationError, # Sandbox failed to start
SandboxExecutionError, # Code or command execution failed
BackendNotInstalledError, # pip package missing for chosen backend
FeatureNotSupportedError, # Backend doesn't support this operation
)All exceptions inherit from SandboxError, so you can catch broadly or narrowly:
try:
sb.snapshot()
except FeatureNotSupportedError:
print("This backend doesn't support snapshots")
except SandboxError as e:
print(f"Something else went wrong: {e}")# Docker — no auth needed, just a running Docker daemon
# Local — no auth needed
# Ray — optional remote cluster
export RAY_ADDRESS=ray://head-node:10001 # omit for local cluster
# Daytona
export DAYTONA_API_KEY=your_key
export DAYTONA_API_URL=https://app.daytona.io/api # optional
export DAYTONA_TARGET=us # optional
# Tensorlake (authenticate via CLI)
tl login
# Modal
export MODAL_TOKEN_ID=your_id
export MODAL_TOKEN_SECRET=your_secret
# RunPod — add the matching public key to your RunPod account
export RUNPOD_API_KEY=your_key
# E2B
export E2B_API_KEY=your_key