Run services, sidecars and secrets
Run a server in a sandbox, call it, connect to an MCP server in it, forward a port, share it with a public URL, add sidecar containers, and pass secrets.
Run a program in a sandbox, call it or share it, put helper containers next to it, and give it credentials without baking them into an image. This page covers services, sidecars and secrets.
A service is a named guest port. The image (or command=) runs the listener;
services= names it and wait_for= holds create until it answers. The
listener must bind a guest-reachable address (0.0.0.0, not only
127.0.0.1).
| Option | Meaning |
|---|---|
command=["python", "app.py"] | argv that replaces the image's entrypoint |
env={"LOG_LEVEL": "debug"} | plain environment variables (not secrets) |
services={"api": 8000} | named guest ports |
wait_for=http("api", "/health") or tcp("api") | readiness; a list waits for all. Fails at once if the command exits |
Start a server#
from cua_sandbox import Image, Sandbox, http
sb = await Sandbox.create(
Image.from_registry("python:3.12-slim"),
command=["python", "-m", "http.server", "8000"],
env={"PYTHONUNBUFFERED": "1"},
services={"api": 8000},
wait_for=http("api", "/"),
)cua sb create python:3.12-slim --name api \
--service api=8000 --wait http:api/ --env PYTHONUNBUFFERED=1 \
-- python -m http.server 8000
cua sb url api apiCall it#
api = sb.service("api")
r = await api.request("GET", "/") # an httpx.Response
r = await api.request("POST", "/items", json={"a": 1}, headers={"x-trace": "1"})
print(await api.url()) # usable from this machineurl() is a loopback URL usable from this machine.
import { embedded, http, SandboxCreateOptions } from '@trycua/cua';
const sb = await embedded().sandboxes().create(
SandboxCreateOptions.create({
image: 'python:3.12-slim',
command: ['python', '-m', 'http.server', '8000'],
env: new Map([['PYTHONUNBUFFERED', '1']]),
services: new Map([['api', 8000]]),
waitFor: [http('api', '/')],
})
);
const res = await sb.service('api').request('GET', '/', undefined, undefined, undefined);
console.log(res.status, await sb.service('api').url());MCP servers#
An MCP server is a service that speaks streamable HTTP; no cua-spacesd is
needed. This one serves an add tool:
import shlex
SERVER = """
from mcp.server.mcpserver import MCPServer
server = MCPServer("demo")
@server.tool()
def add(a: int, b: int) -> int:
return a + b
server.run("streamable-http", host="0.0.0.0", port=8765)
"""
COMMAND = ["sh", "-c", "pip install -q 'mcp>=2' && exec python -c " + shlex.quote(SERVER)]from cua_sandbox import Image, Sandbox, tcp
sb = await Sandbox.create(
Image.from_registry("python:3.12-slim"),
command=COMMAND,
services={"mcp": 8765},
wait_for=tcp("mcp"),
)sb.mcp(service) opens an official mcp client (pip install "cua-sandbox[mcp]").
sb.mcp_config(service) returns the URL and headers for any other client;
fetch a fresh one per connection.
async with sb.mcp("mcp") as client: # path="/other" for another endpoint
result = await client.call_tool("add", {"a": 2, "b": 3})
config = await sb.mcp_config("mcp") # {"url": "http://127.0.0.1:53817/mcp", "headers": {}}import { connectMcp } from '@trycua/cua';
const client = await connectMcp(await sb.mcpConfig('mcp', undefined));
const result = await client.callTool({ name: 'add', arguments: { a: 2, b: 3 } });
await client.close();cua sb mcp NAME mcp tools
cua sb mcp NAME mcp call add '{"a": 2, "b": 3}'
cua sb mcp NAME mcp config # URL and headers for another clientForward a port#
sb.tunnel.forward(port) opens a loopback port for local tools such as a
browser, psql or a debugger.
async with sb.tunnel.forward(8000) as t:
print(t.url) # http://127.0.0.1:53817
async with sb.tunnel.forward(8000, 9222) as tunnels: # several ports
print(tunnels[9222].url)TypeScript: sb.forward(port), sb.publicUrl(service, ttl, label) and
sb.revokePublicUrl(id).
cua sb port-forward web 8000 # until Ctrl-C
cua sb port-forward web 8000:9000 # pick the local portOn a remote sandbox, a port without cua-spacesd in the image must be declared in
services=.
Share with a public URL#
sb.public_url(service, ttl=...) returns a URL that needs no cua credentials
and stops working after ttl seconds (60 s to 24 h, default 1 h) or when
revoked. It is served by the cua daemon on loopback. For an MCP server, append /mcp.
share = await sb.public_url("web", ttl=600, label="demo")
print(share.url, share.expires_at)
await sb.revoke_public_url(share)cua sb url web web --public --ttl 10mA public URL is a bearer credential. Use the shortest practical ttl, revoke
it when sharing ends, and keep it out of logs and source control. It does
not extend the sandbox's lifetime.
Sidecars#
A sidecar is an extra container next to the sandbox, like a Docker Compose service. It is addressed by name on every runtime:
- the sandbox reaches a sidecar at its
name(db:6379); - a sidecar reaches the sandbox at
main; services=names a sidecar port so you can call it from your machine.
from cua_sandbox import Container, Image, Sandbox, tcp
sb = await Sandbox.create(
Image.from_registry("python:3.12-slim"),
command=["sleep", "infinity"],
sidecars=[Container("redis:7-alpine", ports=[6379], name="db")],
services={"db": 6379},
wait_for=tcp("db"),
runtime="runc",
)Local sidecars share the sandbox container's network namespace, so
localhost works too. They need runtime="runc": separate gVisor containers
cannot share a network namespace, and the SDK refuses rather than switching
runtimes silently. Local VM sandboxes refuse sidecars.
Reach the sandbox from a sidecar#
This relay fetches the sandbox's page from http://main:8000 and serves it on
its own port.
from cua_sandbox import Container, Image, Sandbox, http
relay = Container(
"busybox:1.36",
name="relay",
ports=[8081],
command=["sh", "-c", "mkdir -p /www; "
"until wget -qO /www/index.html http://main:8000/; do sleep 1; done; "
"exec httpd -f -p 8081 -h /www"],
)
sb = await Sandbox.create(
Image.from_registry("python:3.12-slim"),
command=["python", "-m", "http.server", "8000"],
sidecars=[relay],
services={"relay": 8081},
wait_for=http("relay", "/"),
runtime="runc",
)
print((await sb.service("relay").request("GET", "/")).status_code) # 200Sidecar options#
Container(image, command=None, env=None, ports=None, name=None):
nameis a DNS label and the hostname. It defaults from the image (redisforredis:7-alpine) and cannot bemain.commandreplaces the sidecar image's entrypoint;envholds plain values.- A bare image string works too:
sidecars=["redis:7-alpine"]. - At most 8 sidecars. With sidecars, the service names
main,sidecarsandscare reserved. - A private sidecar image on the sandbox image's registry uses the same registry secret.
cua sb create python:3.12-slim --name app --runtime runc \
--sidecar redis:7-alpine,port=6379,name=db --service db=6379 --wait tcp:db \
-- sleep infinity--sidecar takes IMAGE[,port=PORT][,env=K=V][,name=NAME] and repeats.
import { sidecar, SandboxCreateOptions } from '@trycua/cua';
SandboxCreateOptions.create({
image: 'python:3.12-slim',
command: ['sleep', 'infinity'],
sidecars: [sidecar('redis:7-alpine', { ports: [6379], name: 'db' })],
services: new Map([['db', 6379]]),
runtime: 'runc',
});Secrets#
Read secrets on the host and hand them to one process in the sandbox at
runtime. Never put them in an image spec. The examples take a connected Linux
sandbox sb with an application at /app/main.py; they work locally and in
your own cloud.
| Value | Where it goes |
|---|---|
| API keys, tokens, passwords | The process environment at runtime (below) |
| Key files | A temporary file that is removed when the process exits (below) |
| Registry credentials | A RegistrySecret, never logged or saved |
| Non-sensitive settings | Image.env(), env= on Sandbox.create, Container(env=...) |
Environment variables#
Set DATABASE_URL and GITHUB_TOKEN on the host from your secret manager,
then call await run_with_secrets(sb):
import os
import shlex
from cua_sandbox import Sandbox
async def run_with_secrets(sb: Sandbox):
db_url = os.environ['DATABASE_URL']
gh_token = os.environ['GITHUB_TOKEN']
command = (
f'DATABASE_URL={shlex.quote(db_url)} '
f'GITHUB_TOKEN={shlex.quote(gh_token)} '
'python3 /app/main.py'
)
result = await sb.shell.run(command)
if not result.success:
raise RuntimeError('Application failed; inspect sanitized diagnostics')Keep the assignments and the application in one shell.run() call: an
export does not persist into the next call. shlex.quote() keeps spaces,
quotes and newlines literal.
A temporary key file#
For an application that reads a key file, point TASK_SSH_KEY_FILE at the
host file (mode 600), then call await run_with_key_file(sb):
import os
from pathlib import Path
import shlex
from cua_sandbox import Sandbox
async def run_with_key_file(sb: Sandbox):
key_content = Path(os.environ['TASK_SSH_KEY_FILE']).read_bytes().decode('utf-8')
script = f"""
set -eu
umask 077
secret_dir=$(mktemp -d /tmp/cua-task-secret.XXXXXXXXXX)
trap 'rm -f "$secret_dir/task-key"; rmdir "$secret_dir"' EXIT
trap 'exit 1' HUP INT TERM
printf '%s' {shlex.quote(key_content)} > "$secret_dir/task-key"
chmod 600 "$secret_dir/task-key"
TASK_SSH_KEY_FILE="$secret_dir/task-key" python3 /app/main.py
"""
result = await sb.shell.run(script)
if not result.success:
raise RuntimeError('Credential task failed; inspect sanitized diagnostics')The file lives in a private directory and is removed when the application exits, including on failure. A killed shell or a lost connection can skip the cleanup: destroy the sandbox then.
These commands contain the secret values. Quoting does not redact logs,
traces or process listings: do not print the command, do not enable
set -x, use short-lived credentials, and revoke them when the task ends.