# Cua Spaces: one daemon per computer, and I ran three of them

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/cua-spaces
> date: 2026-10-23
> tags: agents, computer-use, infrastructure, mcp

On 19 October trycua posted a thread introducing the Cua Spaces SDK. Its pitch is
that every big lab now ships a bot with its own computer, and *"None let you bring
your own. With Cua Spaces, now you can. Put Claude Code, Codex or any agent on
compute you choose, and run jobs across all your computers at once."* The thread
names the places a Space can live: your laptop, the office Mac mini, your own AWS,
Google Cloud or Azure account, or a sandbox provider like E2B, Daytona or Modal.
Its third post says ten lines start a bot, and that changing `on: "local"` to the
name of another computer moves the job there (all reported).

The site has touched cua twice. [cua-s1-forms](/articles/cua-s1-forms) took apart
the team's 706,048-parameter form model, and [arc-driver](/articles/arc-cua) read
cua-driver's macOS source to explain a benchmark. This piece is about the platform
itself: what a Space is, what runs inside one, how your agent reaches it, and what
it costs per action. Satyajit asked for a version you can try, so I also ran it.

I read [`trycua/cua`](https://github.com/trycua/cua) at commit `cdda707`
(2026-10-23), installed `cua` 0.7.0 from PyPI, downloaded the `cua-spacesd` 0.7.0
Linux release (sha256 checked against the published `.sha256`), and drove three
local desktops with a script. No model, no API key, no account.

<RepoCard repo="trycua/cua" />

## The stack moved under its own name

If you last looked at cua a year ago, you remember three pillars: desktop
sandboxes, a Computer framework talking to a `computer-server` inside each VM,
and an Agent framework with prebuilt loops for many vision-language models.
That is still the diagram in the repository's `img/` folder.

<Figure
  src="https://media.thesatyajit.com/ai/articles/cua-spaces/fig1.png?v=82beaf2573facbb7"
  alt="Three columns. Desktop Sandboxes: Linux GUI on Docker, Windows, Linux and Android via QEMU Docker, macOS VMs via Lume, Windows Sandbox. Computer Framework: Python and TypeScript SDKs, screenshots, code execution, a self-hostable computer server. Agent Framework: 100-plus VLM options, prebuilt agent loops, composable architecture, Cua Cloud VLM router."
  caption="The earlier cua architecture: sandboxes, a Computer framework and an Agent framework. Most of the middle and right columns has since been replaced or deprecated (trycua/cua, img/cua-architecture.png)."
/>

Most of that has been replaced. The `cua-spacesd` README says it *"replaces the
former in-image services (computer-server on 8000, the rcdp daemons, and the
supergateway-wrapped cua-driver MCP) and is not wire-compatible with them."* The
Python `cua-agent` package now opens with a deprecation banner that sends you to
cua-driver over MCP and to the `cua` SDK. Hosted Cua Cloud has ended.

The agent framework is still worth one paragraph, because its design is the
opposite of what replaced it. `cua_agent/loops/` registers 19 loops (measured,
counting `register_agent` decorators), each claimed by a regex over the model
name: `.*claude-.*` gets the Anthropic computer-use loop, `computer-use-preview`
gets OpenAI's, UI-TARS, OpenCUA, Holo, GTA1, InternVL and others get their own
parsers, and a catch-all VLM loop sits at priority -100. A model string with a
`+` in it, such as a grounder plus a planner, matches the composed loop at
priority 1: one model describes the target element in words, the other turns the
words into coordinates. OmniParser's loop is still there, but its `omni` extra was
removed because `cua-som` is AGPL-3.0. That framework owned the model. The new
stack refuses to.

## What a Space is

The docs define it in one line: a **computer** is a Mac, Windows or Linux machine
you own or rent, and a **Space** is one desktop on it, *"set up for a task. A Space
is a container or a VM, or the computer's own desktop."* Cua does not rent
compute; you bring it, and your cloud bills you for any VM (reported).

Every Space has an id that says how it is reached:

| Id | Comes from | Reached how |
|---|---|---|
| `local:<name>` | `spaces.create(options)` | A container (gVisor `runsc` when present) or a VM (QEMU, or Lume on a Mac) on this machine |
| `direct:<host:port>` | `spaces.add(url, token)` | Straight to a running `cua-spacesd`, by address and token |
| `relay:<machine-id>` | `cua host setup` on that machine | Through relay.cua.ai, by your cua.ai account |

The app, the `cua` CLI, the SDKs and the `cua daemon` share one registry,
`~/.cua/spaces.json`. When I added three Spaces, their tokens went to a separate
`~/.cua/spaces-credentials.json` with mode `600`, and the registry itself held
none (measured).

The diagram below is built from the crates. Pick a path to see what the relay can
read, click a box for what it does, and slide the computer count to see the
fan-out: each Space is its own connection.

<SpaceMap />

Read it left to right. Your agent calls the SDK, either directly or through the
cua MCP server, whose generated contract lists 86 tools in version 0.7.0
(measured from `spaces-contract/manifest.json`; the README's table still says
33). The SDK is one Rust crate, `cua-sdk`, exported through UniFFI to Python,
TypeScript, Swift and Kotlin, so every binding has the same calls. It runs inside
your process (`cua.embedded()`) or behind a shared `cua daemon` on a Unix socket.

On the far side, in every computer, is one process.

## Inside a computer: cua-spacesd on 3211

`cua-spacesd` is a single Rust binary that serves the `cua.env.v1` contract on one
port. The `.proto` files define 13 services and 88 RPCs in that package
(measured): `System`, `Process`, `Filesystem`, `Computer`, `Windows`,
`Accessibility`, `Driver`, `Stream`, `Presence`, `Teleport`, `Tunnel`, `Volume`
and `HostSpaces`. Native gRPC and gRPC-Web share the port. gRPC-Web cannot carry client streams,
so bidirectional RPCs are forbidden and every client-streaming RPC has a chunked
unary fallback. Alongside the RPCs it serves `/mcp` (streamable-HTTP MCP over the
driver's tools), `/files` (HMAC-signed upload and download URLs), and `/media`
for H.264 video and Opus audio, with QUIC on 3212 when allowed.

Auth is a bearer token, required for any non-loopback bind; without one the
daemon listens on `127.0.0.1` only (reported). A wrong token got me
`Unauthenticated: missing or invalid bearer token` (measured, by accident, after
I rotated tokens and forgot to re-add a Space). Each daemon also kept an
`access.log` in its data directory, one JSON line per remote call, each line
carrying the hash of the one before it. After my runs the first daemon's log had
54 lines (measured).

The daemon never injects input itself. It links cua-driver in-process, and the
driver's tools are what `/mcp` and `DriverService.CallTool` expose. My Linux build
listed 63 of them (measured): `click`, `type_text`, `press_key`, `scroll`,
`get_window_state`, `list_windows`, `get_desktop_state`, `launch_app`, browser
tools, recording, `run_steps` and more. Two of their rules matter for anyone
writing a harness.

**Coordinates must come from a capture.** My first pixel clicks all failed with
`screenshot_context_missing`: *"Call get_window_state(pid, window_id) with the
default screenshot and take x,y from that image"* (measured, 20 of 20). The
driver binds x and y to the last screenshot of that window, so a stale or
invented coordinate is refused rather than clicked.

**Prefer element tokens.** `get_window_state` walks the accessibility tree and
tags each actionable element `[N]`; `snapshot_id:N` is a token you can click,
type into or diff against later. On my test page the whole tree was 14 elements
and 1,236 bytes of Markdown, against 27,082 bytes for a 1280x800 PNG of the same
desktop (both measured).

`System.GetCapabilities` reports what the guest can actually do, and an
unsupported feature carries a `limitation` string instead of failing silently.
That turned out to be the most useful thing in the box, as the next section shows.

## Bring any agent: harnesses over ACP

The Spaces SDK does not ship an agent loop. It ships a way to start someone
else's inside a Space. `agent_start` takes a harness id (`claude-code`,
`openai-codex`, `gemini-cli`, `google-antigravity`, `goose`, `hermes`,
`openclaw`, `opencode` or `pi`), a prompt and optional repo, model and endpoint.
From the `cua-agents` crate:

1. It installs what the harness needs, each piece pinned and verified: release
   archives by sha256, npm tarballs by their pinned sha512, git sources by commit.
2. It writes the run into `~/.cua/agents/<run>/` in the guest: `meta.json`,
   `run.json`, `secrets.json` (mode 0600), `launch.sh`, then `events.jsonl` and
   `state.json` as the run goes.
3. It starts a detached, tagged spacesd process that runs `acp-runner.mjs`, which
   drives the harness over the Agent Client Protocol and normalizes its output
   into one event vocabulary for every harness.
4. It hands the harness the Space's own MCP tools, so Claude Code inside the Space
   sees the same 63 driver tools I called from outside.

Because every fact about a run is a file in the guest, a different client can
list, read, continue or stop a run it never started.

<Figure
  src="https://media.thesatyajit.com/ai/articles/cua-spaces/fig2.jpg?v=c5e80b8c41afd7b0"
  alt="The Cua Bots Mac app. On the left a bot named Ada with a chat thread: the user asked for research on standing desks and Ada replied that the summary is ready, with a standing-desks.md output. On the right, Ada's computer, a Linux desktop showing the Wikipedia article on standing desks, with a Take over button."
  caption="Cua Bots, the sample app from the thread: a bot's thread beside its Space, local:bot-ada, with a Take over button (trycua/cua docs, cua-bots-mac-computer.png)."
/>

The permission model is stated plainly in the contract: runs are *"auto-approved:
the Space is the sandbox, which is why approvalsAreEnforced is false."* Keys reach
a run only as explicit environment, named in `env_from_host` or saved in the app,
and the runner redacts them from the event log. The crate's own doc comment says
*"Host credential files are never copied"*. The connect-your-agent guide
still says the daemon copies `~/.claude/.credentials.json` into the Space at
start. The code and the generated tool reference agree with each other, so I
believe the code; the guide is stale.

Persistent agents add a home in Cua Volume (`agents/<name>/`), restored before
each run and saved after each turn, so a bot remembers across Spaces.

## Bring your own compute: direct, relay, mesh

A computer behind NAT runs `cua host setup`, which installs `cua-spacesd` as a
service that holds one outbound WSS connection to the relay, multiplexed with
yamux. Nothing listens on the open internet. What the relay can see depends on
the path, and the relay-security page is unusually candid about it:

- **HTTPS path.** relay.cua.ai is *"a trusted, TLS-terminating proxy, not a blind
  relay."* It parses every request to route and authorize it, so it can read
  shell output, file contents, the desktop stream and keystrokes in flight. It
  does not store traffic, and in account mode it strips your token and forwards
  a short-lived signed assertion that the computer checks against its own policy.
- **Mesh path, on by default.** When both ends support it, a `relay:` Space is
  reached over a WireGuard mesh that runs inside Cua (Cloudflare's boringtun),
  with no system VPN. The relay coordinates it but holds no session key and
  forwards only ciphertext. `CUA_MESH=require` refuses any path the relay could
  read.
- **Direct.** No relay at all: address and token. This is what I ran.

Secrets get extra care. Teleport bundles (a signed-in Chrome's cookies, say) are
sealed to the guest's own X25519 key, pinned on first use, so even the HTTPS path
carries ciphertext. My daemons each generated that key on first start, mode 600
(measured). A saved site password typed into a page through a `relay:` Space is
the exception the page names: gated behind a per-sign-in acknowledgement, not yet
sealed.

The "your own cloud" placement covers AWS, Google Cloud and Modal in the docs. The
thread's list (Azure, E2B, Daytona) is longer, and a follow-up on 22 October
announced early access for Hetzner and AWS accounts and E2B and Daytona code
running unchanged. I did not test any cloud placement.

## Try it yourself

The documented quickstart creates a `local:` Space from `ghcr.io/trycua/linux:24.04`
in Docker or Podman and needs about 4 GB of free memory. My sandbox has a Docker
client but no daemon, and no KVM, so I took the other route the design allows: run
`cua-spacesd` directly on a desktop and add it as a `direct:` Space. Hardware: a
shared 4-core cloud VM, CPU only, with load averages between 9 and 20 from other
jobs during every run. Treat every number below as measured on a busy machine.

Each "computer" was an Xvfb display at 1280x800, a Chromium window showing a
small editor page, and one `cua-spacesd` on its own loopback port with its own
token. Getting it to drive Chromium took three fixes, each found by reading what the daemon reported rather than guessing:

1. `a11y` came back unsupported with *"AT-SPI needs a D-Bus session bus (start
   the desktop with dbus-run-session)"*. I gave each display a session bus and an
   accessibility bus with the AT-SPI registry. The tree then read correctly.
2. `background_input` carried the limitation that it *"uses XSendEvent, which
   GTK3/GTK4 and Chromium ignore"*. Background clicks were refused with a message
   asking for `delivery_mode:"foreground"`.
3. Foreground needs a window manager to confirm activation. With none, every
   action failed with `foreground_unavailable`. A minimal WM (matchbox) fixed it.

The reference image ships XFCE on Xvfb, PipeWire and the driver as a desktop
session, which is why it should need none of this plumbing. The SDK side is short. This is the scripted sequence,
trimmed, against one Space:

```python
# drive.py: cua 0.7.0, no model
import asyncio, json, cua

async def main():
    spaces = cua.embedded().spaces()
    await spaces.add("http://127.0.0.1:3311", open("token1").read().strip(), "xvfb1")
    sp = await spaces.space("xvfb1")

    async def tool(name, **args):
        return await sp.call_tool(name, json.dumps(args), None, None)

    win = json.loads((await tool("list_windows", app="chrom")).structured_json)["windows"][0]
    pid, wid = win["pid"], win["window_id"]
    tree = json.loads((await tool("get_window_state", pid=pid, window_id=wid,
                                  include_screenshot=False)).structured_json)
    snap = tree["snapshot_id"]                     # rows: [10] entry "Note", [11] button "Save"
    await tool("click", pid=pid, element_token=f"{snap}:10", delivery_mode="foreground")
    await tool("type_text", pid=pid, window_id=wid, text="Hello from a scripted cua run.",
               delivery_mode="foreground")
    # re-read before clicking Save: tokens go stale after the next read
    snap = json.loads((await tool("get_window_state", pid=pid, window_id=wid,
                                  include_screenshot=False)).structured_json)["snapshot_id"]
    await tool("click", pid=pid, element_token=f"{snap}:11", delivery_mode="foreground")
    shot = await sp.screenshot(None)               # PNG bytes over ComputerService
    print((await sp.bash("uname -sr", None)).stdout)

asyncio.run(main())
```

A diff read afterwards (`since: <snapshot>`) returned exactly the change: a new
section reading "Saved 30 chars". The green arrow in the screenshot is the
driver's agent cursor overlay, which it draws so a human watching knows who is
moving.

<Figure
  src="https://media.thesatyajit.com/ai/articles/cua-spaces/fig3.png?v=7f82d4424464dfd6"
  alt="A plain editor page in Chromium on a 1280 by 800 Xvfb desktop. The text area reads: Hello from a scripted cua run. Below it, a Save button with a green agent cursor on it, and the text Saved 30 chars."
  caption="My run, not an official image: the scripted sequence above, after the Save click, captured by the SDK's screenshot call over gRPC (author's run, Xvfb plus Chromium, cua-spacesd 0.7.0)."
/>

### What each action costs

I then timed every action type 20 times after a warm-up. The chart has the
medians and the p10 to p90 spread, and a second condition I explain below.

<LatencyChart />

`Computer.Screenshot` over gRPC returned a 27 KB PNG in
8.4 ms median, and `bash true` round-tripped in tens of milliseconds (both
measured). Reads that walk the window system cost more: `list_windows` 0.46 s,
an accessibility read of a 14-element window 0.65 s, `get_desktop_state` 0.77 s
(medians, measured). That last one returns the same pixels as the 8 ms
screenshot, plus window metadata and a capture id the driver will accept clicks
against; I did not profile where its extra time goes.

Every input action cost more than a second: a foreground click 1.35 s, ten
characters of `type_text` 1.37 s, one `press_key` 1.21 s (medians, measured).
The source says why. After a foreground action, the Linux driver watches the
target process's window set for up to `WINDOW_CHANGE_DEADLINE`, 800 ms, so it can
report a menu or dialog the action opened; activation adds its own settle and the
focus guard a 220 ms watch. It is the same design that [the arc-driver
piece](/articles/arc-cua) found on macOS as a one-second poll. The deadline is an
environment variable, `CUA_DRIVER_WINDOW_CHANGE_TIMEOUT_MS`, read by the daemon.
I restarted one computer with it set to 100 and reran: click 0.66 s, type 0.60 s,
key 0.55 s (measured). Each input action got about 0.7 s faster, which is what
800 minus 100 predicts (reasoned). The reads did not change beyond noise. The trade: a dialog that maps 300 ms after a click is no longer reported in that
click's result.

### Fanning a job across computers

The thread's second promise is jobs across all your computers at once. In the SDK
that is not a scheduler; it is that every Space is an independent handle, so
`asyncio.gather` is the fan-out. My job per Space: find the window, read the
tree, select the text area, type `job on <space>`, re-read, press Save, take a
screenshot and run a shell command.

<Figure
  src="https://media.thesatyajit.com/ai/articles/cua-spaces/fig4.png?v=11907ea5315da8e2"
  alt="Three Chromium editor windows side by side, labelled direct:127.0.0.1:3311, 3321 and 3331, each showing its own text, job on xvfb1, job on xvfb2 and job on xvfb3, and Saved 12 chars."
  caption="My run: three direct Spaces after the same job ran on all of them at once (author's run, three Xvfb displays, one cua-spacesd each)."
/>

| Spaces | One after another | All at once | Rounds |
|---|---|---|---|
| 2 | 15.1 s | 8.0 s | 5 |
| 3 | 22.2 s | 8.9 s | 4 |

Medians, measured. A single job took 7 to 9 s, and almost all of it is the
driver's settle and watch windows, which are waits rather than CPU, so three
jobs overlap almost completely: 2.5 times faster with 3 Spaces (reasoned from the
medians). On a real fleet the Spaces are separate machines and nothing contends
at all; on my one VM they shared four busy cores and still overlapped.

## What the agent can touch

"The Space is the sandbox" is the whole security argument, so it is worth being
exact about when it holds.

On a `local:` Space it is a gVisor container or a VM, and the harness inside
runs with auto-approved permissions against that guest. On a `direct:` or
`relay:` computer that is the machine's own desktop, the agent is a process on
that machine. My bare-host daemons proved it: `get_accessibility_tree`, a
read-only discovery call, listed 184 running processes on the VM, mine and
everything else's (measured). The harness installer goes further: it installs a
harness's OS dependencies through apt when it runs as root or has passwordless
sudo (read in `installables.rs`). That is a fine thing to do in a disposable
container and a surprising one on your workstation. It is also why I did not run
`agent_start` here: on my bare host it would have run apt as root on a shared
machine, and every ready harness needed a provider key I was not going to supply.

The controls that do exist are good ones. Tokens never leave the host in account
mode. The MCP server takes permission scopes, so an agent that only needs to
look can be given `spaces:readonly`. Sharing a Space has viewer and editor roles.
The audit trail is hash-chained. The relay is honest about reading plaintext on
its HTTPS path, and the default mesh removes it from the session.

## What I ran, and what I did not

Ran: everything in the previous section. Did not run, and why:

- **The Docker image and `local:` Spaces.** No Docker daemon and no KVM here.
- **Lume and macOS Spaces.** Lume runs macOS VMs on Apple's Virtualization
  framework; it needs Apple silicon.
- **Windows Sandbox and Windows guests.** No Windows host.
- **The relay and the mesh.** Both need a cua.ai account, which this exercise
  ruled out.
- **`agent_start` with any harness.** It needs a provider key, or a custom
  endpoint. The repository has a scripted mock provider, `cua-mock-llm`, built for
  exactly this test, but the installer would have run apt as root on a shared
  host, so I stopped there.
- **The Spaces app, teleport and Cua Volume.** These need the desktop app (the Mac
  build wants macOS 26 or later) and, for teleport, a signed-in app to move.

Licences matter if you plan to self-host. The SDK, the `cua` CLI, cua-driver and
Lume are MIT. The Spaces apps, `cua-spacesd`, the relay, Keyvault, teleport and
Cua Volume are FSL-1.1-MIT: free to use and self-host, no competing hosted
service, each release becoming MIT two years after it ships (reported).

## What to take from it

The interesting decision in Cua Spaces is that the team stopped owning the agent.
The old stack had a loop per model; the new one has a daemon per computer and an
ACP runner that starts whichever agent you already pay for and hands it the same
63 tools.

Per action, the time is in the driver's waiting, not the wire: 8 ms to see the
screen, 1.35 s to click it, 0.7 s of that the window watch. If you build a harness
on it, read the capability report first, ground every pixel action on a capture,
prefer element tokens, and decide how long you are willing to wait for a dialog
that might never open.

For the models that drive these desktops, see [Qwen-CUA](/articles/qwen-cua),
[Holo4](/articles/holo4) and [Fara 1.5](/articles/fara-1-5). For the other way to
build the box, see [DeepSeek DSec](/articles/dsec-agent-sandbox) and [Strands
Box](/articles/strands-agents-and-box); for the loop around the model, [agent
harnesses](/articles/agent-harness).
