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:
| Target | Connection contract | Owner |
|---|---|---|
| Existing MCP configuration | Launch its configured command, args, and environment unchanged | The MCP client or user configuration |
| Existing daemon | CuaDriver.connect(socketPath) or cua-driver mcp --socket <path> | The process that started the daemon |
| App-hosted daemon | Start EmbeddedCuaDriverHost with an absolute binary path and use the returned connection | The 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.sockThen start the MCP proxy against that socket:
cua-driver mcp --embedded --socket /tmp/yourapp-cua.sock \
--host-bundle-id com.yourco.yourappYou 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 --embeddeddirectly from your app, for example withProcess/NSTask,posix_spawn, orfork/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)orNSWorkspace.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
AXIsProcessTrustedWithOptionsandCGRequestScreenCaptureAccess.
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#
| Behavior | Standalone | Embedded |
|---|---|---|
| Process model | Standalone daemon + proxy | Host-spawned daemon + proxy |
| Daemon launch | May auto-launch CuaDriver.app | Host starts private daemon |
| macOS TCC identity | com.trycua.driver or caller | Host app |
| macOS permission prompts | Driver may prompt | Driver never prompts |
| macOS Settings entries | CuaDriver | Host app only |
check_permissions attribution | driver-daemon or caller | host 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.