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.
Requirements
- macOS 14.0+
- Xcode Command Line Tools (for
swift)
Build
Fast Capability Guide
For 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.
Install
Use the reusable installer script:
scripts/install.sh --prefix /usr/local
If 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.
Permissions
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.
Commands
Doctor
screencommander doctor screencommander doctor --json
Behavior:
- 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.
Screenshot
screencommander screenshot \ --display main \ --out ~/Library/Caches/screencommander/captures/desk.png \ --format png \ --meta ~/Library/Caches/screencommander/captures/desk.json \ --cursor
JSON output variant:
screencommander screenshot --json
Behavior:
- 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.
Click
screencommander click 640 320 --space pixels
Using explicit metadata and double-right-click:
screencommander click 0.25 0.25 \ --space normalized \ --meta ./captures/desk.json \ --button right \ --double
Modifier and middle/triple-click examples:
screencommander click 640 320 --button middle screencommander click 640 320 --modifiers cmd,shift screencommander click 640 320 --triple
Pointer-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-target
Behavior:
- 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.
Scroll
screencommander scroll 640 800 --dy -5 screencommander scroll 640 800 --dy 300 --unit pixels screencommander scroll 640 800 --dx 2 --dy 0
Element-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>.
Drag
screencommander drag 300 400 900 400 screencommander drag 300 400 900 400 --button right --steps 20 --duration-ms 600
Behavior:
- 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>.
Move
screencommander move 640 320 screencommander move 640 320 --dwell-ms 250
Behavior:
- 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>.
Type
screencommander type "hello world"
With per-character delay and JSON output:
screencommander type "delayed text" --mode unicode --delay-ms 50 --json
Typing 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 ax
Behavior:
- 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>.
Key
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>.
Keys
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>.
Elements
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-only
Behavior:
- 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).
Observe
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 changes
Behavior:
- 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.
Cleanup
screencommander cleanup --older-than-hours 24
Explicitly 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.
Sequence
Run an ordered bundle of actions from JSON:
screencommander sequence --file ./sequence.json
Example 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>.
Windows
List visible windows, optionally filtered by app:
screencommander windows screencommander windows --app Safari screencommander windows --json
Behavior:
- 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.
Screenshot (window capture)
Capture a single window instead of the full display:
screencommander screenshot --window 12345 screencommander screenshot --window Safari
Behavior:
--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).
Focus
Bring an app to the foreground:
screencommander focus --app Safari screencommander focus --app 1234 screencommander focus --app Safari --json
Behavior:
--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.
Serve (MCP server)
Run screencommander as a persistent MCP server over stdio:
screencommander serve --mcp
Register it with Claude Code:
claude mcp add screencommander -- screencommander serve --mcp
Behavior:
- 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 (~100–300 ms) is cached for 2 seconds in serve mode, so
bursts like
windows→screenshot→clickpay it once. The CLI always enumerates fresh. 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).
Scripting (JSON output)
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 --compact
With 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.
Metadata Schema
{
"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"
}Exit Codes
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)