kwin-desktop-automation-518ae2 — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited kwin-desktop-automation-518ae2 (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Drive Linux KDE Plasma 6 Wayland desktops through the kwin-mcp MCP server. The MCP server provides 29 capabilities; this skill provides the operational discipline to use them efficiently, in the right order, and without falling into platform-specific traps.
Activate this skill whenever the user wants to:
If no kwin-mcp tools are available, this skill does not apply.
Every coordinate, in and out, is a global logical pixel. Points are objects (target={"x": 10, "y": 20}), rectangles are {x, y, width, height}, and key combinations are one string ("ctrl+shift+t"). Unknown argument names are errors that name the right argument; read the error and retry.
Every other tool except session_info requires a session. Two mutually exclusive modes:
Virtual — `session_start` Opens an isolated dbus-run-session + kwin_wayland --virtual compositor. Nothing reaches the host display. Use when the user says "test", "headless", "CI", "isolated", or names a specific app to launch fresh. Then launch the app with app_launch(command="kate"); if the launch fails, the session keeps running, so call session_stop if you want to roll back.
Useful arguments:
enable_clipboard=true — required for clipboard_read / clipboard_write (not for keyboard_type(method="unicode")). Off by default because wl-copy can hang on a freshly minted bus.home="isolated" — temp HOME with isolated XDG dirs; apps use fresh settings but keep normal filesystem access.retention={"artifacts": true} — keeps the /tmp/kwin-mcp-screenshots-* artifacts directory (screenshots, frames and launched apps' logs) after session_stop; retention={"home": true} (with home="isolated") keeps the temp HOME. Delete kept directories yourself when done.Live — `session_connect` Attaches to an already-running KWin: the user's real desktop, or a KWin running inside a container / kiosk / embedded device. Use when the user says "my", "current", "this window", "what I'm looking at", "container", "kiosk", or "live". Defaults to $DBUS_SESSION_BUS_ADDRESS and $WAYLAND_DISPLAY; clipboard is always enabled. session_stop only disconnects — it never kills the live KWin or its apps, only apps you launched with app_launch.
If the kwin-mcp server was launched with --default-live-session, the descriptions of session_start and session_connect say so: in that mode session_connect is the default and session_start is only for an explicitly requested isolated session.
session_info reports the current session (or none), its backends, the apps you launched (with PIDs for app_log) and any held input.
End every successful turn that opened a session with session_stop. Virtual sessions leak kwin processes otherwise; live sessions just disconnect.
Each interaction is three steps. Cheap observation before action prevents acting on an unfocused window or stale UI.
Observation tools, cheapest first:
window_list — KWin windows with id, app_id, caption, kind, active, and frame rects. window_list(filter={"active": true}) answers "which window has focus?".element_query — AT-SPI2 elements. Always narrow it: locator={"app": "kate", "role": "push button"} and/or max_depth=. locator={"query": "Save"} searches name/role/description; locator={"states": ["focused"]} answers "what currently has focus?". max_depth=1 lists applications and their top-level windows. Don't fetch the whole tree just to find one button.element_query(..., wait={"timeout_ms": 5000}) — polls until a match appears; wait={"until": "absent"} waits for it to disappear. A timeout is not an error: the result says satisfied: false and starts with NOT SATISFIED. Use after launching an app or after any click that triggers async UI.screen_capture — last resort for visual inspection or when AT-SPI2 fails to expose an element (see Pitfalls).Locator supports match: "substring" | "exact" | "spoken" in both element_query (including waits) and element_act strict searches. The default, "substring", searches name, role, and description. Use {"app": "kate", "query": "Save", "match": "exact"} for name-only equality after case folding and whitespace collapsing; punctuation stays significant. Use match: "spoken" for spoken labels: NFC and case folding, collapsed whitespace, and trailing . , ! ? ; : … ignored. A multi-word query can match a joined label (save as matches SaveAs), but a one-word query is not split into words. Other punctuation stays significant; this is not fuzzy matching. Distinct elements that normalize to the same label remain ambiguous for an action.
Pick the cheapest tool that answers the question. Do not start with screen_capture if element_query(locator={"query": "Save"}) would suffice.
screen_capture and capture frames name the saved PNG path. If the kwin-mcp server runs with --screenshot-images, the result also includes each captured image directly, after the text, so you can look at it without opening the file. Otherwise, read the PNG at the reported path. Each attached image costs context, so request only the frames you need.
Action tools, preferred first:
element_act(element={"ref": "e1.12"}, action="Press") when the element exposes the intended AT-SPI action. It works semantically without pointer coordinates, so it still works when the control is obscured or another window is foreground. Without a ref, use element={"strict": {"app": "kate", "query": "Save"}}: it runs only when exactly one element exposes that action; zero or several candidates is an error that lists the actions the matches expose, and an unreadable part of the tree fails closed. A result saying the application did not confirm means the action may have run; check the UI before retrying. kwin-mcp never retries actions automatically.element_act(element={"strict": {"app": "kate", "query": "Save", "match": "exact"}}, action="Press|click") supplies "|"-separated ordered fallbacks (action: "Press|click" in JSON). kwin-mcp prefers Press, then click, counts each matching element only once, and reports the action actually dispatched. Two distinct matching elements remain ambiguous even if they expose different actions. Fallback selection does not retry a dispatched action.pointer_click(target={"ref": "e1.12"}). The pointer goes to the centre of the element's live rectangle (add dx/dy to offset). A stale ref is an error: re-run element_query. An element reported as @ unavailable (reason) has no trustworthy position, and a ref target on it is an error — do not click a guessed position.pointer_click(target={"x": 400, "y": 300}). Element rectangles are already global logical coordinates; count=2 double-clicks; modifiers="ctrl" holds keys for the click only.dbus_call only as a last resort.Windows: ids from window_list (or an element's window_id) are stable while the window exists. window_focus(window={"id": ...}) activates it, window_place(window={"id": ...}, frame={"x": 0, "y": 0}) moves and/or resizes it (supply only the fields that change; width/height at most the workspace size — start the session with a larger screen_width/screen_height for bigger windows), window_place(..., desktop=<id from desktop_list>) moves it to another virtual desktop, and window_close(window={"id": ...}) asks it to close. Name selectors such as {"app_id": "kate"} must match exactly one window and see dialogs and popups too; when ambiguous, add "kind": ["normal"] or use the id. window_place reports fields KWin has not applied yet as pending and a window that vanished as such; window_close only asks — confirm with window_list, because a "save changes?" prompt can keep it open. On a live desktop window_close asks the user to confirm first; a declined or unanswerable prompt is a normal refusal, not an error. Do not retry it unless the user asks.
Screenshot coordinates: screenshot pixels are logical too, but offset by the image origin. Read the Coordinate space: logical: origin (ox, oy); ... line (or the frame's mapping) and click image pixel (px, py) at target={"x": ox + px, "y": oy + py}. The origin can be negative on multi-monitor layouts. Never rescale by the display scale factor — kwin-mcp already did. If the line goes on with image WxH downscaled: pixel (px, py) shows (...), the image was shrunk: apply that formula instead. On a large desktop, small text is unreadable in a downscaled capture: take the overview, then call screen_capture(region={"x": ..., "y": ..., "width": ..., "height": ...}, max_edge=0) to read that area at full resolution.
Typing:
keyboard_type(text) (method keys) is US-QWERTY only: letters, digits, ASCII punctuation, space, tab, and newline. Text containing any other character (non-ASCII, \r, other control characters) is rejected before anything is typed, with an error naming the characters.keyboard_type(text, method="unicode") for Korean / CJK / emoji / any non-ASCII. It tries wtype first; when wtype is unavailable or unsupported by the session, it pastes through a built-in temporary clipboard owner and then restores the previous clipboard in every format. The paste chord follows the focused window: Ctrl+Shift+V in terminals known to paste on it (Konsole, GNOME Terminal, kitty, foot, st, and others), Ctrl+V elsewhere. An error means the clipboard could not be taken, nothing read the text after the paste chord, or the restore failed: verify the target field before retrying. Recognized terminals without a clipboard paste chord (xterm, urxvt) and windows KWin cannot identify fail without any key being pressed; an unrecognized terminal still gets Ctrl+V; type ASCII with method keys there. Text over 1 MiB of UTF-8 or containing NUL is rejected.keyboard_press(keys="ctrl+s") for one key or chord.Branch typing by string content — never assume the input is ASCII.
Verify after every meaningful action. Typical pattern:
element_query(locator={"query": "OK", "states": ["enabled"]}) — get the element and its ref.pointer_click(target={"ref": "<ref>"}) or element_act(element={"ref": "<ref>"}, action="Press") — act on it.element_query(locator={"query": "Settings saved"}, wait={"timeout_ms": 3000}) — confirm.For animation-heavy or transient UI, pass capture={"delays_ms": [0, 100, 300]} to a single action call instead of making three round-trips. Each frame has its own Coordinate space line. A frame that failed after the action already happened is listed in warnings; the action itself still happened. kwin-mcp uses the best supported capture backend. High-frequency timing requires ScreenShot2; the Docker nested visual QA explicitly selects X11/scrot for pixel verification, so do not treat its frame timing as ScreenShot2 performance.
Held input: gestures release everything they press, even on error. Only pointer_hold(phase="press") and keyboard_hold(phase="press") leave a button or key held, and every result lists what is still held (Still held: ...). A gesture that would press or release a held control is refused with the release call to make; pointer_move is refused while a mouse button is held — use pointer_hold(phase="move", button, target) for a manual drag. Release with phase="release" when done. On a live desktop, release held input before ending your turn: a held modifier turns the user's typing into shortcuts.
These are properties of the Wayland / AT-SPI2 / EIS stack, not bugs. Know them or get burned.
isError: true (Error executing tool <name>: ...). The second line of the message is the remedy; follow it. An empty element_query or window_list, an element_query wait that timed out, a window_close refusal, and an unconfirmed action are normal results that say so.method="unicode"; method keys rejects it as an error without typing anything. Always check the input.enable_clipboard=true to session_start AND ensure wl-clipboard is installed before calling clipboard_read / clipboard_write. Live sessions always have clipboard.clipboard_write is different — its text intentionally stays on the clipboard.element_query are already in the space every target takes. When the element's window cannot be matched to exactly one KWin window, the element reports @ unavailable (reason) with rect: null — never click a guessed position.coverage partial (Spectacle fallback with a screen at a negative position) means only the listed regions were captured; the rest of the image is transparent, not empty desktop. On a layout the installed Spectacle cannot compose — mixed scales with a screen outside Spectacle's canvas — screen_capture raises an error naming the output when the fallback would be needed. kwin-mcp has no per-output capture mode; make ScreenShot2 available to the session, or rearrange the outputs, instead of retrying. A frame reporting Coordinate space: unavailable (reason) kept its PNG but it holds the backend's raw unnormalized pixels — only its timing is meaningful. A screen_capture error containing coordinate mapping cannot be proven has no trustworthy pixel-to-screen mapping at all — capture again instead of clicking a guessed position.dbus_call cannot capture one either, because it cannot pass the unix file descriptor that ScreenShot2's CaptureWindow requires. There is no per-window capture tool; move the window with window_place(desktop=...) or switch desktops first.screen_capture to identify the menu item, derive its position from the parent widget's reported screen rectangle, then click.dbus_call to invoke KWin scripting or a keyboard shortcut instead of trying to hover the edge.touch_swipe(fingers=2) or fewer when the target is the app itself.accessibility field of the session_connect result. When it says off, Firefox shows up in no AT-SPI2 query. Only if the user asked to automate such an app, reconnect with enable_accessibility=true; it changes a desktop-wide setting until session_stop. Launch Chromium with --force-renderer-accessibility to expose web page content; without the flag its tree may hold only empty window frames.session_connect rejects missing, non-socket, or unreachable Wayland sockets, as well as an unreachable KWin D-Bus. Mount the target Wayland socket under the container's $XDG_RUNTIME_DIR (or pass its absolute path), and propagate DBUS_SESSION_BUS_ADDRESS.pointer_hold / keyboard_hold (phase="release"); session_stop also releases it.session_stop once the task is complete, even if a step errored.retention={"artifacts": true} was used, /tmp/kwin-mcp-screenshots-* (screenshots, frames and app logs) survives session_stop. Delete it explicitly when no longer needed.home="isolated" with retention={"home": true} was used, the temp HOME under /tmp/ also survives — delete it manually."Screenshot my desktop" (live):
session_connect()screen_capture() → report the file path.session_stop()."Click the Save button in kate" (virtual):
session_start(), then app_launch(command="kate").element_query(locator={"app": "kate", "query": "Save"}, wait={"timeout_ms": 5000}) — get the element's ref.element_act(element={"ref": "<ref>"}, action="Press") (or pointer_click(target={"ref": "<ref>"})).element_query(locator={"query": "Save File"}, wait={"timeout_ms": 3000}) to confirm the dialog appeared.session_stop()."Type non-ASCII Unicode text into the active text field":
keyboard_type(text="...", method="unicode") — pass any non-ASCII text (CJK, accented characters, symbols, or emoji) with method="unicode"; the default keys method is US-QWERTY only and rejects non-ASCII characters."Find what currently has focus":
element_query(locator={"states": ["focused"]})."Drag with a pause in the middle":
pointer_drag(from={"x": 100, "y": 100}, to={"x": 400, "y": 300}, waypoints=[{"x": 250, "y": 200, "dwell_ms": 500}]).~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.