Automating macOS with launchd Agents and Daemons
A practical guide to writing, installing, and debugging your own scheduled or persistent launchd jobs.
Anyone coming to macOS from Linux eventually asks “where’s cron,” and while cron technically still exists on macOS, it’s deprecated in favor of launchd — and for good reason: a launchd agent gets proper logging integration, can be triggered by things cron never could (a file changing, a specific event, a socket connection), and is what Apple’s own scheduled tools use internally. Writing your own is the most practical way to actually understand launchd rather than just reading about it.
Deciding: agent or daemon
The first decision is scope. If the automation needs to run regardless of whether anyone’s logged in (a backup job, a server process), it’s a daemon, installed system-wide in /Library/LaunchDaemons/ and run as root or a dedicated service account. If it only makes sense in the context of a logged-in user — sending a notification, touching files in that user’s home directory — it’s an agent, installed in ~/Library/LaunchAgents/ for that user only, or /Library/LaunchAgents/ if it should apply to every user who logs in.
mkdir -p ~/Library/LaunchAgents
A scheduled agent: replacing a cron job
A job that should run daily needs StartCalendarInterval, launchd’s declarative scheduling key:
<?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.danielcosenza.dailybackup</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/backup.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>2</integer>
<key>Minute</key><integer>30</integer>
</dict>
<key>StandardOutPath</key>
<string>/tmp/dailybackup.log</string>
<key>StandardErrorPath</key>
<string>/tmp/dailybackup.error.log</string>
</dict>
</plist>
Unlike cron, if the Mac was asleep or off at the scheduled time, launchd will run the job as soon as the system wakes, rather than silently skipping that day’s execution entirely — a meaningful behavioral difference for laptops that aren’t always on.
Loading the job
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.danielcosenza.dailybackup.plist
launchctl print gui/$(id -u)/com.danielcosenza.dailybackup
launchctl print shows the job’s current state, last exit code, and next scheduled run — the primary tool for confirming the job is actually registered the way you intended, rather than guessing from whether the expected side effect happened.
Triggering on file changes instead of a schedule
WatchPaths starts a job whenever any path in the list changes — useful for automation that should react to events rather than run on a fixed clock:
<key>WatchPaths</key>
<array>
<string>/Users/daniel/Inbox</string>
</array>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.danielcosenza.inboxwatcher.plist
Note that WatchPaths triggers on essentially any metadata change to the watched path, not just new files being added — a job driven by this key should be written to check what actually changed rather than assuming every trigger means “a new file arrived.”
Keeping a process running continuously
For a long-running helper process rather than a scheduled task, KeepAlive combined with RunAtLoad is the standard pattern — launchd starts it immediately and restarts it automatically if it exits unexpectedly:
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
A more selective KeepAlive dictionary can restart only on abnormal exit, avoiding a restart loop for a process that’s supposed to exit cleanly under some conditions:
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
Debugging a job that won’t run
The most common failure is a plist that fails to parse, or a script that isn’t executable — both surface clearly through launchctl print and the redirected log files:
plutil -lint ~/Library/LaunchAgents/com.danielcosenza.dailybackup.plist
chmod +x /usr/local/bin/backup.sh
launchctl print gui/$(id -u)/com.danielcosenza.dailybackup | grep -i "last exit"
plutil -lint catches malformed XML before you even try loading the job — worth running as a first step any time a plist you hand-edited refuses to bootstrap.
Unloading and iterating
launchctl bootout gui/$(id -u)/com.danielcosenza.dailybackup
# edit the plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.danielcosenza.dailybackup.plist
There’s no “reload” verb — the standard iteration loop is bootout, edit, bootstrap again, which is worth internalizing early since launchctl load (the older, now-discouraged verb) papered over this distinction in a way that caused a lot of confusion about why edits to an already-loaded job weren’t taking effect.
Why this is worth learning over ad hoc scripts
A shell script triggered from a shell alias or run manually has no supervision, no scheduling reliability across sleep/wake cycles, and no structured way to check its last result. A launchd job gets all three for free, plus first-class integration with log show/log stream for output — the same infrastructure Apple’s own background tasks rely on, rather than a parallel, less-visible automation system running alongside it.