Running cua-driver under SSH on Windows
The "daemon in interactive session + SSH-side proxy" workflow that makes cua-driver work headlessly over SSH on Windows.
The canonical headless / remote-driving setup for cua-driver on Windows is:
- A
cua-driver servedaemon runs in the user's interactive session (Session 1+), kept alive bycua-driver autostart. - You SSH into the same box (which lands in Session 0).
cua-driver mcpandcua-driver call <tool>on the SSH side proxy through the interactive-session daemon over the daemon's named pipe.- Claude Code (or any MCP client) over SSH drives real GUI apps —
list_apps,click,type_text,screenshotall respond against the user's real desktop.
This page explains why the proxy is necessary, how to wire it up, and the opt-outs.
The Session 0 problem
Windows OpenSSH server runs in Session 0 — the non-interactive services session. Sessions 1, 2, ... are interactive logons (one per console / RDP user). Every process inherits the session of its parent, so a shell spawned from sshd lands in Session 0 too.
The Win32 APIs that cua-driver's window tools bottom out in — EnumWindows, GetForegroundWindow, PrintWindow, UIA, BitBlt over the screen DC — are all scoped to the calling process's WindowStation + Desktop. Session 0 has no interactive desktop attached. So:
# Over SSH (Session 0), with no daemon helper:
cua-driver call list_windows
# [] ← empty. The user's RDP session has 12 windows open.That's not a cua-driver bug — those APIs are working as designed against a session with no desktop. The same thing would happen to any native Win32 tool spawned the same way.
Confirm with cua-driver doctor. The Windows session probe surfaces this directly:
[warn] interactive session: running in Session 0 (services); window-driving
tools (list_windows, click, type_text, screenshot, get_window_state)
will return empty results — these APIs need an attached interactive
desktop.The fix: daemon in Session 1+, proxy from Session 0
The Rust port (cua-driver-rs) ships with a daemon-proxy path on every platform. When cua-driver mcp or cua-driver call <tool> starts up and detects a daemon already listening on the default socket, it forwards every request through to the daemon instead of running tool calls in-process.
The daemon runs in the user's interactive session (registered via cua-driver autostart enable, which uses a Scheduled Task with LogonType: Interactive). The SSH-side CLI runs in Session 0 but only does protocol-shuttling — the actual tool invocations execute inside the daemon, which has a real desktop attached.
┌───────────────────────────────────────────────────────────────┐
│ Session 1+ (RDP / console — has interactive desktop) │
│ │
│ cua-driver-serve (autostart Scheduled Task) │
│ ↑ │
│ │ named pipe: \\.\pipe\cua-driver │
│ │ │
└──────┼────────────────────────────────────────────────────────┘
│
┌──────┼────────────────────────────────────────────────────────┐
│ Session 0 (services / SSH — no desktop) │
│ │ │
│ cua-driver mcp │
│ cua-driver call list_windows │
│ Claude Code → MCP stdio → cua-driver mcp │
└───────────────────────────────────────────────────────────────┘The canonical recipe
# ── ONE-TIME from your interactive session (RDP or console) ──────────
cua-driver autostart enable # register the Scheduled Task
cua-driver autostart kick # start it now, don't wait for next logon
# ── from SSH (or any session — Just Works) ───────────────────────────
cua-driver status
# cua-driver daemon is running
# socket: \\.\pipe\cua-driver
# pid: 12345
# session: 2 ← daemon's in your interactive session
cua-driver call list_apps # sees real GUI apps
claude # Claude Code's MCP tool calls proxy throughautostart enable registers a Scheduled Task that re-runs cua-driver serve at every interactive logon — see the Autostart concept page for the verb-by-verb breakdown and the RDP-disconnect-survives property.
Prerequisite — the user must have an active interactive session. Either:
- An RDP client is connected, OR
- An RDP client was connected and has disconnected without logging off (the session stays in
Discstate and is still considered active), OR - The user is logged in at the local console.
Confirm with query session from any shell — the user row should show Active or Disc:
query session
# SESSIONNAME USERNAME ID STATE TYPE
# rdp-tcp#23 you 2 Active ← good
# # — or —
# rdp-tcp#23 you 2 Disc ← also goodNo interactive session means autostart kick has no Session 1+ to land the daemon in — kick will report success but the daemon won't have a desktop attached. Open an RDP session once and the autostart trigger will fire serve automatically next logon.
How the proxy decides whether to forward
cua-driver mcp and cua-driver call <tool> share the same gating function (should_use_daemon_proxy in cli.rs):
- If
--no-daemon-relaunchis passed, stay in-process. - If
CUA_DRIVER_RS_MCP_NO_RELAUNCH=1is set, stay in-process. - Otherwise, proxy if a daemon is listening on the default socket; run in-process if none is up.
There's no auto-spawn on Windows / Linux — unlike macOS, there's no open -a CuaDriver equivalent that could land a fresh daemon in the right session. If no daemon is listening when you run cua-driver mcp, the CLI bails with an actionable error:
no cua-driver daemon listening on \\.\pipe\cua-driver. Start one in your
interactive session — on Windows run `cua-driver autostart enable &&
cua-driver autostart kick`; on Linux run `cua-driver serve &` in the user's
session. Then re-run `cua-driver mcp`. To skip the proxy and run in-process
anyway (Session 0 attribution, GUI tools will return empty), pass
--no-daemon-relaunch.Why call worked over SSH before v0.2.7 but mcp didn't. The call CLI had its own daemon-proxy shortcut baked in from the start (the daemon is the only place the per-pid element cache lives, so call had to proxy to share state across invocations anyway). The mcp entrypoint was hardcoded to run in-process on Windows / Linux on the assumption that MCP clients spawn fresh subprocesses — true, but irrelevant: a fresh subprocess over SSH is still in Session 0. cua-driver-rs v0.2.7 (PR #1580) lined the two entrypoints up so mcp proxies on the same conditions call does.
If you're on v0.2.6 or earlier and saw empty MCP results over SSH while cua-driver call list_apps worked, upgrade — irm https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1 | iex.
Wiring Claude Code over SSH
Once the daemon is up in your interactive session, the Claude Code registration is unchanged from the local case:
# From inside your SSH session:
claude mcp add --transport stdio cua-driver -- cua-driver.exe mcp
claudeEvery MCP tool call Claude makes over the stdio link triggers cua-driver mcp on the SSH side, which detects the daemon and proxies to it. The MCP client sees an ordinary stdio server; the daemon sees a steady stream of tool calls from its Session 1+ desktop context.
Opt-out: in-process even when a daemon is up
There are legitimate reasons to skip the proxy — running inside CI in an already-interactive runner that owns its own session, debugging the in-process MCP server, or running an integration test that boots its own daemon and wants the CLI to talk to a different socket.
Two equivalent opt-outs:
cua-driver mcp --no-daemon-relaunch # CLI flag, per-invocation
$env:CUA_DRIVER_RS_MCP_NO_RELAUNCH = "1" # env var, per-shell
cua-driver mcp # now stays in-processEither form keeps cua-driver mcp in-process and the tool calls execute against whatever session spawned the CLI — Session 0 over SSH (empty GUI results), or the interactive session if you're running locally.
Diagnostics — "my MCP calls return empty over SSH"
Walk this list before opening an issue:
cua-driver --versionon the SSH side reports0.2.7or later. Earlier versions don't proxymcpon Windows / Linux. Upgrade with the install one-liner.cua-driver statusfrom SSH reports a running daemon. If not,cua-driver autostart statuswill tell you whether the Scheduled Task is registered. Re-runenable && kickfrom an RDP session if not.query sessionshows anActiveorDiscrow for your user. No interactive session means no Session 1+ for the daemon to land in.cua-driver doctorfrom RDP reports[ok] interactive session: session N has an attached interactive desktop— confirms the daemon's session has a desktop attached. Run from the same session you registered autostart in.- You did not pass
--no-daemon-relaunchandCUA_DRIVER_RS_MCP_NO_RELAUNCHis unset — both opt the proxy out.
If all five pass and tool calls still come back empty, run cua-driver call list_windows over SSH and compare with the same call from RDP. Same empty result on both means the daemon's in the wrong session; differing results mean the proxy isn't activating (file an issue with cua-driver doctor --json from both sessions).
See also
- Autostart — verb-by-verb breakdown of
cua-driver autostart, the daemon-survives-RDP-disconnect property. - Installation → Windows interactive-session requirements — the underlying session model.
- Installation → Auto-start at logon (Windows) — manual Scheduled Task registration (equivalent to
autostart enable, kept for users who want to hand-craft the task).
Was this page helpful?