ccdesk
Provides desktop control on GNOME under Wayland, with full-screen screenshots via gnome-screenshot, pointer and text input via ydotool, and clipboard access via wl-clipboard. Keyboard shortcuts and window management are limited or unavailable.
Provides desktop control on Hyprland, including screenshots via grim, pointer input via ydotool, keyboard input via wtype, window listing and focusing via hyprctl, and clipboard access via wl-clipboard.
Provides desktop control on KDE Plasma under Wayland, with pointer input via ydotool and clipboard access via wl-clipboard. Screenshots and keyboard input are not supported.
Provides desktop control on Linux, including screenshots, mouse and keyboard input, window management, and clipboard operations. Supports both X11 and Wayland environments.
Provides desktop control on Sway, including screenshots via grim, pointer input via ydotool, keyboard input via wtype, window listing and focusing via swaymsg, and clipboard access via wl-clipboard.
Enables desktop control on Wayland compositors, with capabilities such as screenshots via grim, pointer input via ydotool, keyboard input via wtype, window management via compositor-specific tools like swaymsg or hyprctl, and clipboard access via wl-clipboard.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
ccdesk
Claude Code can't see your desktop on Linux. ccdesk gives it eyes and hands.
It also works with other MCP clients: see Using it with opencode.
A small MCP server that lets Claude Code take screenshots, move and click the mouse, type, press keys, list and focus windows, use the clipboard and read the accessibility tree, plus a skill that teaches the safe loop: look, one action, verify.

Recorded by bench/record_demo.py: a real ccdesk session on a private Xvfb screen, not a mockup.
Results
Same GTK signup form filled end to end. Claude Code 2.1.304, Opus 5.5, claude -p, no user
settings or hooks, 3 runs per arm, all 9 succeeded. Input tokens processed, median:
no ccdesk | screenshots only | ui_tree + changes | + | |
Input tokens | 89,456 | 117,743 | 63,361 | 56,681 |
Wall time | 30 s | 22 s | 15 s | 15 s |
Images read | 0 to 3 | 3 | 0 | 0 |
37% fewer input tokens than doing it without ccdesk, and 2x faster.
The saving is fewer round trips, not cheaper looks: one
ui_tree()replaces several screenshots, and after the first tree every action returns only the lines that changed.Screenshots alone are the expensive path (118k), because a full-screen first look plus one tool call per click costs more than the shell commands Claude writes when it has no help.
Since then
screenshotshows the picture in its answer instead of needing a Read: 136k → 78k on that path in a later batch (median of 5, a different setup, so don't compare it with this table).Raw numbers, method and caveats:
bench/RESULTS.md. Reproduce withpython bench/run.py.
Related MCP server: claude-linux-mcp
Quickstart
sudo apt install xdotool scrot wmctrl xclip x11-utils x11-xserver-utils # Debian/Ubuntu, X11
pipx install ccdesk # from PyPI; today the wheel on the Releases page, or pipx install . in a checkout
ccdesk doctor # session type, helper tools, screen size, accessibility bus
ccdesk install # register the MCP server with Claude Code and install the skillRestart Claude Code and run /mcp: ccdesk should be listed as connected. To try it on a
private screen instead of yours, see Headless.
Other distributions
On Fedora: sudo dnf install xdotool scrot wmctrl xclip xprop xwininfo xrandr. On Arch:
sudo pacman -S xdotool scrot wmctrl xclip xorg-xprop xorg-xwininfo xorg-xrandr.
xwininfo gives the size of the whole screen across monitors and xrandr lists the
monitors; without them ccdesk falls back to xdotool, which recent releases report as
one monitor only.
On KDE Plasma under Wayland, the X11 tools are not what you need: see KDE Plasma. Plasma on X11 uses the X11 tools above.
Why it exists
There's no Claude Desktop app on Linux. ccdesk fills that gap with the system tools you
probably already have (xdotool, scrot, wmctrl, xclip). The core is standard-library
Python; the only dependency is the official mcp package.
Wiring it into Claude Code
ccdesk install does two things:
Copies
skills/computer-use/SKILL.mdto~/.claude/skills/computer-use/SKILL.md(or$CLAUDE_CONFIG_DIR/skills/...).Runs
claude mcp add --scope user ccdesk -- /path/to/ccdesk mcp, after removing any earlierccdeskregistration.
Flags you pass to install are forwarded to the server: ccdesk install --read-only,
ccdesk install --allow-launch or ccdesk install --confirm-keys "super+x".
--always-load puts the tool schemas in every session's prompt instead of behind Claude
Code's tool search: one round trip less when you use the desktop, ~2.6k tokens more per
request when you don't (see bench/RESULTS.md). --dry-run
prints the exact file, size, checksum and commands, and changes nothing:
$ ccdesk install --dry-run
ccdesk install --dry-run (nothing is written)
[1] would write /home/hermes/.claude/skills/computer-use/SKILL.md
6198 bytes, sha256 b0dbc9581ca05b94…, copied from /home/hermes/Projects/ccdesk/skills/computer-use/SKILL.md
[2] would run: claude mcp remove --scope user ccdesk # ignored if not registered yet
[3] would run: claude mcp add --scope user ccdesk -- /home/hermes/Projects/ccdesk/.venv/bin/ccdesk mcpTo wire it by hand instead:
claude mcp add --scope user ccdesk -- ccdesk mcp
mkdir -p ~/.claude/skills/computer-use && cp skills/computer-use/SKILL.md ~/.claude/skills/computer-use/Restart Claude Code and run /mcp. ccdesk should be listed as connected.
Using it with opencode
ccdesk is a standard MCP server, so opencode can run it too. Let opencode write the entry, with ccdesk's full path. This stops if ccdesk is not on your PATH:
bin=$(command -v ccdesk) && opencode mcp add --global ccdesk -- "$bin" mcp # add --headless for a private screen
opencode mcp list # ccdesk should say connectedThe first mcp list from a folder can say No MCP servers configured while the server starts. Wait
a few seconds and run it again. If opencode was already running when you added it, run
opencode service restart first.
Then keep its tools as ordinary tools, in ~/.config/opencode/opencode.json. Running mcp add again
removes this setting. Keep the command it wrote, with its full path:
{
"mcp": {
"servers": {
"ccdesk": {
"type": "local",
"command": ["/full/path/to/ccdesk", "mcp"],
"codemode": false
}
}
}
}opencode's default, Code Mode, has the model call tools from code. ccdesk's loop relies on each
result and screenshot reaching the model directly, so codemode: false is the recommended setting.
opencode already reads the skill from ~/.claude/skills, so ccdesk install covers it. Without
Claude Code, copy it yourself:
mkdir -p ~/.config/opencode/skills/computer-use
cp skills/computer-use/SKILL.md ~/.config/opencode/skills/computer-use/To restrict the tools, opencode's permission rules match them as ccdesk_<tool>.
Tested on opencode v2.0.32, once, with ccdesk mcp --headless, codemode: false and
deepseek-v4.1-flash-free: its agent launched the benchmark form, described it from a screenshot,
then filled and submitted it through ui_tree, set_value and click_element, and the saved values
were correct. Screenshots need a model with vision. Not tested: Code Mode with a full task, input
on your real screen, or other models. The token numbers in this README were measured on Claude Code
only.
Headless: let it work while you use your PC
ccdesk install --headless # or: ccdesk mcp --headless [--size 1600x900]
ccdesk peek --open # see what the agent's screen shows right nowWith --headless the server starts its own invisible screen and does everything there, so
you keep your mouse, keyboard, windows and clipboard while Claude works. When Claude Code
closes the server, the screen and everything on it go away (also if the server is killed:
its helpers die with it).
What it starts: Xvfb on a free display number, a window manager (the first of openbox,
fluxbox, icewm, matchbox, xfwm4, else bspwm with a stub config; --wm none|CMD to choose),
and a private D-Bus session with its own accessibility bus. The private bus means:
ui_treesees only the agent's apps, never yours.Apps don't hand their window to a copy already running on your screen (Firefox, GNOME apps).
Apps on that screen can't reach your keyring, notifications or other session services.
Accessibility is on for GTK, Qt and Firefox without touching your settings.
The screen starts empty, so --headless turns on launch. A browser that is already open
on your screen locks its profile; give the agent its own:
firefox --no-remote --profile ~/.cache/ccdesk/firefox (create the folder first) or
chromium --user-data-dir=~/.cache/ccdesk/chromium --force-renderer-accessibility.
It runs X11 apps, and works the same from an X11 or a Wayland session (GNOME and KDE
included): apps on it are told to use X11.
Needs Xvfb (Arch: xorg-server-xvfb, Debian/Ubuntu: xvfb), plus dbus-daemon and
at-spi2-core, which desktops already have. ccdesk peek saves a screenshot of every running
headless screen to ~/.cache/ccdesk/peek-N.png; for a live view, run
x11vnc -display :N -localhost and any VNC viewer (doctor() shows the display).
Both screens: let Claude pick per task
Headless can't see your logged-in browser, and copying a browser profile to get its cookies is a bad idea. Register a second server on your real screen, and Claude chooses one per task:
claude mcp add --scope user ccdesk -- ccdesk mcp --allow-launch --headless
claude mcp add --scope user ccdesk-screen -- ccdesk mcpUse ccdesk for work that doesn't need your accounts, and ccdesk-screen for anything that
does, like a web console or a chat you're signed in to. Keep your hands off the mouse while
Claude works on ccdesk-screen. Restart Claude Code after adding a server; it reads MCP
servers only at startup.
Several agents on one screen
A screen has one pointer and one keyboard focus. Every input action takes a lock on the display
for the focus check and the clicks or keys, so two ccdesk servers on the same display take turns
one action at a time and never interleave inside an action. Waiting for the app to settle and
comparing the window afterwards only reads the screen, so it runs after the lock is released.
A batch call holds the lock for all of its steps. Give each server
a name with --agent:
ccdesk mcp --agent bot-1
ccdesk mcp --agent bot-2With --agent, when another agent has moved focus away from the window this agent activated,
ccdesk activates that window again and sends the input. Without it, ccdesk refuses, because the
one who moved focus is probably you. Audit entries carry the agent's name.
Screenshots show the drawn cursors of every agent on the screen, this one's included. With
--yield-ms, input that comes right after any agent's counts as that agent's, and an agent that
waited in line for the screen checks once more that you are still away before it sends anything.
On X11, each named agent also gets a drawn cursor: an arrow in the agent's colour with a small
label holding its name. It moves to the point of each click, move, drag, scroll and click_element,
and to the field set_value writes to. It is only a picture: the real pointer still does the
clicking, and the overlay window takes no input and is left out of list_windows. It needs a
system Python with PyGObject (the same one ui_tree uses); if that is missing the cursor turns
itself off and doctor says so. The colour is picked from the name; set it with
--cursor-color '#RRGGBB', or turn the cursor off with --no-cursor.
In a test on Xvfb, two agents typing 40 words each into two windows got their own text intact
in 4.1 s. With no lock, both windows came out with letters from both agents.
With two agents typing 15 words each and no ui_tree, comparing outside the lock cut the time an
action holds it from 0.48 s to 0.13 s, and both finished in 7.4 s instead of 14.4 s (2026-11-07).
Because another agent may act while this one compares, a screen_changed can now include that
agent's drawn cursor or its window moving over this one.
An own pointer and keyboard for each agent (X11)
ccdesk mcp --agent bot-1 --own-pointer gives the agent its own pointer and keyboard focus, so it
does not use yours and does not wait for the other agents. At startup it runs
xinput create-master ccdesk-bot-1, which adds a master pointer and keyboard (with their XTEST
devices) that only this agent drives. Moves, clicks, drags, scrolling, typing and key presses go
through them; your pointer stays where it is and your keyboard focus does not change. It
needs xinput, libX11, libXi and libXtst, and it is X11 only. It is refused without --agent and
on other backends. Names must be unique on a screen: a second server with the name of a running
one refuses to start ("use another --agent name"). The devices are removed when the server exits,
also on SIGTERM and SIGINT. After a kill -9 or a crash, the leftovers of dead agents (the ones
whose name is not in use) are removed at the next own-pointer start or by ccdesk doctor, which
lists what it removed.
Window managers that grab clicks can move your keyboard focus when the agent clicks. bspwm does
this on every unfocused window, so with bspwm each agent button press runs with
click_to_focus set to none for a moment (under a lock shared by all agents on the screen) and
the saved value is put back right after; doctor or the next start restores it if a process died
in between. Other window managers are not handled, and doctor says so: an agent click there may
move your keyboard focus.
activate_window sets the agent's keyboard focus and leaves your window manager alone. Only a
window that is not on screen (minimized, or on another desktop) goes through the normal
activation, which does move your focus once; the answer says so in note. A window another
window covers still receives no clicks: the click is refused, as before. The focus check
compares against the agent's own focus and the click check against the window under the agent's
pointer. Each own-pointer agent has its own input lock, so two of them type at the same time.
--yield-ms is ignored: X counts every agent's input, own-pointer input included, as activity
(it also resets screensavers and the idle timer other agents' --yield-ms reads), so own-pointer
agents would wait on each other. The drawn cursor follows the agent's pointer.
Characters that have no key in your keymap are typed through a spare keycode that is remapped
for a moment, as xdotool does. doctor shows the device ids, or why they are unavailable.
A named private screen for several agents
Each --headless server starts its own private screen, so two of them never see each other's
windows. To share one private screen, start it on its own and have the servers join it:
ccdesk screen start work [--size 1600x900] [--wm CMD] # foreground; Ctrl-C or `screen stop` ends it
ccdesk mcp --screen work --agent bot-1
ccdesk mcp --screen work --agent bot-2
ccdesk screen list
ccdesk screen stop workscreen start prints how to watch it (x11vnc -display :N -localhost); ccdesk peek also
lists named screens. --screen turns on launch like --headless and cannot be combined with
it. A server that exits leaves the screen running. ccdesk install --screen work registers the
server with that flag. The state file is $XDG_RUNTIME_DIR/ccdesk/screens/NAME.json (mode 0600,
in a 0700 directory) and holds the display, size, window manager, pid and only the variables a
joining server needs.
Firefox for Claude, without the mouse (opt-in)
For web pages, ccdesk can hand off to Mozilla's
firefox-devtools MCP server. It drives
Firefox over WebDriver BiDi: it reads the page as text (a small page's snapshot is a few dozen
tokens) and clicks inside it, so your mouse and keyboard stay yours. Needs Node.js for npx.
ccdesk install --firefox # also registers firefox-devtools (npx, pinned, --tool-preset slim)Claude then has its own Firefox: the server opens it the first time Claude needs a page,
with a profile kept in ~/.local/share/ccdesk/firefox. Sign in there once to the sites Claude
should use (only those, not your mail or bank); the logins stay for next time. Its window
has an orange strip on top and an orange active tab, so you can tell it from yours (the
profile's chrome/userChrome.css). Your own Firefox is never restarted or flagged. That browser tells sites it is automated
(navigator.webdriver), so some (Cloudflare, Akamai) may challenge it.
To let Claude use your Firefox and the logins it already has instead:
ccdesk install --firefox mine
ccdesk firefox # start your Firefox with --marionette --remote-debugging-port
ccdesk firefox --off # afterwards: start it normally againFirefox only takes the flags when it starts, so ccdesk firefox asks you to quit a running
Firefox first (Ctrl+Q; session restore brings your tabs back); it never quits it for you.
While the flags are on, sites can see the automation in your everyday browsing, and any
program on this PC can drive your browser through the local port. ccdesk doctor warns
while it is on.
Tools
Tool | What it does |
| Saves a PNG and shows it in the answer, with |
| Moves the pointer and clicks. |
| Moves the pointer (hover). |
| Left-button drag along a smooth path. |
| Wheel clicks with the pointer at (x, y). |
| Types into the focused widget. |
|
|
| Returns |
| Raises and focuses a window by id or by partial title. Later input is refused if it would reach another window. |
|
|
| Each monitor's name and box, plus the size of the whole screen. |
| Reads or replaces the clipboard. |
| Sleeps, up to 300 s. Right after an action, says |
| Waits (up to 300 s) until the screen differs from how it looked right after the last action, then until it is still for |
| Reports the session type, tools found, screen size, scale and policy. |
| The window's buttons, fields, checkboxes, links and text as compact lines from the accessibility tree (AT-SPI), with values, states and screen boxes. |
| Clicks an element from the latest |
| Replaces the value of a field, slider or spin button in one call and reads it back: success means the app holds it ( |
| Runs 1 to 20 input steps ( |
| Starts an app without a shell. Only enabled with |
Coordinates are screen pixels with the origin at the top left, the same space as a full-screen screenshot.
Demo
This transcript is a real stdio session against ccdesk mcp, recorded under xvfb-run
while driving the Tk test app in tests/tk_app.py. Each > line is a tool call and each <
line is the server's response. The pixel coordinates were found in the screenshot. In
Claude Code, Claude does the same thing by reading the PNG.
> list_windows()
< {"windows": [{"id": "0x00200029", "title": "ccdesk-test", "class": "tk.Tk", "geometry": {"x": 40, "y": 50, "width": 420, "height": 420}, "desktop": null}]}
> activate_window(title_or_id='ccdesk-test')
< {"activated": {"id": "0x00200029", "title": "ccdesk-test", "class": "tk.Tk", "geometry": {"x": 40, "y": 50, "width": 420, "height": 420}, "desktop": null}}
> screenshot()
< {"path": "/home/hermes/.cache/ccdesk/screenshots/screen-20261019-200511-959.png", "width": 1280, "height": 1024}
> click(x=249, y=79)
< {"clicked": {"x": 249, "y": 79}, "button": "left", "count": 1}
> screenshot(region='99,49,300,110')
< {"path": "/home/hermes/.cache/ccdesk/screenshots/screen-20261019-200514-075.png", "width": 300, "height": 110, "origin": {"x": 99, "y": 49}, "note": "pixel (px,py) in this image is screen (origin.x+px, origin.y+py)"}
> click(x=249, y=179)
< {"clicked": {"x": 249, "y": 179}, "button": "left", "count": 1}
> type_text(text='hello from Claude')
< {"typed_chars": 17}
> press_key(key='Return')
< {"pressed": "Return"}
> press_key(key='w', modifiers=['ctrl'])
< ERROR Error executing tool press_key: Ctrl+w can close windows or quit applications; take a screenshot to verify the target, then call again with confirm=true
> launch(command='xterm')
< ERROR Error executing tool launch: launch is disabled: restart the server with `ccdesk mcp --allow-launch`When the session ended, the app reported label: "submitted:hello from Claude". In Claude
Code a typical request looks like "open Firefox, go to news.ycombinator.com and tell me the
top story". The skill makes Claude find or focus Firefox, use ctrl+l for the address bar,
type the URL, wait, screenshot and read the page.
Token cost
Screenshots are most of what a ccdesk session costs. The model pays about width*height/750
input tokens per image it reads (each result includes approx_tokens), and the image stays in
context for every later turn. Measured on a real Play Console session: a 1920x1080 monitor
capture is ~2,800 tokens, and 17 of them were about 70% of the context the session used.
Capture | Size | Tokens |
one monitor, full scale | 1920x1080 | ~2,765 |
one monitor, | 1152x648 | ~995 |
one monitor, | 960x540 | ~691 |
region around a form field | 600x300 | ~240 |
The cheapest way to look is not a picture at all. ui_tree() reads the accessibility tree
(AT-SPI, what screen readers use) and returns the on-screen elements as text: a form is
usually 100 to 600 tokens, and on a test form a full fill-and-save took two ui_tree() calls
of ~150 tokens each and no screenshots. Clicks land on the element's real box instead of an
estimate from pixels.
Apps only publish the tree when accessibility is on when they start:
gsettings set org.gnome.desktop.interface toolkit-accessibility true # GTK apps and Firefox
# or start them with GNOME_ACCESSIBILITY=1; Chromium/Electron: --force-renderer-accessibilityOutside GNOME (bspwm, i3, sway...) that is often not enough: the session's accessibility bus
has its own switch, org.a11y.Status IsEnabled, that nothing turns on, and Firefox reads it
at startup. ccdesk doctor shows it as a11y bus: off and prints the fix:
busctl --user set-property org.a11y.Bus /org/a11y/bus org.a11y.Status IsEnabled b true
# then restart Firefox; the setting lasts until logout, so add the line to .xinitrc or bspwmrcWhen ui_tree() finds no window, its error names the window that is missing from the bus,
what is on the bus, and this fix, so Claude can ask you to apply it. Together with that text,
ui_tree() attaches a picture of the window (at most about 1500 tokens), so the model does not
need another request for screenshot(). It does the same when the tree has fewer than 3 lines and
none with a button, field or other role to act on (an Electron or canvas window). Not with
query=, and shot_fallback=false turns it off.
The tree is read by ccdesk/a11y_helper.py, which runs under a system Python with PyGObject
(python-gobject on Arch, python3-gi on Debian, plus at-spi2-core), so ccdesk itself keeps
no compiled dependencies. CCDESK_A11Y_PYTHON picks another interpreter. ccdesk doctor
lists the apps that publish a tree. The helper stays alive for the session (one JSON request
per line on stdin), so a read does not pay for a Python start and an AT-SPI handshake every
time; it exits when ccdesk does.
After the first ui_tree(), actions (click, click_element, type_text, press_key)
return changes: only the lines of that window that changed, such as
e5 check box "I accept the terms" [checked]. The model verifies from that instead of
reading the tree or the screen again. Refs stay stable across calls. The helper watches the
window for up to 0.3 s and answers the moment a line changes, so a fast app reports its effect
at once instead of after a fixed pause: on the bench's GTK form a click went from 605 ms to
22 ms (bench/RESULTS.md).
Measured end to end (bench/, same form task, Claude Code's own usage numbers, 3 runs each):
no ccdesk 89k input tokens, ccdesk with screenshots 118k, ccdesk with ui_tree and changes
63k (−29%), 57k with --always-load (−37%). Without ccdesk Claude already screenshots just
the window, so the saving is mostly fewer round trips, not smaller pictures.
Without a tree (apps that don't publish one, or the bus switched off), click, type_text,
press_key, scroll and drag compare a 1/8-size grayscale copy of the receiving window
from before and after the action and return screen_changed and, if it changed,
changed_area. A blinking text caret doesn't count. "Save did nothing" is then one call
instead of a wait and two screenshots, and wait_for_change() replaces fixed wait()s for
pages and dialogs. Each comparison costs about 0.2 s per capture (ImageMagick does the
shrinking; without it the fields are left out).
show_change=true on those same actions also puts a capture of
changed_area (padded 24 px, scaled down to about 600 tokens at most) in the answer as
change_shot, so the check needs no screenshot of its own. And when a screenshot taken right
after an action is more than four times the size of what changed, or a wait() of a second
or more follows an action, the result carries a hint naming the cheaper call. In a real
session without a tree (filling three web forms of the same shape) most of the 117 screenshots were
half-scale full-monitor checks of a click that changed a few hundred pixels, at ~700 tokens
each, and 86 fixed wait()s did what wait_for_change() does.
The skill tells Claude to orient at scale=0.5, then act and verify with small full-scale
regions, and to pull long page text through the clipboard instead of reading it from pixels.
Security model
ccdesk drives your real desktop with your user's privileges. The guardrails are:
Read-only mode.
ccdesk mcp --read-onlydisables every input tool:batch,click,click_element,set_value,move,drag,scroll,type_text,press_key,activate_window,manage_window,clipboard(set)andlaunch. Screenshots,list_windows,clipboard(get),waitanddoctorstill work.No launching by default.
launch(command)refuses unless the server was started with--allow-launch. Even then it runs the command without a shell (shlex.split, no pipes, redirects or;) in a new session.Destructive shortcuts need confirmation.
Alt+F4,Ctrl+Q,Ctrl+W,Super+Q,Ctrl+Alt+DeleteandCtrl+Alt+BackSpaceare refused unless the call passesconfirm=true. Supersets such asCtrl+Shift+Wcount too. Window managers bind their own close, quit and power keys, so add yours with--confirm-keys, for example on bspwm:ccdesk install --confirm-keys "super+x,ctrl+alt+super+p,ctrl+alt+super+r,ctrl+alt+super+q".Input goes to the window Claude chose. An accessibility action goes to its element wherever focus and the pointer are; if the tree is of another window than
activate_window()chose,set_valuerefuses andclick_elementtakes the checked route below. Otherwise, afteractivate_window(),click,click_element,drag,scroll,type_textandpress_keycheck where the input would land (the window under the pointer, or the one with keyboard focus) and refuse when it is another window, for example because you switched to your terminal to answer. Dialogs of that window, such as a file chooser, count as the same window.force=truesends it anyway. Every input result names the window that received it ("window": {id, title}). This needs an EWMH window manager on X11 or Hyprland; without one, or on other Wayland compositors, focus is unknown and the check is skipped. On Hyprland the stacking order under the pointer is inferred, because Hyprland does not report it, and a dialog is recognised as a floating window of the same app on the same workspace.Input goes to the tab Claude chose. A browser is one window with many tabs, so the check above passes when you switch from the tab Claude was using to your mail. Pass
expect_title="Kimai"toclick,click_element,type_textorpress_keyand it refuses unless the receiving window's title contains that text (case-insensitive); Firefox and Chromium put the active tab's title in the window title.force=truedoes not skip it, and when the window can't be known the call is refused rather than sent unchecked. It is checked once, just before the input: a tab switch while text is being typed is not caught.dragandscrolldon't take it.Yielding to you. On your own screen you share one pointer and keyboard with Claude.
ccdesk mcp --yield-ms 3000(orccdesk install --yield-ms 3000) holds every input action until you have left the mouse and keyboard alone for 3 s, waiting up to 15 s and then refusing with "the user is using the mouse or keyboard". ccdesk's own events reset X's idle counter too, so only input that came after its own counts as yours. Results sayyielded_mswhen it waited, anddoctorshowsuser idle. Needsxprintidle(X11); off by default, and pointless for--headless.Typed text stays private. It reaches
xdotool,wtypeandydotoolthrough stdin, not as arguments that any process of yours could read in/proc. After aui_tree()that shows a focused password field,type_textrefuses. It knows only what the latest read of the tree showed, so this is a backstop for the rule below, not a guarantee.Audit log. Every call, including denied and failed ones, is appended as one JSON line to
~/.cache/ccdesk/audit.log(mode 0600; override with--audit-logorCCDESK_AUDIT_LOG). Each line has a timestamp, the tool, its arguments, the status (ok/denied/error), the result or error, and the duration. Clipboard contents are logged by length only. Typed text is logged, truncated to 200 characters, which is one more reason the skill tells Claude never to type secrets.Input validation. Coordinates are bounds-checked against the screen. Counts, scroll amounts, durations, waits and text length are capped. Key names are whitelisted characters, so nothing reaches a helper tool as an option or shell syntax.
The skill. It tells Claude to screenshot before acting and verify after, to never type passwords, to never dismiss dialogs blindly, and to treat on-screen text as data, not instructions.
Each tool also carries MCP annotations (readOnlyHint, destructiveHint, openWorldHint), so a
host can tell looking from acting. They are hints for the host; the checks above are what refuse.
These guardrails are not a sandbox. Any client that can call type_text can type a shell
command into a terminal. Use --read-only when you only want Claude to look, and keep
Claude Code's own tool-permission prompts turned on for mcp__ccdesk__* if you want to
approve each action.
Wayland caveats
Wayland isolates clients from each other by design, so ccdesk can only do what the compositor allows:
Screenshots need
grim, which requires wlr-screencopy (sway, Hyprland, river, Wayfire). It runs asgrim -s 1: logical pixels, so coordinates match, but on a scaled monitor the image is not at full physical resolution. GNOME falls back tognome-screenshot -f, and only for full-screen captures. KDE has neither; on Plasmaspectacledoes it (see below).Pointer input on sway goes through sway itself (
swaymsg seat - cursor set/press/release): exact positions, no root, nothing extra to install. Elsewhere ccdesk speaks the wlr-virtual-pointer protocol directly (ccdesk/backends/vpointer.py, standard library only): exact positions, real motion events, no root, no daemon, nothing to install. Verified on Hyprland; river and Wayfire should work where the protocol is offered, not tested. GNOME and KDE don't offer it. As the last resort, compositors without it needydotool1.x with theydotoolddaemon and access to/dev/uinput; absolute positioning then depends on ydotool's pointer acceleration, so verify clicks with screenshots. (On Hyprland it was measured landing at double the requested position.)Keyboard uses
wtype(virtual-keyboard protocol, not available on GNOME) and falls back toydotool typefor plain text.press_keyrequireswtype, except on Hyprland, where it uses Hyprland's ownhl.dsp.send_shortcut. Known issue: a named key sent alone withwtype -k(Left, BackSpace) does not reach GTK 3 apps, while typed text does. Seen on a real Hyprland and on a headless sway with only virtual devices; not yet checked on a real sway session. Text reacheswtypethrough stdin, to keep it out of the process list. In Chromium on sway that typed the same as an argument. Accents, arrows, dashes and CJK arrive intact, but Chromium cuts emoji to 16 bits, so🙂becomes a private-use character.type_textwarns when text has emoji; useclipboardandctrl+vthere.xdotoolon X11 typed the same emoji into Chromium correctly. computer-use-linux reports stdin garbling text in Electron on Hyprland; not checked here on a real Hyprland, so verify typed text in those apps.ui_tree works on Wayland too. GTK and Qt report element boxes relative to their own window there; on sway ccdesk adds the window's position from
swaymsg -t get_treeand on Hyprland fromhyprctl clients, soclick_elementlands on screen coordinates. On other compositors the result carries a note to verify with a screenshot.Windows are listed and focused through
swaymsg(sway) orhyprctl(Hyprland, with Luahl.dsp.focus, falling back to the oldfocuswindowfor versions before Lua; the fallback is untested, andpress_keyandmanage_windowneed a version with Lua). On Plasma,kdotooldoes it through KWin scripting. Elsewhere ccdesk falls back to XWayland viaxdotool, which only sees X11 apps.Window control (
manage_window) is implemented on sway, Hyprland and KDE Plasma; other compositors refuse with a clear error. On Hyprland, move and resize make a tiled window float, maximize uses Hyprland's maximized state, minimize moves the window to a hidden special workspace, and restore undoes those, brings a minimized window to the workspace on show, and re-tiles only windows this ccdesk server floated (a restart forgets them). On Plasma the moves and sizes go to KWin, which decides what a tiled window does with them. Monitors come fromswaymsg,hyprctlor, on Plasma, KWin; on Hyprland they and the screen size are logical pixels. ccdesk's coordinates start at 0,0, sodoctorwarns about a monitor at a negative position. Per-window screenshots crop the window's box, which needs a compositor that lists windows.Clipboard uses
wl-copy/wl-paste.If you only need to drive XWayland apps,
CCDESK_BACKEND=x11(or--backend x11) forces the X11 backend.
ccdesk doctor reports exactly which of these are present.
KDE Plasma (Wayland)
Plasma on Wayland has its own backend, picked from XDG_CURRENT_DESKTOP. On X11 it uses the X11
backend and needs none of this. Three tools do the work:
sudo pacman -S spectacle ydotool # Arch; elsewhere: spectacle, ydotool
paru -S kdotool # 0.3 or newer. AUR, or `cargo install kdotool`
systemctl --user enable --now ydotool # the input daemon, as you, not rootScreenshots go through
spectacle, because KWin implements no wlr-screencopy andgrimexits with "the screen capture protocol is not supported". A region shot is cut out of a full grab and needs ImageMagick. So does a scaled display, where the grab is brought down to logical pixels to match windows and the pointer.Windows come from
kdotool, which loads a script into KWin over D-Bus and reads its answer back. One script answers for every window, with its frame box, desktop and state, solist_windows,activate_window,manage_windowand the window-under-the-pointer check all work. Withoutkdotool, screenshots and typing still work, but clicks and drags are refused because there is no way to place the pointer, the window tools say what is missing, and coordinates are wrong on a scaled display. Panels, the desktop and other shell surfaces are left out oflist_windows, but the focus guard still sees them: a click on a panel, or typing into the open launcher, is refused like any other window that is not the one you activated.Input is
ydotool, through/dev/uinput, withydotooldrunning as your user (the Arch package ships that user unit). Plasma offers neither the virtual-pointer nor the virtual-keyboard protocol, so there is no unprivileged path.ydotool --absolutedoes not place the pointer under KWin (every value lands in a corner), so ccdesk moves the pointer in relative steps and corrects against the position KWin reports, which also absorbs the pointer acceleration curve. A click that cannot be placed within a few pixels is refused rather than sent somewhere else.press_keyneeds nowtype: ccdesk turns key names into the keycodesydotool keytakes. Keys andtype_textassume a US layout, andydotoolonly types ASCII, so paste anything else through the clipboard.ui_treeworks as everywhere else, once the accessibility bus is on (see above). Withkdotool, its boxes are moved to screen coordinates; without it,ui_treesays they may be relative to the window.
ccdesk doctor lists whichever of these is missing.
Support matrix
Environment | screenshot | click/move/drag/scroll | type_text | press_key | list/activate windows | clipboard |
X11 (any WM/DE) | ✅ scrot / import | ✅ xdotool | ✅ xdotool | ✅ xdotool | ✅ wmctrl | ✅ xclip / xsel |
X11 without a WM (Xvfb) | ✅ | ✅ | ✅ | ✅ | ✅ xdotool + xprop fallback | ✅ |
Wayland: sway | ✅ grim | ✅ swaymsg seat | ✅ wtype / ydotool | ⚠️ wtype (see above) | ✅ swaymsg | ✅ wl-clipboard |
Wayland: Hyprland | ✅ grim | ✅ virtual pointer | ✅ wtype / ydotool | ✅ hl.dsp.send_shortcut | ✅ hyprctl | ✅ wl-clipboard |
Wayland: GNOME | ⚠️ full screen only | ⚠️ ydotool + ydotoold | ⚠️ ydotool | ❌ | ⚠️ XWayland apps only | ✅ wl-clipboard |
Wayland: KDE Plasma | ✅ spectacle | ⚠️ ydotool + ydotoold | ⚠️ ydotool | ✅ ydotool | ✅ kdotool | ✅ wl-clipboard |
Headless ( | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ private clipboard |
No display (SSH, CI) | ❌ (doctor explains) | ❌ | ❌ | ❌ | ❌ | ❌ |
manage_window on X11 uses xdotool (EWMH window managers). On bspwm it goes through bspc:
move and resize make a tiled window float, maximize means fullscreen (bspwm has no maximized
state), minimize hides the node, and restore unhides it and tiles it again, including a window
you had floating. On X11, "on show" means the window is mapped; a window manager that keeps
windows mapped on other desktops can't be told apart, and with several monitors the current
desktop can't decide it, because each monitor shows its own.
tests/test_integration_windows.py runs that against a real bspwm on a private screen. Other
X11 window managers and sway are covered by unit tests of the commands ccdesk builds. Hyprland
is covered against the real thing; see below.
The X11 rows are exercised by the integration suite under Xvfb, and the sway row by
tests/test_integration_wayland.py on a headless sway it starts itself (screenshots, window
list, clipboard, ui_tree coordinates, clicks and typing in a real GTK app), which also
checks the virtual pointer separately (exact clicks, wheel, drag). The Hyprland row by
tests/test_integration_hyprland.py, verified on Hyprland 0.56.2 with two monitors at scale 1.5
and 1. It opens windows and clicks for real, so it only runs with CCDESK_TEST_HYPRLAND=1 from
inside Hyprland, or with its session variables set. The headless
row by tests/test_integration_headless.py, which launches a GTK app on the private screen
and checks that your session bus never sees it. The other
Wayland rows are covered by unit tests of the exact commands ccdesk builds.
Requires Python 3.10+. The full suite passes on Python 3.14 with both mcp 2.3 and mcp 1.30.
ccdesk does not replace Cua Driver. If cua-driver is on
your PATH, doctor mentions it, but ccdesk never calls it.
Development
python -m venv .venv && . .venv/bin/activate
pip install -e '.[test]'
xvfb-run -a pytest -q # unit tests + Tk integration test on a virtual X server
pytest -q # without a display the integration tests are skipped
xvfb-run -a ccdesk doctorThe display tests skip themselves when DISPLAY looks like your real screen (:0, :1):
they click and type for real. Set CCDESK_TEST_REAL_DISPLAY=1 to override.
The Wayland test needs sway, grim, wtype, wl-clipboard, a C compiler, pkg-config and
wayland-scanner. It starts a headless sway on its own socket, plus a small virtual-pointer
helper (tests/wayland/vpointer.c, compiled on the fly) because a headless seat has no mouse.
The Hyprland test needs hyprctl, grim, wtype, wl-clipboard and a system Python with
PyGObject and GTK 3.
The integration test (tests/test_integration_tk.py) starts a Tk window with a button, a
label, an entry, a canvas and a list. It takes a screenshot, finds the button by its colour
in the PNG, clicks it, and checks that the label changed. It then types into the entry,
presses Return, drags across the canvas, scrolls the list and checks the clipboard,
asserting each result against the app's own state.
Layout:
ccdesk/
cli.py ccdesk doctor | firefox | install | mcp | peek | version
firefox.py ccdesk firefox, and the firefox-devtools registration
headless.py --headless: private Xvfb screen, window manager and D-Bus
server.py MCP tool definitions (the only module importing `mcp`)
desktop.py validation + policy + audit around a backend
safety.py read-only / launch policy, destructive keys, JSONL audit log
parsing.py pure parsers: keys, regions, wmctrl/xdotool output, PNG header
doctor.py environment report
install.py Claude Code registration and skill copy
backends/ x11.py, wayland.py, kde.py, vpointer.py, keycodes.py, base.py
skills/computer-use/SKILL.md
tests/Releasing
A release is a tag. v0.2.0 publishes that commit to PyPI and opens the GitHub Release with the
wheel and the sdist attached. The version lives in ccdesk/__init__.py; hatch reads it from there
and the tag has to match it.
scripts/release.sh 0.2.0 # unit tests, version bump, commit, tag, pushThe script won't run off master, with a dirty tree, or with a version that doesn't go up. A
version that was never released can be tagged as it stands, so 0.1.0 of an unreleased repo goes
out without a bump commit.
.github/workflows/release.yml builds, runs the tests again on the tagged commit, uploads to PyPI
with trusted publishing and writes the notes from the commits since the last tag. uv.lock has to
be committed: the workflow installs with --locked when it finds one.
The PyPI upload needs a trusted publisher once, at
https://pypi.org/manage/account/publishing/: project ccdesk, owner asloth, repository
ccdesk, workflow release.yml, environment pypi. No token is stored anywhere.
If a run fails halfway, fix it and re-run the workflow from the Actions tab with the same tag: the PyPI upload skips versions that already exist and the release assets are replaced.
License
MIT. See LICENSE.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
A cloud Linux desktop for your AI: browse, click, type, read the screen and run commands.
Use your Mac, Windows or Linux computer from ChatGPT, Claude or Codex: files, commands, documents.
Give your AI hands on every computer you own. Remote control of your Windows, Mac and Linux machines from one ChatGPT, Claude or Cursor chat — even from your phone. Ask it to find out why the office server is crawling, update every PC in the house, sort out your mom's laptop from another city, or ship the site from your work machine while you're on a train. It runs commands, reads files and takes a screenshot when it needs to see the screen. You decide what each computer allows, and you can cut any of them off in a second.
Operate Linux, macOS and Windows from your LLM. Every action runs through an auditable allowlist.
Related MCP Servers
- FlicenseAqualityDmaintenanceEnables Claude Code to interact with Linux X11 desktops by performing mouse actions, keyboard input, and capturing screenshots. It provides a suite of tools for automation tasks such as clicking, typing, and zooming on specific screen regions.16-
- AlicenseAqualityCmaintenanceGive Claude Desktop full desktop control on Linux/X11: screenshot, mouse, keyboard, windows, clipboard, app launch. Zero-dependency MCP extension, MIT-licensed.15MIT
- AlicenseNot gradedqualityDmaintenanceEnables full Linux desktop control including windows, mouse, keyboard, clipboard, audio, screenshots, OCR, accessibility, and system management through MCP-compatible AI agents.1MIT
- AlicenseNot gradedqualityDmaintenanceEnables Claude to control the local desktop via screenshot, mouse, keyboard, and clipboard operations.MIT