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.

#macos#sysadmin#servicesupdated 05-26-2026

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.

bash
which launchctl
launchctl version
sw_vers -productVersion

Output:

text
/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.

bash
# Modern
launchctl <verb> <domain-target | service-target> [plist-or-args]

# Legacy
launchctl <verb> <plist-path>

Output: (none — exits 0 on success)

Essential verbs

VerbEraMeaning
bootstrapmodernLoad a plist into a domain (validates and registers)
bootoutmodernUnload a service from a domain
enablemodernMark a service as enabled (persists across reboots)
disablemodernMark a service as disabled (persists across reboots)
kickstartmodernForce-start a registered service immediately; -k to kill+restart
printmodernDump the live state of a domain or service (rich diagnostic output)
print-disabledmodernList services that are explicitly disabled in a domain
dumpstatemodernWrite a complete launchd state dump to /tmp/launchd-statedump.txt
listlegacyOne-line-per-service overview (still widely used)
loadlegacyEquivalent to bootstrap for plist files (deprecated)
unloadlegacyEquivalent to bootout (deprecated)
start / stoplegacyStart or stop a registered service (still works)
setenv / getenv / unsetenvmodernEnvironment variables visible to launchd's children
procinfomodernDetailed info on one PID (resource limits, IORegistry entries)
examinemodernOpen the launchd state in a viewer
limitmodernInspect/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.

DirectoryKindRuns asLoaded when
~/Library/LaunchAgentsuser agentthe logged-in user (you)you log in
/Library/LaunchAgentssystem-installed user agenteach logged-in user, individuallyany user logs in
/Library/LaunchDaemonssystem daemonrootboot, before login
/System/Library/LaunchAgentsApple-shipped user agenteach logged-in userlogin
/System/Library/LaunchDaemonsApple-shipped system daemonrootboot

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.

bash
ls -1 ~/Library/LaunchAgents     2>/dev/null
ls -1 /Library/LaunchAgents      2>/dev/null
ls -1 /Library/LaunchDaemons     2>/dev/null

Output:

text
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
<?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:

KeyTypePurpose
LabelstringUnique identifier — also the filename
ProgramstringSingle-binary path (use this OR ProgramArguments)
ProgramArgumentsarrayargv (path is [0])
RunAtLoadboolRun immediately when bootstrapped/loaded
KeepAlivebool or dictRestart on exit (unconditionally or per-condition)
StartIntervalintegerRun every N seconds (after the previous run completes)
StartCalendarIntervaldict or arrayCron-like schedule (minute/hour/day/weekday/month)
StartOnMountboolRun whenever a volume mounts
WatchPathsarrayRun when listed files/dirs change
QueueDirectoriesarrayRun when listed dirs become non-empty
EnvironmentVariablesdictEnv to pass to the program
WorkingDirectorystringchdir to this before launch
StandardOutPathstringRedirect stdout
StandardErrorPathstringRedirect stderr
UmaskintegerDefault file-creation mask
UserName / GroupNamestringDrop privileges (daemons only)
Niceintegernice value for the process
LowPriorityIOboolsetiopolicy_np low priority
ProcessTypestringBackground / Standard / Interactive / Adaptive
LimitLoadToSessionTypestringAqua (GUI), Background, LoginWindow
MachServicesdictXPC / Mach service names this job vends
SocketsdictInherited sockets (inetd-style on-demand)
DisabledboolSet true to make launchctl enable required first
ExitTimeOutintegerSeconds to wait before SIGKILL during shutdown
ThrottleIntervalintegerMinimum 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).

bash
# 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:

text
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.

bash
# 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:

text
disabled services = {
    "com.example.daily-backup" => disabled
    "com.apple.someoldservice" => disabled
}

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.

bash
# 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):

text
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.

bash
launchctl list | head -10
launchctl list | grep example
launchctl list | grep -v com.apple

Output:

text
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.

xml
<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.

xml
<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.

xml
<!-- 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).

xml
<!-- 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.

xml
<!-- 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".

xml
<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.

bash
launchctl setenv LANG "en_US.UTF-8"
launchctl getenv LANG
launchctl unsetenv LANG

Output:

text
en_US.UTF-8

To inspect a running launch agent's environment, attach via lsof + procinfo:

bash
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.

DomainTarget stringWho 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
Systemsystemroot, no GUI
Per-sessionsession/<asid>one specific audit session
One PIDpid/<pid>inspect-only target for a running process

A service target extends a domain with the service Label:

bash
gui/501/com.example.daily-backup
user/501/com.example.always-on
system/com.example.system-monitor

Output: (none — exits 0 on success)

bash
# 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.

swift
// 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.

bash
# 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:

bash
# 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)

LegacyModernNotes
launchctl load -wlaunchctl enable + launchctl bootstrap-w persisted the enable bit
launchctl unload -wlaunchctl disable + launchctl bootout-w persisted the disable bit
launchctl getrusagelaunchctl procinfoResource 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:

bash
# 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):

text
/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.

bash
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

  1. Load failed: 5: Input/output error — XML is malformed, a required key is missing, or Label doesn't match the filename. Run plutil -lint.
  2. Path had bad ownership/permissions on file ... — daemons under /Library/LaunchDaemons require root:wheel 644. Fix with chown root:wheel and chmod 644.
  3. Service "starts" but the script does nothing$PATH is empty under launchd. Set EnvironmentVariablesPATH explicitly to include /opt/homebrew/bin.
  4. StartCalendarInterval fires twice in a row — the previous schedule was missed (Mac asleep) and launchd is catching up. Add LimitLoadToSessionType or use a wrapper script that records the last-run timestamp.
  5. KeepAlive causes infinite restart loop — your program is exiting non-zero and launchd is respawning. Pair with ThrottleInterval (default 10s) and check StandardErrorPath. Or condition KeepAlive on SuccessfulExit = false.
  6. Old launchctl load -w doesn't survive a reboot-w only persists the enable bit; the plist must still live in a LaunchAgents / LaunchDaemons directory.
  7. bootstrap returns Bootstrap failed: 37: Operation already in progress — the service is already loaded. bootout first, or just kickstart -k to restart.
  8. System daemon can't read ~/.aws/credentials — daemons run as root and $HOME is /var/root. Either copy credentials to a system-readable location or use a user agent instead.
  9. WatchPaths triggers a flood of runs — the program writes back to the watched path. Move the output elsewhere or use QueueDirectories for FIFO behaviour.
  10. GUI dialogs (osascript display dialog) don't appear from a daemon — daemons have no GUI session. Use a user agent (~/Library/LaunchAgents) instead.
  11. launchctl load com.example.foo.plist (without path)load/bootstrap need a file path, not a label. Use launchctl kickstart or start with the label.
  12. Reboot doesn't run my new agent — the file ownership may be wrong (chown $(whoami):staff ~/Library/LaunchAgents/*.plist) or the agent is Disabled.

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
<?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:

bash
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:

text
[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
<?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>
bash
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
<?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:

bash
#!/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
<?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>
bash
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
<?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:

bash
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 DOWlaunchd StartCalendarInterval
30 3 * * *{ Hour=3; Minute=30 }
0 9 * * 1-5array of dicts with Weekday=1..5, Hour=9, Minute=0
*/5 * * * *use StartInterval=300 instead
0 0 1 * *{ Day=1; Hour=0; Minute=0 }
@rebootRunAtLoad=true (in a system daemon for true at-boot semantics)

A conversion example for 30 3 * * * /Users/alice/bin/nightly.sh:

xml
<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.

bash
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:

text
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.

bash
sudo launchctl disable system/com.apple.SomeService
sudo launchctl bootout system/com.apple.SomeService
sudo launchctl print-disabled system | grep com.apple.SomeService

Output:

text
"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.

bash
#!/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:

bash
ll ~/Library/LaunchAgents/com.example.heartbeat.plist

Output:

text
/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.

bash
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)

  • macos-cli — broader terminal reference
  • homebrewbrew services wraps launchctl for 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.