screencommander is a macOS 14+ CLI and MCP server that gives agents an observe → decide → act loop over the desktop, accessibility-first: read the screen as structured data when possible, capture pixels when needed, and act without taking the user's mouse.
- Observe — Retina-aware screenshots (full display or a single window), the accessibility tree as structured elements or plain text (
elements, ~30–80 ms with no capture), a real-time stream of UI-change events (observe), and a pre/post frame diff on every action that answers "did that do anything?" without re-reading images. - Decide — deterministic coordinate mapping from capture metadata (pixels, points, or normalized → global points, window-relative included), element ids and bounds that are directly clickable, and
--untilpredicates for blocking until the UI reaches a state. - Act — click, scroll, drag, hover, type, and key chords, delivered through a tier ladder: coordinate-free accessibility actions → process-targeted events posted straight to an app (cursor untouched, works on background windows) → global event synthesis.
--no-cursorguarantees the pointer never moves.
Vision models work from screenshots and pixel coordinates. Non-vision models (for example codex-5.3-codex-spark) work from the element tree and text alone — same commands, same JSON envelopes. serve --mcp exposes the entire surface as MCP tools with screenshots returned in-band.
- macOS 14.0+
- Xcode Command Line Tools (for
swift)
swift buildFor a concise, operations-first guide to screencommander capabilities and reliable command patterns, see:
SKILL.md
This is the same skill/runbook AGENTS use to quickly understand and operate the CLI.
Use the reusable installer script:
scripts/install.sh --prefix /usr/localIf you prefer a user-local install without sudo:
scripts/install.sh --prefix "$HOME/.local"The binary is installed to <prefix>/bin/screencommander.
For user-local installs, ensure ~/.local/bin is on your PATH.
screencommander requires macOS privacy permissions:
- Screen Recording permission for
screenshot
- System Settings path: Privacy & Security > Screen Recording
- Deeplink:
x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture
- Accessibility permission for
click,scroll,drag,move,type,key,elements, andobserve
- System Settings path: Privacy & Security > Accessibility
- Deeplink:
x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility
On denial, commands fail fast with explicit remediation text and stable exit codes.
screencommander doctor
screencommander doctor --jsonBehavior:
- Reports permission status with traffic lights (
🟢granted,🔴denied). - Reports active displays with IDs and bounds in points.
- Exits
0even if permissions are missing; use output for remediation.
screencommander screenshot \
--display main \
--out ~/Library/Caches/screencommander/captures/desk.png \
--format png \
--meta ~/Library/Caches/screencommander/captures/desk.json \
--cursorJSON output variant:
screencommander screenshot --jsonBehavior:
- Captures with ScreenCaptureKit
SCScreenshotManager. - Writes image and JSON metadata.
- Default image path:
~/Library/Caches/screencommander/captures/<timestamp>.png - Default metadata path:
<image>.json(for example~/Library/Caches/screencommander/captures/<timestamp>.json) - Also updates managed
~/Library/Caches/screencommander/last-screenshot.jsonby default. - Does not prune older captures; use
cleanupexplicitly when you want retention.
screencommander click 640 320 --space pixelsUsing explicit metadata and double-right-click:
screencommander click 0.25 0.25 \
--space normalized \
--meta ./captures/desk.json \
--button right \
--doubleModifier and middle/triple-click examples:
screencommander click 640 320 --button middle
screencommander click 640 320 --modifiers cmd,shift
screencommander click 640 320 --triplePointer-free element clicks (no coordinates, cursor untouched):
screencommander click --element "General" --app "System Settings" --no-cursor
screencommander click --element-id 0.3.2 --app Safari
screencommander click --element "Save" --role button --via ax
screencommander click 640 320 --verify-targetBehavior:
- Defaults to metadata path
~/Library/Caches/screencommander/last-screenshot.json. - Maps screenshot coordinates into global Quartz coordinates deterministically.
- Supports
--button left|right|middle,--double,--triple, and--modifiers cmd,shift,option,ctrl. --element "<title/label substring>"or--element-id <id>clicks an accessibility element instead of coordinates. Resolution happens fresh at click time (ids fromelementsare positional). No match exits70(element_not_found); ambiguous substring matches exit82(element_ambiguous). Disambiguate with--element-id,--role, and--app.- Element clicks use a tiered actuator, tried in order
ax(AXPress/AXShowMenu — coordinate-free, background-safe) →pid(CGEvents posted to one app; cursor stays put) →global(classic path; moves the cursor). Downgrades are recorded in the result (deliveryMethod), not errors. - Disabled elements fail with exit
72before any fallback tier runs. --via ax|pid|globalforces one tier with no fallback (--strictimplied).--no-cursorremoves theglobaltier so the pointer never moves.--strictturns any downgrade into exit72(element_not_actionable).- Coordinate clicks keep the historical
globaldelivery;--via pidwith--app <name|pid>posts a coordinate click to one app instead. - Plain coordinate clicks send one physical click. For human-like global clicks,
--appor window metadata lets the engine activate the target before clicking, instead of firing an extra focus click. --verify-target(coordinate clicks) hit-tests the mapped point via accessibility first and includes the element found there in the result.- Coordinate actions report advisory
metadataFreshnessin JSON when metadata is used. Add--strict-metadatato fail withstale_metadatabefore input injection when the metadata is known stale. - Captures pre-action and post-action screenshots by default and prints both paths.
- Compares pre-action and post-action screenshots and reports a changed region when pixels differ. Tune with
--diff-grid <1...512>and--diff-threshold <0...1>; defaults stay64and0.04. - Disable before/after capture with
--no-postshot. - Disable only frame comparison with
--no-diff.
screencommander scroll 640 800 --dy -5
screencommander scroll 640 800 --dy 300 --unit pixels
screencommander scroll 640 800 --dx 2 --dy 0Element-targeted and pid-delivered scrolling:
screencommander scroll --element "Content" --app Safari --dy -5
screencommander scroll 640 800 --dy -5 --via pid --app SafariBehavior:
- Maps the target point through screenshot metadata, moves the cursor there, then posts a scroll event.
- Uses line units by default; pass
--unit pixelsfor pixel scrolling. - Requires at least one nonzero delta across
--dxand--dy. --element/--element-idscrolls at an element's center. There is no AX scroll action, so element scrolls trypidthenglobal;--no-cursorrestricts topid;--via pid|globalforces a tier. The result recordsdeliveryMethod.- Coordinate scrolls report advisory
metadataFreshness; use--strict-metadatato fail before input injection when metadata is known stale. - Captures pre-action and post-action screenshots by default (
--no-postshotto disable). - Tune frame diff with
--diff-grid <1...512>and--diff-threshold <0...1>.
screencommander drag 300 400 900 400
screencommander drag 300 400 900 400 --button right --steps 20 --duration-ms 600Behavior:
- Maps both endpoints through the same metadata and coordinate space.
- Posts mouse-down, interpolated drag events, and mouse-up.
- Defaults to
--steps 12and--duration-ms 300. - Reports advisory
metadataFreshness; use--strict-metadatato fail before input injection when metadata is known stale. - Captures pre-action and post-action screenshots by default (
--no-postshotto disable). - Tune frame diff with
--diff-grid <1...512>and--diff-threshold <0...1>.
screencommander move 640 320
screencommander move 640 320 --dwell-ms 250Behavior:
- Maps screenshot coordinates to global Quartz coordinates and posts one mouse-move event.
- Sleeps after the move when
--dwell-msis provided. - Reports advisory
metadataFreshness; use--strict-metadatato fail before input injection when metadata is known stale. - Captures pre-action and post-action screenshots by default (
--no-postshotto disable). - Tune frame diff with
--diff-grid <1...512>and--diff-threshold <0...1>.
screencommander type "hello world"With per-character delay and JSON output:
screencommander type "delayed text" --mode unicode --delay-ms 50 --jsonTyping into an element without keyboard focus games:
screencommander type "user@example.com" --element "Email" --app Safari
screencommander type "hello" --element-id 0.4.1 --via axBehavior:
- Defaults to paste mode (
cmd+v) for reliable full-text input. --element/--element-idtargets an accessibility element: tieraxsetsAXValuedirectly (works on background apps), falling back to focusing the element and using the keyboard path (global).typehas nopidtier.--via ax|globalforces a tier;--strictturns downgrades into exit72.- Captures pre-action and post-action screenshots by default (
--no-postshotto disable). - Compares pre-action and post-action screenshots by default (
--no-diffto disable). - Tune frame diff with
--diff-grid <1...512>and--diff-threshold <0...1>.
screencommander key "enter"
screencommander key "cmd+shift+4"
screencommander key "option+tab" --json
screencommander key "spotlight"
screencommander key "missioncontrol"
screencommander key "launchpad"Supported modifier aliases include cmd|command, opt|option|alt, and ctrl|control.
System/media keys are also supported (for example volumeup, volumedown, brightnessup, mute, launchpad, play, next, prev).
spotlight and raycast map to cmd+space; missioncontrol maps to f3.
Behavior:
- Captures pre-action and post-action screenshots by default (
--no-postshotto disable). - Compares pre-action and post-action screenshots by default (
--no-diffto disable). - Tune frame diff with
--diff-grid <1...512>and--diff-threshold <0...1>.
screencommander keys "press:cmd+tab" "press:cmd+tab"
screencommander keys "press:next" "sleep:100" "press:prev"keys executes down/up/press/sleep steps in strict order.
For repeated modifier-based shortcuts, include modifiers explicitly in each press step (for example press:cmd+tab). Standalone keys such as press:next and press:prev do not require modifiers.
It captures and compares pre-action and post-action screenshots by default; use --no-postshot or --no-diff to disable those separately. Tune frame diff with --diff-grid <1...512> and --diff-threshold <0...1>.
Read an app's accessibility (AX) element tree — ground-truth UI structure and text without pixels:
screencommander elements # frontmost app, focused window
screencommander elements --app Safari --text # text-only view of the UI
screencommander elements --app 8412 --json # by pid, machine-readable
screencommander elements --app "System Settings" --roles AXButton,AXTextField --visible-onlyBehavior:
- Defaults to the frontmost application; target explicitly with
--app <name|pid>. Ambiguous app names report candidate PIDs so callers can retry with a PID. - Traverses the focused window by default; use
--all-windowsor--window-id <id>to widen or narrow. - Each element carries a positional id (dot-joined child-index path such as
0.3.2), role, title/value/description, enabled/focused state, supported AX actions, and bounds in global points. - When
~/Library/Caches/screencommander/last-screenshot.jsonexists, elements inside that screenshot also getboundsPixelsin its pixel space, soelementsoutput can driveclick <x> <y>directly. --textprints an indentedrole "title": valueview (also inresult.textwith--json) — a token-cheap way to read a screen without vision.--max-depth(default 40, max 200),--max-elements(default 2000, max 10000, result markedtruncatedwhen hit),--max-value-length(200),--roles, and--visible-onlybound the traversal.- Electron/Chromium apps are primed automatically (
AXManualAccessibility, falling back toAXEnhancedUserInterface, restored afterwards); the result reportsaxPrimed. - Apps that expose no usable AX tree fail with exit code
71(ax_tree_unavailable). --profile textskips geometry and action discovery while preserving scalar text/state fields. Its emptyactionsmeans unqueried, not unsupported.--max-visited <1...100000>bounds visited nodes including filtered containers.--timeout-ms <1...60000>sets a cooperative AX read deadline with per-handle messaging timeouts; cancellation and deadlines are checked between synchronous calls. Partial results includevisitedCount,truncated, andpartialReason.
Stream real-time UI-change events for an app as NDJSON (one JSON object per line) — a push-based feed so agents can block on outcomes instead of screenshot polling:
screencommander observe --app TextEdit --events value # value changes as you type
screencommander observe --app Finder --until 'role=AXWindow title~=Downloads' --timeout-ms 10000
screencommander observe --app Safari --events focus,window # focus and window changesBehavior:
- Streams NDJSON, one event per line:
{ ts, event, app: { pid, name }, element? }. This command always emits one-line JSON (it ignores pretty/compact). --eventsselects categories (default all):value(value changed),focus(focused element changed),window(created/moved/resized/title changed),destroy(element destroyed),app(NSWorkspace launch/activate/terminate for the target app).- Runs until Ctrl-C (SIGINT, exit
0),--timeout-mselapses, or--untilmatches. --until '<predicate>'stops when an element matches: whitespace-joinedkey<op>valueconditions wherekeyisrole/title/value/idand<op>is=(exact) or~=(case-insensitive contains), e.g.role=AXButton title~=Save. An initial tree scan makes already-true conditions return immediately. On match, a final{ "matched": true, "element": ... }line is printed (exit0).- With
--untiland--timeout-ms, an unmet predicate within the timeout exits73(observe_timeout); a plain--timeout-mswithout--untilexits0. - Requires Accessibility permission.
screencommander cleanup --older-than-hours 24Explicitly prunes managed capture artifacts (png, jpg, jpeg, json) in ~/Library/Caches/screencommander/captures older than the configured age. Screenshot and action commands never run cleanup implicitly.
Run an ordered bundle of actions from JSON:
screencommander sequence --file ./sequence.jsonExample sequence.json:
{
"steps": [
{ "click": { "x": 935, "y": 1074, "meta": "./last-screenshot.json" } },
{ "scroll": { "x": 935, "y": 800, "dy": -4 } },
{ "move": { "x": 935, "y": 700, "dwellMS": 100 } },
{ "type": { "text": "hello from sequence", "mode": "paste" } },
{ "sleep": { "ms": 100 } },
{ "key": { "chord": "enter" } }
]
}Behavior:
- Executes steps in order.
- Step keys are exactly one of
click,scroll,drag,move,type,key, orsleep. click,scroll, andtypesteps accept the element-targeting fieldselement,elementId,role,app,via,noCursor(click/scroll), andstrict, mirroring the CLI options (for example{ "click": { "element": "Save", "app": "TextEdit", "via": "ax" } }).- Coordinate
click,scroll,drag, andmovesteps acceptstrictMetadata. - Captures pre-action and post-action screenshots around each step by default.
- Disable per-step before/after capture with
--no-postshot. - Compares each step's pre-action and post-action screenshots by default. Use command-level
--no-diffor a step-levelnoDiff: truefield to disable comparison. Tune sequence-wide frame diff with--diff-grid <1...512>and--diff-threshold <0...1>.
List visible windows, optionally filtered by app:
screencommander windows
screencommander windows --app Safari
screencommander windows --jsonBehavior:
- Requires Screen Recording permission.
- Without
--app, lists all visible windows across all apps. --appaccepts an app name (exact or prefix) or a PID.- Each row shows:
[windowID] AppName: "title" (WxH at X,Y, layer=N). - Exits non-zero with code
81if the app is not found.
Capture a single window instead of the full display:
screencommander screenshot --window 12345
screencommander screenshot --window SafariBehavior:
--windowaccepts a numeric window ID or an app-name prefix (captures the frontmost window of that app).- The sidecar metadata gains optional
windowIDandwindowBoundsPointsfields. clickcoordinates from a window screenshot map correctly using the window bounds as the origin.- Without
--window, screenshot behavior is unchanged (full display).
Bring an app to the foreground:
screencommander focus --app Safari
screencommander focus --app 1234
screencommander focus --app Safari --jsonBehavior:
--appaccepts an app name (exact or prefix) or a PID.- Prints
Focused <AppName> (was: <PriorAppName>). - Exits non-zero with code
81if the app is not found.
Run screencommander as a persistent MCP server over stdio:
screencommander serve --mcpRegister it with Claude Code:
claude mcp add screencommander -- screencommander serve --mcpBehavior:
- Speaks the Model Context Protocol: newline-delimited JSON-RPC 2.0 on stdin/stdout
(
initialize,tools/list,tools/call); diagnostics go to stderr. - Exposes every command as a tool:
screenshot,click,type,key,keys,scroll,drag,move,elements,windows,focus,observe_wait,doctor,cleanup. - One warm engine instance serves all calls — no per-action process startup — and
tool results are the same JSON envelopes the CLI prints (as
structuredContentplus a text block), sodocs/json-output-schema.mdcovers both surfaces. - Requests run in parallel by default. A client that needs a serialized follow-on
request can put
dependsOnin the JSON-RPC requestparamswith the upstream request id; the follow-on starts after that upstream request completes. notifications/cancelledwithparams.requestIdcancels the matching in-flight request and any queued dependents.- Window/display enumeration is cached for 2 seconds per enumeration flavor in serve mode; simultaneous misses share one fetch. Window capture reuses the resolver's display list. Numeric window targets validate current geometry before cache reuse; app-name targets resolve fresh window ordering. Moved/resized windows and changed display bounds force a refresh. The CLI enumerates fresh. Screenshot pixels are always captured anew.
screenshotadditionally returns the capture as an in-band MCP image content block (base64 PNG), so clients get pixels without a follow-up file read.observe_waitwrapsobserve --until+ timeout as a single call; an unmet predicate is anobserve_timeouterror (exitCode73 in the envelope), with collected events retained inresult.- MCP
elementsacceptsprofile,maxVisited, andtimeoutMs.snapshot: trueretains a bounded session snapshot;since: "<snapshotId>"returns changed positional records andremovedIds. Each delta is based on a fresh AX read. Missing/evicted/incompatible snapshots return the full observation withresetReason; incomplete reads cannot become baselines. IDs remain positional and must be resolved freshly before acting. - Action tools accept optional
postObserve: {"app":"<name|pid>"}to return one bounded post-delivery AX observation in the same call. Optionalprofile,since,maxElements,maxVisited, andtimeoutMscontrol it. A post-read failure reportsobservationErrorwhile retaining the successful action result; it is not a reason to repeat the action. Immediate observation does not imply an asynchronous UI transition has settled.
Reproduce the performance comparison using bench/live_ab.py against a baseline and candidate release binary. It creates a dedicated fixture window, checks semantic equivalence, and saves paired timings and response sizes; see bench/README.md.
For automation and scripts, the CLI can emit exactly one JSON object to stdout (success or error). Use this to parse results without scraping human output.
- Per-command: Add
--jsonto any command (e.g.screencommander doctor --json). - Global: Use
--output jsonbefore the subcommand, or setSCREENCOMMANDER_OUTPUT=jsonso every command defaults to JSON. - One-line output: Use
--compact(orSCREENCOMMANDER_JSON_COMPACT=1) when output is JSON for smaller, faster-to-parse output. - Precedence: Per-command
--jsonoverrides root--outputover env over default (human).
Example:
# Single command
screencommander doctor --json
# Global JSON for the run
screencommander --output json screenshot --out /tmp/cap.png
# Env var for whole script
export SCREENCOMMANDER_OUTPUT=json
screencommander doctor
screencommander cleanup --json --compactWith JSON mode, stdout is exactly one JSON object: either a success envelope ("status": "ok", result, optional exitCode) or an error envelope ("status": "error", error.code, error.message, exitCode). Scripts can read stdout once and branch on status. For the full contract (envelope fields and per-command result shapes), see docs/json-output-schema.md.
For maximum speed in scripts, combine --json --compact --no-postshot (and optionally --output json or the env var) so action commands skip before/after screenshots and emit one-line JSON. Use --no-diff when you want captures but do not need frame comparison.
{
"capturedAtISO8601": "2026-02-20T12:34:56.789Z",
"displayID": 69733248,
"displayBoundsPoints": { "x": 0, "y": 0, "w": 1512, "h": 982 },
"imageSizePixels": { "w": 3024, "h": 1964 },
"pointPixelScale": 2,
"imagePath": "/absolute/path/to/Screenshot-20260220-123456.png"
}10: screen recording permission denied11: accessibility permission denied20: capture failed21: image write failed30: metadata read/write failed40: invalid coordinate41: mapping failed50: input synthesis failed60: invalid arguments or chord parse70: element not found (--element/--element-idmatched nothing)71: target app exposes no usable accessibility (AX) tree72: element not actionable (found but disabled, or action unsupported under--strict/--via)73:observe --untilpredicate unmet within--timeout-ms80: window not found (--windowid/name matched nothing)81: app not found (--appname/pid matched no running app)82: element ambiguous (--elementmatched multiple best candidates; use--element-idor narrow with--role/--app)83: stale metadata (--strict-metadatafound display/window metadata that no longer matches the live desktop)