macOS Automation skill for Claude Code
Shortcuts, AppleScript and launchd that just keep running. Claude picks the least fragile macOS mechanism for the job: a Shortcut or Tahoe automation, AppleScript or JXA against the app's dictionary, or a launchd agent with StartCalendarInterval or WatchPaths. Paths are absolute, secrets come from the Keychain, logs go to ~/Library/Logs, and privacy errors like -1743 are traced to the exact binary that needs permission.
- name
macos-automation - size190 lines · ≈2,250 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Automates macOS with the right tool for each job — Shortcuts (including Mac automations and the shortcuts CLI), AppleScript and JavaScript for Automation via osascript, launchd LaunchAgents for schedules and folder watching, Keychain for secrets, and shell scripts — with correct privacy permissions (TCC), logging, and replacements for Automator workflows and cron. Use when automating a Mac task, writing AppleScript or JXA, scheduling a script on macOS, creating a launchd plist, running a Shortcut from the terminal, watching a folder, or fixing "not authorized to send Apple events" errors. Try asking
- Run my backup script every night at 2:30 on my Mac, even if it was asleep
- Write an AppleScript that archives read Mail messages older than 30 days
- Process any PDF dropped into ~/Inbox/Scans automatically and move it when done
Use it when
- Automating a repetitive task on a Mac
- Scheduling or folder-watching scripts on macOS
- Writing AppleScript, JXA or running Shortcuts from the terminal
- Fixing Apple events or privacy permission errors
Skip it when
- The job runs on a Linux server — use scheduled-jobs
- You are building a native Mac app UI — use swiftui-views
How to check the result
launchctl print shows last exit code 0 after a kickstart, the job runs on wake after sleeping through its slot, and it is still loaded after a reboot.
The common mistake it prevents
Granting Automation or disk permission to Terminal and expecting it to apply when launchd runs the same script.
Done well means: the automation runs without someone at the keyboard, survives sleep and reboot, asks for each privacy permission once (and the user knows why), writes logs to a known file, keeps secrets in the Keychain, and can be turned off with one command.
Hard rules
- Use the least-invasive mechanism: Shortcuts or app scripting dictionaries before UI scripting (simulated clicks via System Events), which is brittle and needs Accessibility permission.
- Never ask the user to disable SIP, Gatekeeper, or TCC protections. Grant specific permissions (Automation, Files and Folders, Full Disk Access, Accessibility) to the specific app or binary that needs them.
- Secrets go in the Keychain (
security add-generic-password), never in plists, scripts, or Shortcuts text actions. - Automations that delete, move, or send files or messages run in dry-run mode first and need user confirmation before being scheduled.
- Use absolute paths everywhere; launchd and Shortcuts run with a minimal environment.
Pick the tool
| Task | Default | Notes |
|---|---|---|
| Personal workflow with app actions (Notes, Reminders, Files, Messages) | Shortcuts | Shareable; runs from menu bar, Spotlight, CLI |
| Trigger on time, folder change, app launch, Wi-Fi, Focus, display | Shortcuts automations (macOS 26 Tahoe and later) | Choose “run immediately” vs. ask; Mac-specific triggers include folders and external drives |
| Same, on older macOS or needing logs/retries | launchd LaunchAgent + script | StartCalendarInterval, WatchPaths, StartInterval |
| Control a scriptable app (Finder, Mail, Safari, Music, Office) | AppleScript or JXA via osascript | Check the app’s dictionary in Script Editor (File -> Open Dictionary) |
| File and text processing | zsh/bash or Python script | Called from launchd or a Shortcut’s “Run Shell Script” |
| Keyboard-driven window and app control | Hammerspoon, Raycast, or Alfred | Third-party; Lua/JS scripting |
| Legacy Automator workflows | Rebuild in Shortcuts | Shortcuts can import many Automator workflows on first open |
| cron on macOS | Replace with launchd | cron skips runs while asleep and needs Full Disk Access for many paths |
Workflow
- [ ] 1. Define trigger, inputs, outputs, and side effects
- [ ] 2. Prototype interactively (Shortcuts editor / Script Editor / terminal)
- [ ] 3. Move secrets to Keychain; make paths absolute
- [ ] 4. Wire the trigger (Shortcuts automation or launchd)
- [ ] 5. Grant permissions once; document which and why
- [ ] 6. Add logging and failure notification
- [ ] 7. Test: manual run, scheduled run, after sleep, after reboot
Shortcuts from the terminal
shortcuts list # names of all shortcuts
shortcuts run "Resize Images" -i ~/Desktop/in.png -o ~/Desktop/out.png
echo "hello" | shortcuts run "Append to Log" # stdin as input
shortcuts view "Resize Images" # open in editor
shortcuts sign -i unsigned.shortcut -o signed.shortcut -m anyone # share outside iCloud
Inside Shortcuts, “Run Shell Script” runs with a minimal PATH; call tools by absolute
path (/opt/homebrew/bin/jq on Apple silicon, /usr/local/bin on Intel).
AppleScript and JXA
osascript -e 'display notification "Backup finished" with title "Backup" sound name "Glass"'
osascript -e 'tell application "Finder" to get name of every disk'
osascript -l JavaScript -e 'Application("Music").currentTrack.name()'
-- file: ~/Scripts/archive-mail.applescript
on run argv
set cutoff to (current date) - (30 * days)
tell application "Mail"
set oldMsgs to (messages of inbox whose date received < cutoff and read status is true)
repeat with m in oldMsgs
move m to mailbox "Archive" of account "Work"
end repeat
return (count of oldMsgs) as text
end tell
end run
// JXA: file ~/Scripts/reminders.js, run with: osascript -l JavaScript ~/Scripts/reminders.js
const app = Application('Reminders');
const due = app.lists.byName('Work').reminders.whose({ completed: false })();
due.map(r => `${r.name()} ${r.dueDate() ?? ''}`).join('\n');
- Compile check:
osacompile -o /tmp/x.scpt script.applescript. - Long operations: wrap in
with timeout of 600 seconds ... end timeout(default Apple event timeout is 2 minutes). - AppleScript is better documented for most apps; JXA is handy when you want JSON and JavaScript, but some dictionaries behave oddly from JXA.
launchd LaunchAgent (per-user schedule or watcher)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.example.nightly-backup</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string><string>-c</string>
<string>/Users/me/Scripts/backup.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>2</integer><key>Minute</key><integer>30</integer></dict>
<key>EnvironmentVariables</key>
<dict><key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
<key>StandardOutPath</key><string>/Users/me/Library/Logs/nightly-backup.log</string>
<key>StandardErrorPath</key><string>/Users/me/Library/Logs/nightly-backup.log</string>
<key>ProcessType</key><string>Background</string>
</dict>
</plist>
plutil -lint ~/Library/LaunchAgents/com.example.nightly-backup.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.nightly-backup.plist
launchctl kickstart -k gui/$(id -u)/com.example.nightly-backup # run now
launchctl print gui/$(id -u)/com.example.nightly-backup | grep -E 'state|last exit'
launchctl bootout gui/$(id -u)/com.example.nightly-backup # disable/unload
Triggers:
StartCalendarInterval: wall-clock schedule; a run missed during sleep fires on wake (multiple missed runs coalesce into one).StartInterval: every N seconds while awake.WatchPaths/QueueDirectories: run when a path changes / while a directory is non-empty (good for “process files dropped in this folder”; the script must move processed files out, or it relaunches repeatedly).RunAtLoad: also run at login/bootstrap.KeepAlive: for daemons only.
LaunchAgents (~/Library/LaunchAgents) run as the user in the GUI session and can show
notifications and talk to apps. LaunchDaemons (/Library/LaunchDaemons, root) run
without login and cannot reliably script GUI apps. Recent macOS versions show the user a
“Background Items Added” notification and list agents under System Settings -> General ->
Login Items; tell the user to expect it.
Overlap: launchd does not start a second instance of the same job while one is running, but your script should still be idempotent (see scheduled-jobs for the full pattern).
Secrets from the Keychain
security add-generic-password -a "$USER" -s backup-api-token -w # prompts for value
TOKEN=$(security find-generic-password -a "$USER" -s backup-api-token -w)
First access from a new binary may prompt the user; approve with “Always Allow” for that tool only.
Privacy permissions (TCC)
| Error / symptom | Permission needed | Where |
|---|---|---|
| “Not authorized to send Apple events to X” (-1743) | Automation | Settings -> Privacy and Security -> Automation |
| “Operation not permitted” reading Desktop/Documents/Mail data | Files and Folders or Full Disk Access | Same pane |
| UI scripting via System Events fails (-25211 / assistive access) | Accessibility | Same pane |
Permissions attach to the calling binary (Terminal, iTerm, osascript, a compiled
app, or the launchd-run interpreter). Granting Terminal does not cover launchd. To reset
a confused prompt: tccutil reset AppleEvents com.apple.Terminal (resets that client’s
Automation grants; the user must re-approve).
Verification
- Manual run produces the expected result and log lines.
launchctl printshows the job loaded withlast exit code = 0after a kickstart.- Put the Mac to sleep across a scheduled time; confirm the job ran on wake.
- Reboot and log in; confirm the agent is still loaded.
- Revoke one permission; the script fails with a clear logged message, not a hang.
Gotchas
- Shell profile files are not read by launchd or Shortcuts; set PATH explicitly.
~is not expanded inside plists; use full paths.- Editing a loaded plist has no effect until
bootoutthenbootstrapagain. - Old tutorials use
launchctl load/unload; they still work but are legacy; preferbootstrap/bootout/kickstart. - Scripts that drive apps open them; check
application "X" is runningfirst if a visible app launch at 2 a.m. would be unwelcome. - UI scripting breaks with every app redesign and locale change; use it as a last resort.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.