| name | cleanshotx |
| description | Automates CleanShot X on macOS via its cleanshot:// URL scheme — captures screenshots (area, window, fullscreen, scrolling, previous-area, all-in-one), records the screen, OCRs text from images or screen regions, pins images on top, opens files for annotation, manages capture history, toggles desktop icons, and opens specific settings tabs. Use when the user mentions CleanShot, CleanShot X, cleanshot:// URLs, scripted macOS screenshots or screen recordings, programmatic screen OCR on a Mac, pinning a screenshot, or driving the CleanShot app from a shell, Shortcut, or agent. macOS only. Most commands need CleanShot 3.5.1+; the `action=` parameter and most spatial parameters require 4.7+; `capture-text` requires 3.8.1+ on macOS 10.15+. |
| license | MIT |
| compatibility | macOS with CleanShot X installed (https://cleanshot.com). Commands fire by opening cleanshot:// URLs (`open "cleanshot://…"`); requires an Aqua login session and CleanShot to be running or launchable. Over SSH the URL fires only when the same user has an active graphical session. Most commands require 3.5.1+; `action=` and most spatial parameters require 4.7+; `capture-text` requires 3.8.1+ on macOS 10.15+; `all-in-one` 4.2+; `open-history` 4.4+. |
| metadata | {"app-website":"https://cleanshot.com","api-docs":"https://cleanshot.com/docs-api","url-scheme":"cleanshot://"} |
Driving CleanShot X via the cleanshot:// URL scheme
CleanShot X is a macOS screenshot and screen-recording app from MagicLasso. It exposes a URL scheme (cleanshot://command?param=value&…) that triggers any of its capture, recording, OCR, annotation, pin, history, or settings actions. Opening such a URL launches the app (if needed) and runs the action. That URL scheme is the only supported automation surface — there is no public CLI, AppleScript dictionary, or HTTP API. macOS Shortcuts.app actions internally invoke the same scheme.
The full per-command reference with every parameter and version constraint lives in references/url-scheme.md. Read it before automating any command this SKILL.md does not show in full.
Gotchas — read these before automating
- Requires an Aqua login session.
open "cleanshot://…" goes through LaunchServices and the app's UI. It needs an active logged-in graphical session for the same user — over SSH it fires only when that user is also logged in locally. It will not run from a launchd Background agent or before login. Use a user-context launchd agent (LimitLoadToSessionType: Aqua), Screen Sharing, or run it as a step inside a logged-in session.
- CleanShot X must be installed and able to launch. If the app is not installed, opening the URL silently does nothing or prompts the user to choose an app. Verify with
mdfind "kMDItemCFBundleIdentifier == 'pl.maketheweb.cleanshotx'", or check the common install paths: /Applications/CleanShot X.app (direct/App Store) and ~/Applications/Setapp/CleanShot X.app (Setapp).
- No return value, no completion signal. The URL fires asynchronously; the calling shell gets exit code 0 as soon as
open hands off. There is no built-in way to wait for "capture finished" or to recover the resulting file path. To know what happened: inspect the configured save folder (CleanShot ▸ Settings ▸ Screenshots ▸ "Save to") or watch the clipboard.
- URL parameters must be URL-encoded. Spaces in a
filepath become %20. Always quote the URL when passing it to open, and percent-encode user-supplied paths. See the helper at the bottom of this file.
- Version-gated parameters may not work on older builds. Parameters added in 4.7 (
action=…, x/y/width/height/display on most commands, start=true, autoscroll=true, tab=…) are not supported on earlier versions and are typically ignored — the command still runs without the parameter. Don't rely on a version-gated parameter without first checking defaults read "/Applications/CleanShot X.app/Contents/Info" CFBundleShortVersionString.
action is a single value, not a chain. action=annotate alone is valid; action=copy,upload is not. Pick one of copy | save | annotate | upload | pin. To compose actions (e.g. annotate then upload), drive subsequent steps from a follow-up URL or from the annotator UI.
filepath parameter rules. When you supply a filepath to any command that takes one (pin, open-annotate, capture-text, add-quick-access-overlay), it must be an absolute path — not ~-relative. Expand ~ in the shell before passing. The parameter is optional for pin / open-annotate / capture-text (omitting it falls back to an interactive picker or area selection — see references/url-scheme.md) and required for add-quick-access-overlay. Accepted formats: PNG and JPEG for screenshot commands; add-quick-access-overlay additionally accepts MP4.
capture-window is always interactive — wrong tool for "screenshot the X window" automation. It enters a hover-and-click selection mode; there is no parameter to target a window by name, PID, or window ID. For unattended capture of a known window, use capture-area with explicit x, y, width, height (4.7+) computed from the window's frame — see Capture a specific named window unattended below. Do not drop down to screencapture -l just because capture-window does not fit; CleanShot's URL scheme can do this headlessly.
- Don't infer point dimensions by halving a Retina pixel resolution.
system_profiler SPDisplaysDataType reports Resolution: 3456 x 2234 Retina — that is the native pixel grid. Dividing by 2 happens to be right on the default macOS display mode but is wrong on any scaled mode ("More Space", "Larger Text"), where points are decoupled from native pixels. Read points directly: AppleScript tell application "Finder" to get bounds of window of desktop (main display, 4th comma-field), or JXA NSScreen.frame.size.height (any display) — both return points.
- The save-folder defaults key is
exportPath, not CaptureFolder. defaults read pl.maketheweb.cleanshotx CaptureFolder does not exist — the command errors with a non-zero exit and any || echo "$HOME/Desktop" fallback silently leaves you scanning ~/Desktop, where the file will never appear if the user has moved their save folder. Always read exportPath (the user's "Save to" path from CleanShot ▸ Settings ▸ Screenshots).
- Stick to stock macOS — do not reach for PyObjC. Tempting alternatives like
python3 -c "import Quartz; ..." or from AppKit import NSScreen will fail with ModuleNotFoundError on a default install: PyObjC ships with Apple's system Python 2 historically but is not present in /usr/bin/python3 or in Homebrew Python without pip install pyobjc. Use osascript (AppleScript or JavaScript-for-Automation) instead — it always works.
- Apple-backend / sandbox surprises. Capturing a window or area may require Screen Recording permission for the app. Recording the screen also requires Microphone permission if audio is enabled. The first run of any command may surface a TCC prompt that needs a user click — agents cannot auto-accept TCC.
scrolling-capture only proceeds with start=true on 4.7+; without it the user has to click "Start" in the overlay. Use start=true&autoscroll=true for a fully unattended capture (4.7+).
record-screen does not stop itself. There is no stop-recording URL. Stop recording from the menu-bar item, the global shortcut (default ⌘⇧⌥3 stop, or click the floating control), or by sending a cleanshot://record-screen toggle — but the toggle behavior depends on the user's settings and is not guaranteed. Treat recording as user-supervised.
display= is a 1-based index of the display ordering CleanShot sees. Display 1 is typically the main display; multi-display indices may not match system_profiler SPDisplaysDataType ordering. Test on the target machine.
- No undo for
delete-style actions inside CleanShot. The URL scheme has no destructive commands directly, but restore-recently-closed and open-history both touch the history database — back it up before bulk operations.
Quick start: invoking the URL scheme
The single primitive is open with a cleanshot:// URL. From a shell:
open "cleanshot://capture-area?action=annotate"
open "cleanshot://capture-previous-area?action=copy"
open "cleanshot://capture-text?filepath=/Users/me/Desktop/screenshot.png&linebreaks=true"
open "cleanshot://pin?filepath=/Users/me/Desktop/diagram.png"
open "cleanshot://open-settings?tab=recording"
From AppleScript / osascript:
osascript -e 'open location "cleanshot://capture-fullscreen?action=upload"'
From Python (when you need URL-encoding done for you):
import subprocess, urllib.parse, pathlib
path = pathlib.Path("~/Desktop/My Screenshot.png").expanduser().resolve()
url = "cleanshot://pin?filepath=" + urllib.parse.quote(str(path), safe="/")
subprocess.run(["open", url], check=True)
Command catalog (summary)
The 19 commands fall into seven groups. For full parameters, defaults, and per-parameter version requirements, see references/url-scheme.md.
| Group | Commands |
|---|
| Screenshots | capture-area, capture-previous-area, capture-fullscreen, capture-window, self-timer, scrolling-capture, pin |
| Recording | record-screen |
| OCR | capture-text |
| Annotation | open-annotate, open-from-clipboard |
| All-In-One | all-in-one |
| Desktop icons | toggle-desktop-icons, hide-desktop-icons, show-desktop-icons |
| History / overlays / settings | add-quick-access-overlay, open-history, restore-recently-closed, open-settings |
Common parameter shapes:
action=copy | save | annotate | upload | pin — what to do with the result. Requires CleanShot 4.7+. Default is whatever the user has configured in CleanShot ▸ Settings ▸ Screenshots ▸ "After capture".
x, y, width, height, display — capture region. Requires 4.7+ on most commands. Coordinates use the macOS native coordinate system: origin (0,0) is the lower-left corner of the screen, with y increasing upward (per the CleanShot docs). Units are points, not Retina pixels. display is a 1-based integer; if omitted it defaults to the display under the cursor.
filepath — absolute path to a PNG or JPEG (MP4 also for add-quick-access-overlay). Must be URL-encoded.
Common workflows
One-shot: capture an area and upload to the cloud
open "cleanshot://capture-area?action=upload"
The user drags out a region; CleanShot uploads to the configured destination (CleanShot Cloud, S3, Dropbox, etc.) and copies the resulting URL to the clipboard. The URL is not returned to the caller — read it from the clipboard:
pbpaste
Repeat the previous capture region and save to disk
Useful for taking a sequence of "same region" shots while a UI updates:
open "cleanshot://capture-previous-area?action=save"
OCR text out of an image file
open "cleanshot://capture-text?filepath=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "$HOME/Downloads/menu.jpg")&linebreaks=false"
sleep 1
pbpaste
linebreaks=false flattens the text into a single paragraph; true preserves line breaks. CleanShot's standard OCR flow writes the recognized text to the clipboard — read it with pbpaste (the URL itself returns nothing).
Capture a specific named window unattended
The right tool for "screenshot Ghostty's window" with no clicks is not capture-window (that command always opens an interactive picker). It is capture-area with explicit x/y/width/height (4.7+) computed from the window's frame, plus action=save. The only twist is the coordinate flip: macOS UI APIs (System Events, AppKit, Accessibility) report top-left-origin bounds, but CleanShot's URL scheme uses the lower-left origin (cs_y = display_height - top_y - height).
The snippet below uses only tools shipping in stock macOS — osascript (AppleScript and JXA), defaults, find. Do not reach for PyObjC / import Quartz / pip install pyobjc here: those are not part of a stock macOS python3, and any agent that tries them will hit ModuleNotFoundError: No module named 'Quartz'. AppleScript covers everything you need.
There are two flavours of "the right window":
- "This window" (the one the user is currently looking at, e.g. when the user types "screenshot this window" inside the agent's host terminal). Resolve the frontmost application's frontmost window — do not hard-code an app name like
Ghostty, because that breaks the moment the user runs the agent inside iTerm2, Terminal.app, Alacritty, Wezterm, Kitty, or any other terminal.
- "Window of app X" (the user explicitly named the app, e.g. "screenshot Slack"). Pin the AppleScript to that app by name.
Both flavours share the rest of the pipeline (raise the window so it isn't occluded; flip y; fire the URL; poll for the file). The only difference is how APPNAME and the window-bounds query are resolved at the start.
read -r APPNAME WX WY WW WH <<<"$(osascript <<'AS'
tell application "System Events"
set frontApp to first application process whose frontmost is true
-- Skip menu-bar-only apps (no windows) and walk to the next visible app.
if (count of windows of frontApp) = 0 then
repeat with proc in (every application process whose visible is true)
if (count of windows of proc) > 0 then set frontApp to proc
if (count of windows of frontApp) > 0 then exit repeat
end repeat
end if
set w to window 1 of frontApp
-- Raise it so a region-capture doesn't grab whatever is currently on top.
try
set frontmost of frontApp to true
end try
try
perform action "AXRaise" of w
end try
delay 0.2
set pos to position of w
set sz to size of w
return (name of frontApp) & " " & (item 1 of pos as text) & " " & (item 2 of pos as text) & " " & (item 1 of sz as text) & " " & (item 2 of sz as text)
end tell
AS
)"
SH=$(osascript -l JavaScript -e '
ObjC.import("AppKit");
(function () {
var screens = $.NSScreen.screens, x = '"$WX"', y = '"$WY"';
for (var i = 0; i < screens.count; i++) {
var f = screens.objectAtIndex(i).frame;
if (x >= f.origin.x && x < f.origin.x + f.size.width
&& y >= f.origin.y && y < f.origin.y + f.size.height) {
return f.size.height; // points
}
}
return $.NSScreen.mainScreen.frame.size.height; // fallback: main display
})();
')
CSY=$(( SH - WY - WH ))
MARK=$(mktemp -t cs.marker)
open "cleanshot://capture-area?x=${WX}&y=${CSY}&width=${WW}&height=${WH}&action=save"
SAVE_DIR=$(defaults read pl.maketheweb.cleanshotx exportPath 2>/dev/null \
|| echo "$HOME/Desktop")
for _ in 1 2 3 4 5 6 7 8 9 10; do
NEW=$(find "$SAVE_DIR" -type f -newer "$MARK" \
\( -name '*.png' -o -name '*.jpg' \) -print -quit 2>/dev/null)
[ -n "$NEW" ] && { echo "$NEW"; break; }
sleep 0.3
done
Add &display=N (1-based) to the URL if you also want CleanShot to disambiguate; the capture-area math above already handles non-primary displays through JXA's NSScreen.screens lookup. CleanShot may still flash a save-confirmation overlay unless the user has set "After capture: Save" in their settings.
If you have a single primary display and want a one-liner instead of the JXA snippet, AppleScript's Finder also returns the main display height in points: osascript -e 'tell application "Finder" to get bounds of window of desktop' | awk -F', ' '{print $4}'. Same caveat — points, not pixels.
Caveat: multi-tab/multi-window terminals and "this window"
System Events models a macOS application process as having an ordered list of windows. It does not expose tabs, panes, or per-TTY window mappings. So when the agent runs inside one tab/pane of a multi-tab terminal (Ghostty, iTerm2, Wezterm, Kitty, Alacritty…) and the user has also opened other windows or tabs in that same terminal app, the recipe above resolves to the application's most-recently-active window — which may not be the agent's own host tab/window.
Tempting fixes that don't work reliably:
- OSC 0/2 title-marker injection (writing an
ESC ] 0 ; MARKER BEL sequence to /dev/<user-tty>). The marker reaches the right pty, but Claude Code (and many shell prompts) re-set the window title on every render, so the marker is overwritten well under a second after you write it — System Events usually reads the post-restore title, not the marker.
screencapture -l <windowID>. It bypasses CleanShot entirely and produces a file CleanShot's pipeline doesn't know about; only fall back to it when CleanShot is genuinely unavailable.
- Ghostty's
GHOSTTY_SURFACE_ID env var. Ghostty exposes the surface id but does not (as of 1.3.x) provide a CLI/AppleScript hook to focus a surface by id, so reading the env var doesn't get you to the right window.
What to do instead:
- Resolve the frontmost app + its frontmost window atomically (the snippet above) and capture immediately. In the common case — single-window terminal, or the user is actively staring at the agent — this is correct.
- If the user explicitly names a target ("screenshot Slack"), use the
APP=… variant in 1b so the result doesn't depend on what happens to be frontmost.
- If the user has multiple windows of the same app and demands the agent's host window specifically, the only fully reliable path is to ask them to bring that window to the front first, or to run the agent in a single-window terminal session for the duration of the capture.
Unattended scrolling capture of a long page (4.7+)
open "cleanshot://scrolling-capture?start=true&autoscroll=true"
Without autoscroll=true, the user has to scroll manually; without start=true, they have to click Start in the overlay.
Pin an image as a floating reference
ABS_PATH=$(python3 -c 'import os,sys;print(os.path.abspath(os.path.expanduser(sys.argv[1])))' "~/Desktop/spec.png")
ENC_PATH=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))' "$ABS_PATH")
open "cleanshot://pin?filepath=$ENC_PATH"
The image floats above all windows until the user closes the pin. The URL scheme has no documented unpin command — close the pin manually or via UI scripting.
Locate the file CleanShot just saved
The open "cleanshot://…" call returns immediately and prints nothing — there is no synchronous way to recover the saved file path. Three workable patterns:
-
Poll the configured save folder. Read the user's "Save to" path from pl.maketheweb.cleanshotx's exportPath default (do not read CaptureFolder — that key does not exist; defaults read will error out and your fallback to ~/Desktop will look in the wrong place for any user who has changed their CleanShot save folder), then pick the newest file written after a marker:
SAVE_DIR=$(defaults read pl.maketheweb.cleanshotx exportPath 2>/dev/null \
|| echo "$HOME/Desktop")
MARK=$(mktemp -t cs.marker)
open "cleanshot://capture-area?x=0&y=0&width=800&height=600&action=save"
for _ in 1 2 3 4 5; do
NEW=$(find "$SAVE_DIR" -type f -newer "$MARK" \
\( -name '*.png' -o -name '*.jpg' \) -print -quit)
[ -n "$NEW" ] && { echo "$NEW"; break; }
sleep 0.5
done
find … -newer works because CleanShot writes a brand-new file per capture. (annotateLastSaveURL in the same defaults domain also tracks the most recently saved capture, but it's only updated when CleanShot's annotator wrote the file — exportPath + a fresh-file scan is more reliable.)
-
Use action=copy instead of save. The image goes on the clipboard; pull bytes with pbpaste (or the AppKit NSPasteboard) and write them yourself.
-
Use action=upload. CleanShot copies the upload URL (not the image) to the clipboard — pbpaste returns it, e.g. https://cln.sh/abcd1234.
Do not assume open prints the file path: it never does, regardless of action=.
Detect installed version before using 4.7-only parameters
VERSION=$(defaults read "/Applications/CleanShot X.app/Contents/Info" CFBundleShortVersionString 2>/dev/null || echo "0")
if [ "$(printf '%s\n4.7\n' "$VERSION" | sort -V | head -n1)" != "4.7" ]; then
echo "CleanShot $VERSION is too old for action= / spatial parameters" >&2
exit 1
fi
URL-encode a path safely (helper)
CleanShot accepts only percent-encoded URLs. The most reliable encoder for an arbitrary path:
encode_path() {
python3 -c 'import sys,urllib.parse,os;print(urllib.parse.quote(os.path.abspath(os.path.expanduser(sys.argv[1])),safe="/"))' "$1"
}
open "cleanshot://open-annotate?filepath=$(encode_path "~/Pictures/My Shot.png")"
Bash-only fallback (no Python): printf '%s' "$path" | jq -sRr @uri if jq is available.
Verifying CleanShot is present and runnable
Before firing any URL, agents should confirm:
if [ ! -d "/Applications/CleanShot X.app" ] && [ ! -d "$HOME/Applications/Setapp/CleanShot X.app" ]; then
if [ -z "$(mdfind "kMDItemCFBundleIdentifier == 'pl.maketheweb.cleanshotx'")" ]; then
echo "CleanShot X not installed" >&2; exit 1
fi
fi
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -dump 2>/dev/null \
| grep -A1 "scheme:" | grep -q "cleanshot" \
&& echo "URL scheme registered"
launchctl managername | grep -qi Aqua || echo "Warning: not an Aqua session — URL scheme may not fire"
When the URL scheme isn't enough
Things the URL scheme cannot do — escalate or document the limitation rather than inventing a command:
- Programmatically stop a recording (no
stop-recording URL).
- Compose multiple post-capture actions in one call (
action=copy,upload).
- Read the resulting file path or upload URL from the command — must read the clipboard or scan the configured save folder.
- Drive Quick Access overlay placement, size, or z-order beyond "added".
- Change settings programmatically (only open the settings tab).
- Capture without a user-visible UI (every command surfaces some on-screen affordance).
- Unpin a pinned image.
For workflows that need richer integration than the URL scheme exposes, fall back to macOS Shortcuts.app (CleanShot ships several Shortcut actions that wrap these URLs). Drop down to OS-level alternatives (screencapture, xcrun simctl io … screenshot, native AVFoundation recorders) only when CleanShot is genuinely unavailable. In particular, do not reach for screencapture -l <windowID> to dodge capture-window's click — cleanshot://capture-area with explicit x/y/w/h (see Capture a specific named window unattended) covers that case headlessly.
Where to look next
- references/url-scheme.md — every command, every parameter, version requirements, examples.
- Vendor docs: https://cleanshot.com/docs-api (authoritative; check for new commands when this skill seems incomplete).
- App version:
defaults read "/Applications/CleanShot X.app/Contents/Info" CFBundleShortVersionString.
- App support directory (per-user CleanShot state):
~/Library/Application Support/pl.maketheweb.cleanshotx/.
- Default save folder for captured files: shown in CleanShot ▸ Settings ▸ Screenshots ▸ "Save to".