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

OptionMeaning
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 api

Call 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 machine

url() 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 client

Forward 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 port

On 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 10m

A 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)  # 200

Sidecar options#

Container(image, command=None, env=None, ports=None, name=None):

  • name is a DNS label and the hostname. It defaults from the image (redis for redis:7-alpine) and cannot be main.
  • command replaces the sidecar image's entrypoint; env holds plain values.
  • A bare image string works too: sidecars=["redis:7-alpine"].
  • At most 8 sidecars. With sidecars, the service names main, sidecars and sc are 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.

ValueWhere it goes
API keys, tokens, passwordsThe process environment at runtime (below)
Key filesA temporary file that is removed when the process exits (below)
Registry credentialsA RegistrySecret, never logged or saved
Non-sensitive settingsImage.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.