osascript-launchd
LaunchAgents (per-user, GUI access) and LaunchDaemons (root, system-wide, survives logout). All scripts are pure Python using stdlib only.
LaunchAgent vs LaunchDaemon
| LaunchAgent | LaunchDaemon | |
|---|---|---|
| Runs as | Logged-in user | root |
| Starts when | User logs in | System boot |
| Lives in | ~/Library/LaunchAgents |
/Library/LaunchDaemons |
| GUI access | Yes | No |
| TCC restrictions | Yes | No — full system access |
Use LaunchAgent for automation that needs your session (osascript, screen, Chrome). Use LaunchDaemon for always-on background work that needs root or full disk access.
Trigger Types
| Trigger | Key | Use case |
|---|---|---|
| On load/login | RunAtLoad: true |
Start immediately |
| Keep alive | KeepAlive: true |
Restart on any exit (even kill -9) |
| Interval | StartInterval: N |
Every N seconds |
| Calendar | StartCalendarInterval |
Cron replacement (catches up after sleep) |
| File change | WatchPaths |
Instant trigger on file/dir change (no polling) |
| New files | QueueDirectories |
Job queue — fires when files appear |
Scripts
Generate a LaunchAgent plist from parameters:
uv run scripts/generate-plist.py com.user.myjob "/usr/bin/python3 /path/to/script.py" --interval 60
Write plist to ~/Library/LaunchAgents and load it immediately:
uv run scripts/install-agent.py com.user.myjob "/usr/bin/python3 /path/to/script.py" --interval 60
Load, unload, start, stop, or check status of a LaunchAgent:
uv run scripts/launchctl.py status com.user.myjob
uv run scripts/launchctl.py load com.user.myjob
uv run scripts/launchctl.py stop com.user.myjob
Reference
- reference/07-launchdaemons.md — Full plist anatomy, all trigger types, resource controls, launchctl commands
Key Pattern: WatchPaths for iMessage Bot
<key>WatchPaths</key>
<array>
<string>/Users/you/Library/Messages/chat.db</string>
</array>
Fires instantly when chat.db changes. No polling. Daemon reads new message, Claude processes, osascript sends reply.