Cua Docs

App-hosted daemon reference

Run a private Cua Driver daemon when a desktop app must expose MCP using its permission identity.

This reference describes the daemon-backed host for an application that must expose MCP to an external agent. It is not required for ordinary SDK use: CuaDriver.create() runs the driver inside the importing process with no daemon or socket.

The app-hosted form runs a dedicated cua-driver serve daemon as a direct child of your host app instead of launching the standalone CuaDriver.app. On macOS, the daemon inherits the host app's Accessibility and Screen Recording grants, so users only approve your app. A second cua-driver mcp child proxies stdio MCP traffic to that daemon; it never executes tools itself.

A complete macOS reference host and demo live in the repo at libs/cua-driver/rust/examples/embedded-host-macos.

Target forms#

Cua Driver integrations have three target forms. Each form has a distinct owner and lifecycle:

TargetConnection contractOwner
Existing MCP configurationLaunch its configured command, args, and environment unchangedThe MCP client or user configuration
Existing daemonCuaDriver.connect(socketPath) or cua-driver mcp --socket <path>The process that started the daemon
App-hosted daemonStart EmbeddedCuaDriverHost with an absolute binary path and use the returned connectionThe permission-owning host app

The SDK does not scan PATH, search installation directories, or choose among these targets. The embedding application must select an explicit target. An app that starts an embedded host must use the connection.mcp.command, connection.mcp.args, and connection.mcp.environment values returned by that host instead of reconstructing the proxy invocation.

EmbeddedDriverHostOptions.binaryPath / binary_path may point to any absolute executable path. The path does not determine macOS permission ownership. The process that calls start() determines the responsibility chain. Packaged applications normally keep the executable in their signed resources so the nested binary is covered by packaging, signing, and notarization.

An embedded connection belongs to one host generation. After restart(), the host must replace every SDK client, MCP proxy, and copied MCP configuration with values from the new connection.

Launch embedded#

The supported SDK host generates a private endpoint, clears unsafe ambient environment variables, starts the daemon, and waits for a versioned metadata handshake:

import { CuaDriver, EmbeddedCuaDriverHost } from "@trycua/cua-driver"
 
const host = new EmbeddedCuaDriverHost(
  "/path/to/cua-driver",
  "com.yourco.yourapp",
)
const connection = await host.start()
const driver = CuaDriver.connect(connection.socketPath)
 
// Existing application SDK calls use `driver` unchanged.
// Existing agent runtimes launch connection.mcp.command with
// connection.mcp.args and connection.mcp.environment.

Python exposes the same generated Rust objects:

from cua_driver import CuaDriver, EmbeddedCuaDriverHost, get_binary_path
 
host = EmbeddedCuaDriverHost(str(get_binary_path()), "com.yourco.yourapp")
connection = await host.start()
driver = CuaDriver.connect(connection.socket_path)

For non-SDK hosts, the equivalent low-level launch is:

CUA_DRIVER_EMBEDDED=1 \
CUA_DRIVER_HOST_BUNDLE_ID=com.yourco.yourapp \
cua-driver serve --socket /tmp/yourapp-cua.sock

Then start the MCP proxy against that socket:

cua-driver mcp --embedded --socket /tmp/yourapp-cua.sock \
  --host-bundle-id com.yourco.yourapp

You can pass --embedded --host-bundle-id com.yourco.yourapp to serve instead of the environment variables. Only the exact value CUA_DRIVER_EMBEDDED=1 enables environment-based embedded mode. The host bundle id is an advisory label echoed in check_permissions; trust still comes from macOS's responsibility chain.

Node and Electron hosts#

Use the embedded host in @trycua/cua-driver instead of duplicating socket naming, readiness checks, restart handling, and process cleanup:

import { CuaDriver, EmbeddedCuaDriverHost } from '@trycua/cua-driver';
 
const embedded = new EmbeddedCuaDriverHost(
  '/path/inside/YourApp.app/Contents/Resources/cua-driver',
  'com.example.your-app',
);
const connection = await embedded.start();
const driver = CuaDriver.connect(connection.socketPath);
// Application calls use driver; an agent runtime uses connection.mcp.
driver.uniffiDestroy();
await embedded.stop();
embedded.uniffiDestroy();

The package does not install or bundle cua-driver. Ship a compatible executable outside Electron's ASAR archive, preserve its executable bit, and sign the nested executable before signing and notarizing the enclosing macOS app. The /electron entry point exposes compatibility-named Accessibility and Screen Recording helpers backed by the same generated Rust SDK; there is no second FFI library. The host remains responsible for permission UI, status, and restart policy, and must not start the daemon until both grants are active.

Destroy the SDK client and call await embedded.stop() from every orderly shutdown path. Electron hosts should defer their first before-quit event until stop() completes because asynchronous cleanup cannot run after the host process has exited. If grants change while the daemon is running, destroy the SDK client, call embedded.restart(), and reconnect so macOS re-evaluates them in a fresh process.

Lifecycle contract#

  • start() is concurrency-safe and coalesces callers into one generation.
  • A connection is valid only for its returned generation. restart() changes the generation, PID, and usually the endpoint; discard every old SDK client and MCP proxy before reconnecting.
  • Stop accepting new work, end active sessions, close MCP proxies and SDK clients, then await stop(). stop() cancels startup and is idempotent.
  • Use waitForExit(generation) / wait_for_exit(generation) to observe an unexpected child exit. Do not automatically replay an action whose completion is unknown.
  • The host holds a parent-liveness pipe. EOF shuts down the daemon if the host exits; Rust destruction also requests a kill as a fallback. This is crash containment, not a substitute for orderly shutdown.
  • Capture modality is selected by each observation or action target, not by the host or lifecycle session. Concurrent sessions sharing one daemon may issue exact window and desktop calls independently; always close host leases and explicitly end named sessions when deterministic cleanup matters.
  • macOS grant changes require a daemon restart. Never start the daemon before the permission-owning host process is ready.
  • Custom endpoints are accepted only when they are absent or a stale socket. The host refuses regular files, symlinks, and live listeners and only removes the endpoint identity owned by the matching generation.

Host requirements#

  • Spawn cua-driver serve --embedded directly from your app, for example with Process / NSTask, posix_spawn, or fork / exec.
  • Wait for its private socket to become ready, then spawn cua-driver mcp --embedded --socket <path> and speak MCP over the proxy's stdin/stdout.
  • On macOS, do not launch the driver with open(1) or NSWorkspace.open; LaunchServices makes the launched app its own responsible process and breaks permission inheritance.
  • On macOS, request Accessibility and Screen Recording from the host app with AXIsProcessTrustedWithOptions and CGRequestScreenCaptureAccess.

If macOS grants are added after the daemon has started, restart the daemon so TCC is re-queried with a fresh per-process cache.

App + gateway architectures#

--embedded does not transfer a GUI app's grants to the driver; it only keeps the daemon inside its spawner's macOS responsibility chain. If a separate gateway or Node process spawns the daemon, the daemon inherits the gateway's identity, not the app's. Spawn cua-driver serve --embedded from the app process.

Normal OpenClaw gateway and Hermes YAML MCP configurations remain standalone integrations; do not set embedded mode merely because one of those agents is the client. A signed Node or Electron desktop host may use @trycua/cua-driver/embedded, but only its permission-owning app process may start the daemon. The MCP client can then launch the proxy described by connection.mcp.

Wrong (inherits the gateway's identity):        Right:
 
gateway / node daemon                           YourApp.app
  └─ cua-driver serve --embedded                  ├─ cua-driver serve --embedded
                                                  └─ cua-driver mcp --socket <private>

An Electron app may pass the returned MCP configuration to a separate backend over its existing bootstrap or IPC channel. That backend may launch the MCP proxy, but it must not construct EmbeddedCuaDriverHost or start the embedded daemon on the app's behalf.

What changes#

BehaviorStandaloneEmbedded
Process modelStandalone daemon + proxyHost-spawned daemon + proxy
Daemon launchMay auto-launch CuaDriver.appHost starts private daemon
macOS TCC identitycom.trycua.driver or callerHost app
macOS permission promptsDriver may promptDriver never prompts
macOS Settings entriesCuaDriverHost app only
check_permissions attributiondriver-daemon or callerhost on macOS embedded runs

Driver tools, screenshots, AX tree reads, background input, and the agent cursor overlay otherwise behave the same.

macOS permission check#

Call the check_permissions MCP tool after the proxy connects to the embedded daemon. On macOS, embedded mode ignores prompt requests and should return source.attribution: "host":

{
  "accessibility": true,
  "screen_recording": true,
  "screen_recording_capturable": null,
  "direct_capture_status": "not_checked",
  "source": {
    "attribution": "host",
    "host_bundle_id": "com.yourco.yourapp",
    "embedded": true
  }
}

Embedded mode never lets the driver raise consent dialogs, so this read-only call deliberately does not run the prompt-capable ScreenCaptureKit probe. The host should present its own consent context and verify pixels with an explicit screenshot/capture operation.

If source.attribution is not host on macOS, embedded mode is not active in the daemon handling your MCP calls. Check that CUA_DRIVER_EMBEDDED=1 is passed to the serve child, that the daemon was spawned directly, and that the proxy uses the intended private socket.

source.attribution: "host" means the daemon is running in embedded mode; it does not prove that your GUI app is the responsible process. If a gateway or Node process spawned the daemon, the reported grant state still belongs to that spawner.