Skip to content
macOSDeep Dive Published Updated 6 min readViews unavailable

Automating macOS with launchd Agents and Daemons

A practical guide to writing, installing, and debugging your own scheduled or persistent launchd jobs, from a first plist through production-grade patterns.

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.

Preventing a rapid restart loop with ThrottleInterval

A KeepAlive job that crashes immediately on every restart can otherwise spin in a tight restart loop, consuming CPU and filling logs far faster than the underlying bug itself actually warrants investigating. ThrottleInterval sets a minimum number of seconds launchd waits between successive restarts of the same job, specifically to prevent this:

<key>ThrottleInterval</key>
<integer>10</integer>

This is a genuinely different concern from KeepAlive’s SuccessfulExit dictionary covered earlier — that key decides whether to restart at all based on exit status; ThrottleInterval decides how fast restarts are allowed to happen once the decision to restart has already been made, and combining both gives a job that retries sensibly rather than either giving up entirely or hammering the system with instant restart attempts.

Declaring the job’s process type for scheduler hints

<key>ProcessType</key>
<string>Background</string>

ProcessType (values include Interactive, Standard, Background, and Adaptive) hints to the kernel’s scheduler and resource-management subsystems about the job’s priority relative to foreground, user-facing work — a Background classification tells macOS this job’s CPU and I/O demands should yield to anything the user is actively interacting with, which matters specifically for a long-running agent that shouldn’t visibly degrade interactive performance just because it happens to be running at the same time. Omitting this key leaves the job at a default classification that may compete more aggressively for resources than a genuinely background task actually needs to.

Why launchd jobs don’t automatically inherit your shell’s environment

A script that works perfectly when run manually from Terminal can behave differently — or fail outright — when launchd runs the identical script, because a launchd job’s environment is deliberately minimal and doesn’t source .bashrc/.zshrc or inherit whatever PATH and environment variables your interactive shell happens to have accumulated. Explicitly setting any environment variables a job’s script actually depends on directly in the plist avoids this entire category of “works manually, fails when launchd runs it” confusion:

<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

Scripts assuming a fully-populated interactive-shell PATH are one of the single most common causes of a launchd job that works when tested manually but silently fails (often with a “command not found” from deep inside the script) the moment launchd actually runs it unattended.

Related:

Sources:

Comments