cheat sheet
launchctl
Bootstrap, bootout, kickstart, print, and enable/disable services with launchctl on macOS: agents vs daemons, plist anatomy, user/system domains, RunAtLoad vs KeepAlive vs StartInterval vs StartCalendarInterval, and recipes for periodic scripts and on-demand workers.
launchctl — Managing launchd Agents and Daemons
What it is
launchctl is the command-line interface to launchd, the unified macOS init system that replaces init, cron, inetd, at, and systemd in a single Apple-developed daemon (PID 1 since macOS 10.4 Tiger). Services are declared as XML property-list (plist) files in well-known directories, and launchctl is the verb-and-noun CLI you use to load them, run them on demand, query their state, and unload them. Reach for launchctl when you want a script to run at login, run on a schedule, run when a file or directory changes, run forever (restarting on crash), or run on demand triggered by a socket or IPC. The closest Linux analogue is systemctl over systemd units; the closest Windows analogue is sc.exe/schtasks.exe over Services + Task Scheduler.
Install
launchctl is part of the macOS base system at /bin/launchctl. There is nothing to install. The behaviour and verbs have changed across releases: the modern verbs (bootstrap, bootout, print, kickstart, enable, disable) were introduced in macOS 10.11 (El Capitan) and are the recommended interface today. The legacy verbs (load, unload, start, stop, list) still work but emit deprecation warnings on newer releases and don't model service state as cleanly.
which launchctl
launchctl version
sw_vers -productVersion
Output:
/bin/launchctl
launchctl version 1310.140.2
26.5
Syntax
The modern shape is launchctl <verb> <domain-target> [options], where a domain identifies the scope of a service (gui/<uid> for a user agent, system for a system daemon, pid/<pid> for one specific process) and a service-target is a fully-qualified identifier (gui/501/com.example.agent). The legacy shape is launchctl <verb> <plist-path> and is implicit about scope.
# Modern
launchctl <verb> <domain-target | service-target> [plist-or-args]
# Legacy
launchctl <verb> <plist-path>
Output: (none — exits 0 on success)
Essential verbs
| Verb | Era | Meaning |
|---|---|---|
bootstrap | modern | Load a plist into a domain (validates and registers) |
bootout | modern | Unload a service from a domain |
enable | modern | Mark a service as enabled (persists across reboots) |
disable | modern | Mark a service as disabled (persists across reboots) |
kickstart | modern | Force-start a registered service immediately; -k to kill+restart |
print | modern | Dump the live state of a domain or service (rich diagnostic output) |
print-disabled | modern | List services that are explicitly disabled in a domain |
dumpstate | modern | Write a complete launchd state dump to /tmp/launchd-statedump.txt |
list | legacy | One-line-per-service overview (still widely used) |
load | legacy | Equivalent to bootstrap for plist files (deprecated) |
unload | legacy | Equivalent to bootout (deprecated) |
start / stop | legacy | Start or stop a registered service (still works) |
setenv / getenv / unsetenv | modern | Environment variables visible to launchd's children |
procinfo | modern | Detailed info on one PID (resource limits, IORegistry entries) |
examine | modern | Open the launchd state in a viewer |
limit | modern | Inspect/adjust resource limits (maxfiles, maxproc) |
Agents vs daemons — the four directories
launchd distinguishes agents (run as a logged-in user, can interact with the GUI) from daemons (run as root, no GUI access, available before any user logs in). Each kind has a user-installed variant under ~/Library and a system-installed variant under /Library; Apple's own services live under /System/Library and should be left alone.
| Directory | Kind | Runs as | Loaded when |
|---|---|---|---|
~/Library/LaunchAgents | user agent | the logged-in user (you) | you log in |
/Library/LaunchAgents | system-installed user agent | each logged-in user, individually | any user logs in |
/Library/LaunchDaemons | system daemon | root | boot, before login |
/System/Library/LaunchAgents | Apple-shipped user agent | each logged-in user | login |
/System/Library/LaunchDaemons | Apple-shipped system daemon | root | boot |
A user agent has access to the user's $HOME, defaults database, keychain, and screen; a system daemon does not — it lives in a UI-less context and can't show notifications or read user keychains.
ls -1 ~/Library/LaunchAgents 2>/dev/null
ls -1 /Library/LaunchAgents 2>/dev/null
ls -1 /Library/LaunchDaemons 2>/dev/null
Output:
com.example.daily-backup.plist
homebrew.mxcl.postgresql@16.plist
homebrew.mxcl.redis.plist
com.adobe.AdobeCreativeCloud.plist
com.docker.vmnetd.plist
com.example.system-monitor.plist
Plist anatomy
A launchd job is described by a single XML plist with a small set of well-defined keys. At minimum, every plist needs a unique Label (must match the filename without .plist) and either a Program (single binary) or ProgramArguments (argv array). Everything else — when to run, what to do on crash, where output goes, what environment to run in — is optional.
<?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.daily-backup</string>
<key>ProgramArguments</key>
<array>
<string>/Users/alice/bin/backup.sh</string>
<string>--verbose</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>30</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/alice/Library/Logs/daily-backup.log</string>
<key>StandardErrorPath</key>
<string>/Users/alice/Library/Logs/daily-backup.err</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>HOME</key>
<string>/Users/alice</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/alice</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
The most-used keys, by purpose:
| Key | Type | Purpose |
|---|---|---|
Label | string | Unique identifier — also the filename |
Program | string | Single-binary path (use this OR ProgramArguments) |
ProgramArguments | array | argv (path is [0]) |
RunAtLoad | bool | Run immediately when bootstrapped/loaded |
KeepAlive | bool or dict | Restart on exit (unconditionally or per-condition) |
StartInterval | integer | Run every N seconds (after the previous run completes) |
StartCalendarInterval | dict or array | Cron-like schedule (minute/hour/day/weekday/month) |
StartOnMount | bool | Run whenever a volume mounts |
WatchPaths | array | Run when listed files/dirs change |
QueueDirectories | array | Run when listed dirs become non-empty |
EnvironmentVariables | dict | Env to pass to the program |
WorkingDirectory | string | chdir to this before launch |
StandardOutPath | string | Redirect stdout |
StandardErrorPath | string | Redirect stderr |
Umask | integer | Default file-creation mask |
UserName / GroupName | string | Drop privileges (daemons only) |
Nice | integer | nice value for the process |
LowPriorityIO | bool | setiopolicy_np low priority |
ProcessType | string | Background / Standard / Interactive / Adaptive |
LimitLoadToSessionType | string | Aqua (GUI), Background, LoginWindow |
MachServices | dict | XPC / Mach service names this job vends |
Sockets | dict | Inherited sockets (inetd-style on-demand) |
Disabled | bool | Set true to make launchctl enable required first |
ExitTimeOut | integer | Seconds to wait before SIGKILL during shutdown |
ThrottleInterval | integer | Minimum seconds between respawn attempts (default 10) |
bootstrap, bootout, kickstart
bootstrap parses and registers a plist with launchd in a given domain — it's the modern replacement for load. bootout reverses it. kickstart triggers a registered service to start right now, ignoring its schedule (-k first stops the running instance, then restarts; -p returns the PID).
# Identify your user-domain target ID
echo "gui/$(id -u)" # e.g. gui/501
# Bootstrap a user agent
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.example.daily-backup.plist
# Bootstrap a system daemon (must run as root)
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.system-monitor.plist
# Bootout (the inverse)
launchctl bootout "gui/$(id -u)/com.example.daily-backup"
sudo launchctl bootout system/com.example.system-monitor
# Kickstart — force run now even if RunAtLoad is false and schedule hasn't fired
launchctl kickstart -k "gui/$(id -u)/com.example.daily-backup"
# Print the PID after start
launchctl kickstart -p "gui/$(id -u)/com.example.daily-backup"
Output:
4827
enable, disable
enable and disable mark a service as runnable or non-runnable for the domain — the state persists across reboots and overrides the plist's own Disabled key. This is how you keep a plist on disk without it running, or temporarily silence a service for debugging.
# Disable a user agent across reboots
launchctl disable "gui/$(id -u)/com.example.daily-backup"
# Re-enable
launchctl enable "gui/$(id -u)/com.example.daily-backup"
# Disable a system daemon
sudo launchctl disable system/com.example.system-monitor
# List explicitly-disabled services in a domain
launchctl print-disabled "gui/$(id -u)" | head -10
Output:
disabled services = {
"com.example.daily-backup" => disabled
"com.apple.someoldservice" => disabled
}
print — the canonical state dump
launchctl print is by far the most useful diagnostic verb. Given a domain target, it lists every registered service with their state, PID, exit code, last-spawn time, and current run-state. Given a service target, it dumps a comprehensive record including the parsed plist values, environment, IORegistry holds, and the inferred restart reason.
# Domain-level overview
launchctl print "gui/$(id -u)" | head -30
# Service-level detail
launchctl print "gui/$(id -u)/com.example.daily-backup"
# System-level
sudo launchctl print system | head -30
Output (launchctl print for a single service):
gui/501/com.example.daily-backup = {
active count = 0
path = /Users/alice/Library/LaunchAgents/com.example.daily-backup.plist
state = not running
program = /Users/alice/bin/backup.sh
arguments = {
"/Users/alice/bin/backup.sh"
"--verbose"
}
working directory = /Users/alice
stdout path = /Users/alice/Library/Logs/daily-backup.log
stderr path = /Users/alice/Library/Logs/daily-backup.err
last exit code = 0
spawn type = daemon (3)
jetsam priority = 40
runs = 12
successful exits = 12
StartCalendarInterval = (
{ Hour = 3; Minute = 30; }
)
}
list — the quick overview
launchctl list (legacy but ubiquitous) prints one line per registered service: PID (or - if idle), exit code of the last run, and the label. Pipe through grep to focus on a subset.
launchctl list | head -10
launchctl list | grep example
launchctl list | grep -v com.apple
Output:
PID Status Label
- 0 com.apple.parsecd
4201 0 com.apple.Spotlight
- 78 com.example.daily-backup
812 0 homebrew.mxcl.postgresql@16
A non-zero Status is the last exit code — 78 here might mean "config error" depending on the program. - for PID means the service isn't currently running.
Scheduling — the four mechanisms
launchd supports four orthogonal ways to decide when a job runs. You almost always pick exactly one per plist.
RunAtLoad
Runs once, immediately, when launchd bootstraps the plist (at user login for user agents, at boot for daemons). Use it for "do this every time I log in" tasks like setting environment, mounting drives, or starting an Emacs server.
<key>RunAtLoad</key>
<true/>
StartInterval
Runs every N seconds after the previous instance has exited. If the program takes longer than N to run, launchd does not pile up overlapping invocations. Useful for "every 5 minutes" cadences that don't need to be wall-clock-aligned.
<key>StartInterval</key>
<integer>300</integer> <!-- every 5 minutes -->
StartCalendarInterval
Cron-like wall-clock scheduling. The dict shape is { Minute, Hour, Day, Weekday, Month }; any subset can be omitted (a wildcard). Pass an array of dicts for multiple distinct schedules.
<!-- 03:30 every day -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>30</integer>
</dict>
<!-- 09:00 weekdays only -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
<key>Weekday</key><integer>1</integer> <!-- 1 = Mon ... 5 = Fri -->
</dict>
<!-- Multiple — array of dicts -->
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>13</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>17</integer><key>Minute</key><integer>0</integer></dict>
</array>
Caveat: if the Mac is asleep at the scheduled time, the job runs as soon as the Mac wakes — launchd doesn't skip missed runs (unlike cron). Pair with pmset if you need wake-from-sleep.
WatchPaths and QueueDirectories
Trigger on filesystem change. WatchPaths fires when any listed path is modified (including the directory's mtime); QueueDirectories fires only when listed directories become non-empty (FIFO-style job queue).
<!-- Re-run when ~/Documents/inbox/ has new files -->
<key>QueueDirectories</key>
<array>
<string>/Users/alice/Documents/inbox</string>
</array>
<!-- Re-run when a config file changes -->
<key>WatchPaths</key>
<array>
<string>/Users/alice/.config/myapp/settings.toml</string>
</array>
KeepAlive — "run forever"
KeepAlive restarts the program when it exits. As a bare <true/> it restarts unconditionally. As a dictionary, restart is conditioned on one or more sub-keys.
<!-- Unconditional restart on exit -->
<key>KeepAlive</key>
<true/>
<!-- Restart only on non-zero exit -->
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<!-- Restart only while a network mount is available -->
<key>KeepAlive</key>
<dict>
<key>NetworkState</key>
<true/>
<key>PathState</key>
<dict>
<key>/Volumes/Backup</key>
<true/>
</dict>
</dict>
<!-- Throttle to one respawn every 60 seconds (default is 10) -->
<key>ThrottleInterval</key>
<integer>60</integer>
Environment, working directory, logs
launchd does not inherit your shell's environment — there is no $PATH, no shell startup file, nothing. Anything beyond /usr/bin:/bin:/usr/sbin:/sbin must be set explicitly. The EnvironmentVariables dict sets variables for the process; WorkingDirectory changes cwd; StandardOutPath and StandardErrorPath redirect stdout and stderr to files. Without these three, the symptom is "the script works from Terminal but the launch agent does nothing".
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>HOME</key>
<string>/Users/alice</string>
<key>AWS_PROFILE</key>
<string>default</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/alice/code/project</string>
<key>StandardOutPath</key>
<string>/Users/alice/Library/Logs/myapp.log</string>
<key>StandardErrorPath</key>
<string>/Users/alice/Library/Logs/myapp.err</string>
launchctl setenv exposes a variable to all launchd children — useful for things like LANG or PATH that GUI apps need at launch.
launchctl setenv LANG "en_US.UTF-8"
launchctl getenv LANG
launchctl unsetenv LANG
Output:
en_US.UTF-8
To inspect a running launch agent's environment, attach via lsof + procinfo:
pid=$(launchctl list | awk '/com.example.daily-backup/ {print $1}')
launchctl procinfo "$pid" | head -20
Output: (none — exits 0 on success)
Domains in full
A domain target is the path you address a service by; understanding the syntax is the most common stumbling block on macOS.
| Domain | Target string | Who runs the job |
|---|---|---|
| User (GUI) | gui/<uid> | the logged-in user; service has access to GUI |
| User (background, pre-login) | user/<uid> | the user, even before GUI login |
| System | system | root, no GUI |
| Per-session | session/<asid> | one specific audit session |
| One PID | pid/<pid> | inspect-only target for a running process |
A service target extends a domain with the service Label:
gui/501/com.example.daily-backup
user/501/com.example.always-on
system/com.example.system-monitor
Output: (none — exits 0 on success)
# Get your UID
id -u
# Inspect every user agent in your GUI domain
launchctl print "gui/$(id -u)" | grep ' = ' | head
# Inspect every system daemon
sudo launchctl print system | grep ' = ' | head
Output: (none — exits 0 on success)
SMAppService — the modern app-bundled alternative
For services that ship inside a macOS app bundle (login items, helper agents, privileged daemons), Apple now recommends the SMAppService API in the ServiceManagement framework instead of hand-installing plists into ~/Library/LaunchAgents or /Library/LaunchDaemons. Available from macOS 13 (Ventura) onward, SMAppService lets an app embed its launchd plists inside Contents/Library/LaunchAgents, Contents/Library/LaunchDaemons, or Contents/Library/LoginItems, and register/unregister them with one Swift/Obj-C call — no installer privileges required, automatic teardown when the app is uninstalled, and per-app user consent surfaced through System Settings → General → Login Items & Extensions.
// Register a launch agent shipped inside MyApp.app/Contents/Library/LaunchAgents/
let service = SMAppService.agent(plistName: "com.example.helper.plist")
try service.register()
// Or a login item
let loginItem = SMAppService.loginItem(identifier: "com.example.LoginHelper")
try loginItem.register()
// Inspect status
print(service.status) // .enabled / .requiresApproval / .notRegistered / .notFound
Output: (none — exits 0 on success)
From the command line, the user-facing surface for SMAppService-registered items is sfltool (Service Framework Tool) and the same System Settings pane; launchctl print gui/<uid> still shows them by Label, so the diagnostic verbs in this article apply uniformly. Reach for SMAppService when you ship a packaged app to end-users; reach for raw launchctl bootstrap + a plist in ~/Library/LaunchAgents for personal automation, devops tooling, and anything outside an .app bundle.
# List user-approved app-managed services (read-only inspection)
sfltool dumpbtm | head -40
# launchctl still works against SMAppService-registered Labels
launchctl print "gui/$(id -u)/com.example.helper" | head -10
Output: (none — exits 0 on success)
Legacy verbs
Older articles and Stack Overflow answers use the legacy load/unload verbs. They still work but issue deprecation warnings. The mapping is straightforward:
# Legacy load (deprecated)
launchctl load ~/Library/LaunchAgents/com.example.foo.plist
# Modern equivalent
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.example.foo.plist
# Legacy unload (deprecated)
launchctl unload ~/Library/LaunchAgents/com.example.foo.plist
# Modern equivalent
launchctl bootout "gui/$(id -u)/com.example.foo"
# Legacy start (still works)
launchctl start com.example.foo
# Modern equivalent
launchctl kickstart "gui/$(id -u)/com.example.foo"
# Legacy stop (still works)
launchctl stop com.example.foo
# Modern equivalent
launchctl kill SIGTERM "gui/$(id -u)/com.example.foo"
Output: (none — exits 0 on success)
| Legacy | Modern | Notes |
|---|---|---|
launchctl load -w | launchctl enable + launchctl bootstrap | -w persisted the enable bit |
launchctl unload -w | launchctl disable + launchctl bootout | -w persisted the disable bit |
launchctl getrusage | launchctl procinfo | Resource accounting |
launchctl asuser <uid> <cmd> | launchctl bsexec (when needed) | Cross-domain command exec |
Validating a plist
A plist with bad XML or an unknown key will fail to bootstrap with a cryptic error. Validate before you load with plutil:
# Lint the XML
plutil -lint ~/Library/LaunchAgents/com.example.foo.plist
# Show the parsed structure
plutil -p ~/Library/LaunchAgents/com.example.foo.plist
# Convert binary → XML (Apple's plist editor saves binary)
plutil -convert xml1 ~/Library/LaunchAgents/com.example.foo.plist
# Convert XML → binary (smaller on disk, mandatory for some Apple-signed daemons)
plutil -convert binary1 ~/Library/LaunchAgents/com.example.foo.plist
Output (plutil -lint):
/Users/alice/Library/LaunchAgents/com.example.foo.plist: OK
Permissions on the plist file matter for daemons — /Library/LaunchDaemons/*.plist must be root:wheel 644 or launchd refuses to load it.
sudo chown root:wheel /Library/LaunchDaemons/com.example.system-monitor.plist
sudo chmod 644 /Library/LaunchDaemons/com.example.system-monitor.plist
Output: (none — exits 0 on success)
Common pitfalls
Load failed: 5: Input/output error— XML is malformed, a required key is missing, orLabeldoesn't match the filename. Runplutil -lint.Path had bad ownership/permissions on file ...— daemons under/Library/LaunchDaemonsrequireroot:wheel 644. Fix withchown root:wheelandchmod 644.- Service "starts" but the script does nothing —
$PATHis empty under launchd. SetEnvironmentVariables→PATHexplicitly to include/opt/homebrew/bin. StartCalendarIntervalfires twice in a row — the previous schedule was missed (Mac asleep) and launchd is catching up. AddLimitLoadToSessionTypeor use a wrapper script that records the last-run timestamp.KeepAlivecauses infinite restart loop — your program is exiting non-zero and launchd is respawning. Pair withThrottleInterval(default 10s) and checkStandardErrorPath. Or conditionKeepAliveonSuccessfulExit = false.- Old
launchctl load -wdoesn't survive a reboot —-wonly persists the enable bit; the plist must still live in aLaunchAgents/LaunchDaemonsdirectory. bootstrapreturnsBootstrap failed: 37: Operation already in progress— the service is already loaded.bootoutfirst, or justkickstart -kto restart.- System daemon can't read
~/.aws/credentials— daemons run as root and$HOMEis/var/root. Either copy credentials to a system-readable location or use a user agent instead. WatchPathstriggers a flood of runs — the program writes back to the watched path. Move the output elsewhere or useQueueDirectoriesfor FIFO behaviour.- GUI dialogs (
osascript display dialog) don't appear from a daemon — daemons have no GUI session. Use a user agent (~/Library/LaunchAgents) instead. launchctl load com.example.foo.plist(without path) —load/bootstrapneed a file path, not a label. Uselaunchctl kickstartorstartwith the label.- Reboot doesn't run my new agent — the file ownership may be wrong (
chown $(whoami):staff ~/Library/LaunchAgents/*.plist) or the agent isDisabled.
Real-world recipes
A user agent that runs a script every 30 minutes
The canonical "run my backup periodically" recipe. The script just touches a log file so the example is verifiable.
<?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.heartbeat</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>echo "[$(date)] heartbeat" >> "$HOME/Library/Logs/heartbeat.log"</string>
</array>
<key>StartInterval</key>
<integer>1800</integer> <!-- 30 minutes -->
<key>RunAtLoad</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/alice/Library/Logs/heartbeat.out</string>
<key>StandardErrorPath</key>
<string>/Users/alice/Library/Logs/heartbeat.err</string>
</dict>
</plist>
Install and tail the log:
mkdir -p ~/Library/LaunchAgents ~/Library/Logs
# Save the plist
cat > ~/Library/LaunchAgents/com.example.heartbeat.plist <<'PLIST'
... # contents above
PLIST
# Validate
plutil -lint ~/Library/LaunchAgents/com.example.heartbeat.plist
# Bootstrap into the user-GUI domain
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.example.heartbeat.plist
# Confirm
launchctl print "gui/$(id -u)/com.example.heartbeat" | head -15
# Watch it tick
tail -F ~/Library/Logs/heartbeat.log
Output:
[Sun May 25 09:14:02 BST 2026] heartbeat
[Sun May 25 09:44:02 BST 2026] heartbeat
[Sun May 25 10:14:02 BST 2026] heartbeat
A weekday-09:00 backup with StartCalendarInterval
Same shape, but wall-clock-driven and wrapped to capture both stdout and stderr to a single rotating log.
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.weekday-backup</string>
<key>ProgramArguments</key>
<array>
<string>/Users/alice/bin/backup.sh</string>
</array>
<key>StartCalendarInterval</key>
<array>
<dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>2</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>3</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>4</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>5</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/alice/Library/Logs/weekday-backup.log</string>
<key>StandardErrorPath</key>
<string>/Users/alice/Library/Logs/weekday-backup.log</string>
</dict>
</plist>
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.example.weekday-backup.plist
launchctl print "gui/$(id -u)/com.example.weekday-backup"
Output: (none — exits 0 on success)
A "watch this folder and process new files" agent
QueueDirectories is the launchd analogue of inotify-driven file watchers — every time files land in the watched directory, launchd starts the program.
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.inbox-processor</string>
<key>ProgramArguments</key>
<array>
<string>/Users/alice/bin/process-inbox.sh</string>
</array>
<key>QueueDirectories</key>
<array>
<string>/Users/alice/Documents/inbox</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/alice/Library/Logs/inbox-processor.log</string>
<key>StandardErrorPath</key>
<string>/Users/alice/Library/Logs/inbox-processor.err</string>
</dict>
</plist>
The script should drain the directory on each run:
#!/usr/bin/env bash
# process-inbox.sh
set -euo pipefail
inbox=/Users/alice/Documents/inbox
done=/Users/alice/Documents/processed
mkdir -p "$done"
for f in "$inbox"/*; do
[[ -e "$f" ]] || continue
echo "[$(date)] Processing $f"
# ... do work ...
mv "$f" "$done/"
done
Output: (none — exits 0 on success)
A "keep this server up" agent with throttle
A development server that crashes on bad input should restart, but not in a tight loop. Cap respawn to once per minute.
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.dev-server</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/node</string>
<string>/Users/alice/code/server/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/alice/code/server</string>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>60</integer>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin</string>
<key>NODE_ENV</key>
<string>development</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/alice/Library/Logs/dev-server.log</string>
<key>StandardErrorPath</key>
<string>/Users/alice/Library/Logs/dev-server.err</string>
</dict>
</plist>
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.example.dev-server.plist
# Force a restart after editing the script
launchctl kickstart -k "gui/$(id -u)/com.example.dev-server"
# Tail logs
tail -F ~/Library/Logs/dev-server.{log,err}
Output: (none — exits 0 on success)
A system daemon that runs as root
Daemons under /Library/LaunchDaemons run as root and start at boot. Useful for system-monitoring tools that need privileged access. Ownership and mode are strict.
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.system-monitor</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/system-monitor</string>
<string>--interval=60</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/var/log/system-monitor.log</string>
<key>StandardErrorPath</key>
<string>/var/log/system-monitor.err</string>
</dict>
</plist>
Install:
sudo cp com.example.system-monitor.plist /Library/LaunchDaemons/
sudo chown root:wheel /Library/LaunchDaemons/com.example.system-monitor.plist
sudo chmod 644 /Library/LaunchDaemons/com.example.system-monitor.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.system-monitor.plist
sudo launchctl print system/com.example.system-monitor | head -15
Output: (none — exits 0 on success)
Replace a cron job with launchd
launchd is the recommended replacement for cron on macOS — cron is still present but considered deprecated. Translate every cron entry into a StartCalendarInterval plist.
cron MIN HOUR DOM MON DOW | launchd StartCalendarInterval |
|---|---|
30 3 * * * | { Hour=3; Minute=30 } |
0 9 * * 1-5 | array of dicts with Weekday=1..5, Hour=9, Minute=0 |
*/5 * * * * | use StartInterval=300 instead |
0 0 1 * * | { Day=1; Hour=0; Minute=0 } |
@reboot | RunAtLoad=true (in a system daemon for true at-boot semantics) |
A conversion example for 30 3 * * * /Users/alice/bin/nightly.sh:
<dict>
<key>Label</key>
<string>com.example.nightly</string>
<key>ProgramArguments</key>
<array><string>/Users/alice/bin/nightly.sh</string></array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>30</integer>
</dict>
</dict>
Update an existing plist and re-load it
Editing a plist in place doesn't take effect — launchd has the old version cached. bootout + bootstrap is the cleanest reload cycle.
label=com.example.heartbeat
plist=~/Library/LaunchAgents/${label}.plist
target="gui/$(id -u)/${label}"
# Edit the plist
"${EDITOR:-vim}" "$plist"
# Reload
launchctl bootout "$target" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$plist"
launchctl print "$target" | grep state
Output:
state = running
Disable an Apple service without root file edits
When an Apple-shipped daemon is annoying you (a Bluetooth helper that crashes, an analytics service), disable it persistently without touching /System/Library.
sudo launchctl disable system/com.apple.SomeService
sudo launchctl bootout system/com.apple.SomeService
sudo launchctl print-disabled system | grep com.apple.SomeService
Output:
"com.apple.SomeService" => disabled
Re-enable with launchctl enable system/com.apple.SomeService followed by a reboot (or bootstrap).
Build a tiny launchd "lint and load" helper
A wrapper script that lints, optionally bootouts, and bootstraps a plist in one step — drop it in ~/bin so authoring new agents is faster.
#!/usr/bin/env bash
# ll — lint and load a launchd plist
set -euo pipefail
[[ $# -ge 1 ]] || { echo "usage: $0 <plist>"; exit 1; }
plist=$(realpath "$1")
# 1. Lint
plutil -lint "$plist"
# 2. Determine domain
case "$plist" in
*LaunchDaemons*) domain="system"; sudo="sudo" ;;
*LaunchAgents*) domain="gui/$(id -u)"; sudo="" ;;
*) echo "Not a LaunchAgent or LaunchDaemon path"; exit 1 ;;
esac
# 3. Derive label from filename
label=$(basename "$plist" .plist)
# 4. Bootout if already loaded (suppress error if not)
$sudo launchctl bootout "${domain}/${label}" 2>/dev/null || true
# 5. Bootstrap
$sudo launchctl bootstrap "$domain" "$plist"
# 6. Print state
$sudo launchctl print "${domain}/${label}" | head -10
Output: (none — exits 0 on success)
Usage:
ll ~/Library/LaunchAgents/com.example.heartbeat.plist
Output:
/Users/alice/Library/LaunchAgents/com.example.heartbeat.plist: OK
com.example.heartbeat = {
active count = 1
path = /Users/alice/Library/LaunchAgents/com.example.heartbeat.plist
state = running
program = /bin/bash
arguments = {
"/bin/bash"
"-lc"
"echo \"[$(date)] heartbeat\" >> \"$HOME/Library/Logs/heartbeat.log\""
}
runs = 1
Diagnose "why isn't my job running?"
The most common debugging dance. Walk these checks in order.
label=com.example.heartbeat
target="gui/$(id -u)/${label}"
# 1. Is the plist syntactically valid?
plutil -lint ~/Library/LaunchAgents/${label}.plist
# 2. Is it loaded at all?
launchctl print "$target" 2>&1 | head -5
# 3. Is it disabled?
launchctl print-disabled "gui/$(id -u)" | grep "$label" || echo "not disabled"
# 4. What did the last run exit with?
launchctl list | grep "$label"
# 5. Is there stderr output?
tail -50 ~/Library/Logs/heartbeat.err 2>/dev/null
# 6. Try kickstarting it manually
launchctl kickstart -k "$target"
# 7. Watch the unified log stream live
log stream --predicate 'process == "launchd"' --info | grep "$label"
Output: (none — exits 0 on success)
Related cheat sheets
- macos-cli — broader terminal reference
- homebrew —
brew serviceswrapslaunchctlfor formulae - defaults — schedule a launch agent that flips preferences periodically
- cron — the Linux scheduler launchd replaces on macOS
Sources
- ss64 — launchctl — complete enumeration of modern and legacy verbs, with notes on which subcommands are unimplemented.
- Apple — SMAppService — the modern Swift/Obj-C API for app-bundled launch agents, daemons, and login items.
- Apple — Script management with launchd — official end-user guidance on installing scripts as launch agents from Terminal.
- launchd.info — community plist-key reference and tutorial that's still the most thorough single page on the format.
- Apple — What's new for enterprise in macOS Tahoe 26 — Tahoe-era changes to MDM, launchd, and configuration profiles.