Cua Docs

Build a local sandbox image

Select a local sandbox base image and add packages, files, setup commands, and ports for execution on your own hardware.

This guide uses the Sandbox SDK on your own hardware with local=True. Use an Image to define the guest OS and setup steps before starting a local sandbox. Start with Choose a base image.

For hosted execution, follow Choose a Fleet image. The local builder steps below do not publish a Fleet artifact or run during a claim.

SDK builder customization is local-only in cua-sandbox 0.7.0. apt_install(), pip_install(), env(), run(), copy() and the other builder methods are applied when you boot the image locally (local=True). Fleet runs a prebuilt registry artifact and rejects a customized one with NotImplementedError: Fleet cloud supports registry images with optional exposed services only. For Fleet customization, prepare and publish the guest image first. See Sandbox runtime support for versioned acceptance and verification limits.

Choose a published Fleet image#

The hosted procedure is now in Choose a Fleet image. Use that guide for published artifacts, guest-service requirements, and Windows licensing requirements. Continue below only for local execution.

Choose a base image#

An Image describes the OS and software environment. Images are immutable and chainable. Each builder method returns a new Image.

Start with the built-in constructor that matches the operating system and isolation level you need.

from cua import Image
 
Image.linux()                        # Ubuntu 24.04 VM (default, QEMU)
Image.linux(kind='container')        # Ubuntu 24.04 container (lighter, Docker/XFCE)
Image.linux('ubuntu', '22.04')       # Older Ubuntu
Image.macos()                        # macOS Tahoe (version '26')
Image.macos('15')                    # macOS Sequoia
Image.windows()                      # Windows Server 2022
Image.windows('11')                  # Windows 11 (local ISO install only)
Image.android()                      # Android 14

Choose a constructor using the OS and runtime table. A constructor returning an Image does not verify that its guest can boot on your host. Check local runtime requirements before selecting it.

If a local Docker image times out on readiness, check the guest server's listening address. An earlier trycua/cua-xfce:latest recipe reported the server bound to 127.0.0.1:8000 inside the container, which Docker's published port could not reach. That observation does not establish the state of every later mutable image tag.

How a Linux image runs locally#

Image.linux() is a VM. Locally it boots the same pinned containerDisk that Cua Fleet references, under QEMU on the host. Sharing an artifact does not make host networking, firmware, resources, or available SDK operations identical:

from cua import Image, Sandbox
 
async with Sandbox.ephemeral(Image.linux(), local=True) as sb:
    print((await sb.shell.run('uname -a')).stdout)

That path needs qemu-system-x86_64 on the host, and uses /dev/kvm when it is available. Without QEMU the call raises rather than quietly starting something else:

RuntimeError: Image.linux() is a VM and needs QEMU, which was not found on this host.

Image.linux(kind='container') is the Docker path instead, and needs no QEMU.

Passing runtime=QEMURuntime() explicitly selects mode='docker', which wraps QEMU in a container and does not boot the pinned containerDisk. Use QEMURuntime(mode='bare-metal') if you are naming a runtime by hand, or leave runtime= off and let the default pick it.

Install packages#

Use the package manager for the target operating system.

Image.linux().apt_install('curl', 'git', 'ffmpeg')
Image.macos().brew_install('ffmpeg', 'jq')
Image.windows().choco_install('nodejs', 'git')
Image.windows().winget_install('Microsoft.VisualStudioCode')
Image.android().apk_install('/path/to/app.apk')

Install Python packages with pip_install() or uv_install().

Image.linux().pip_install('numpy', 'pandas', 'playwright')
Image.linux().uv_install('numpy', 'pandas')  # faster, installs into cua-server project

Set environment variables#

Set non-sensitive environment variables with .env().

Image.linux().env(DATABASE_URL='postgres://localhost/mydb', LOG_LEVEL='debug')

Variables are baked in as /etc/profile.d/cua-env.sh on Linux and macOS, and as machine-scoped setx variables on Windows. On Linux that file is read by login shells, so a bare sb.shell.run() does not see them — ask for a login shell:

await sb.shell.run("echo $MY_TOKEN")            # empty
await sb.shell.run("bash -lc 'echo $MY_TOKEN'") # abc123

Avoid putting real secrets in .env() because anyone who can read the Image spec can see the values.

Run setup commands#

Run arbitrary shell commands during setup with .run().

Image.linux().run('curl -fsSL https://deb.nodesource.com/setup_20.x | bash -')

Copy files#

Copy local files into the image with .copy().

Image.linux().copy('./config.json', '/app/config.json')

Expose ports#

Expose every port the sandbox workload must listen on.

Image.linux().expose(8080).expose(5432)

In 0.7.0, bare-metal QEMU forwards exposed ports at startup and reports their host ports in sb.exposed_ports. Other local backends can differ. Fleet creates named services instead. expose() does not make tunnel.forward() available on every transport; see ports and transports.

Chain builder calls#

Chaining: builder calls are composable, each returns a new Image:

img = (
    Image.linux()
    .apt_install('curl', 'git', 'ffmpeg')
    .pip_install('requests', 'Pillow')
    .env(MY_TOKEN='abc123', DEBUG='1')
    .run('mkdir -p /app/data')
    .expose(8080)
)

Fork a base image for different use cases:

base = Image.linux().apt_install('curl', 'git')
dev  = base.pip_install('ipython', 'rich').env(DEBUG='1')
prod = base.pip_install('gunicorn').run('useradd -m appuser')

Use a custom OCI image#

Use Image.from_registry() when your base image already exists in a registry.

The reference must be a KubeVirt containerDisk — an image carrying a bootable disk at /disk/disk.img. An ordinary OCI application image such as ubuntu:22.04 is not one, and fails with FileNotFoundError: OCI image 'ubuntu:22.04' does not contain /disk/disk.img.

img = Image.from_registry(
    'registry.example/workspace@sha256:...',
    os_type='linux',
    kind='vm',
)
# Local setup only; Fleet rejects builder layers.
img = img.run('echo hi')

A registry image defaults to kind=None, so the SDK cannot automatically select a runtime unless you set the kind. For local use, select a runtime compatible with the actual guest disk. The Linux VM example uses bare-metal QEMU:

from cua import QEMURuntime, Sandbox
 
async with Sandbox.ephemeral(img, local=True, runtime=QEMURuntime(mode='bare-metal')) as sb:
    ...

Replace the example reference with an artifact you published. Image.from_registry() defaults to os_type='linux'; set os_type='windows', kind='vm' for a Windows VM disk. The registry reference does not prove that the artifact is accessible or bootable. Local pull authentication and Fleet registry access are separate requirements.

Use a local disk image#

Use Image.from_file() for qcow2, vhdx, raw, or iso disk images. URLs are also supported and cached automatically.

Image.from_file('/path/to/disk.qcow2', os_type='linux')
Image.from_file('/path/to/windows.vhdx', os_type='windows')
Image.from_file('https://example.com/disk.qcow2', os_type='linux')

Disk images downloaded from a URL are cached in ~/.cua/cua-sandbox/image-cache/, keyed by a hash of the URL. This is a different cache from the one used for registry containerDisks, which land in ~/.cua/cua-sandbox/images/container-disks/<sha256-of-ref>/disk.qcow2. Delete either directory to force a re-download.

Inspect an image#

Call .to_dict() to inspect the final image spec before using it.

img = Image.linux().apt_install('curl').pip_install('requests')
print(img.to_dict())
# {'os_type': 'linux', 'distro': 'ubuntu', 'version': '24.04', 'kind': 'vm',
#  'layers': [{'type': 'apt_install', 'packages': ['curl']},
#             {'type': 'pip_install', 'packages': ['requests']}]}

env, ports and files keys appear only when you have set them:

img = Image.linux().env(DEBUG='1').expose(8080).copy('./a.json', '/app/a.json')
print(img.to_dict())
# {..., 'kind': 'vm', 'layers': [], 'env': {'DEBUG': '1'},
#  'ports': [8080], 'files': [['./a.json', '/app/a.json']]}