macOS modern launchctl subcommands

These are my notes on the modern launchctl subcommands, as used on a user LaunchAgent. launchctl(1) documents them but gives no examples, so this page adds a few.

Every command on this page refers to one example agent through these four variables:

LABEL=dk.thrysoee.my-launch-agent
PLIST=~/Library/LaunchAgents/$LABEL.plist
DOMAIN=gui/$(id -u)
TARGET=$DOMAIN/$LABEL

The service target uses the Label string from inside the plist, not the filename. By convention they match, but only the label counts. Check with plutil -p "$PLIST".

Domains

Modern subcommands take an explicit target. bootstrap, print-disabled and a whole-domain print take a domain target. Everything else takes a service target, which is a domain target with the label appended.

Domain targetWhat it isWhere its jobs live
system/ The root Mach bootstrap. Anyone can read it; changing it needs root. /Library/LaunchDaemons
user/<uid>/ A user's domain. It exists whether or not that user has logged in at the GUI. Agents that opt out of the GUI session with LimitLoadToSessionType
gui/<uid>/ The user's GUI login domain. A convenient spelling of login/<asid>, and the one for a normal LaunchAgent. ~/Library/LaunchAgents
login/<asid>/ The same login domain, addressed by audit session ID instead of by user. n/a
pid/<pid>/ The XPC services one process can reach through xpc_connection_create(3). n/a

For UID 501, gui/501 and gui/$(id -u) name the same domain. Hardcode the number in a one-off; use the substitution in anything you save.

launchctl print gui/501          # fine interactively
launchctl print "gui/$(id -u)"   # portable, use this in scripts

That command is also the readable dump of a whole domain: type, ASID, service counts, inherited environment, and every service in it. The asid field is the number the login/ form takes.

gui/<uid> and user/<uid> are not interchangeable. The man page says the two share a flat Mach bootstrap namespace but hold discrete sets of services, so an agent in gui/501 is absent from user/501.

$ launchctl print user/501/dk.thrysoee.my-launch-agent
Could not find service "dk.thrysoee.my-launch-agent" in domain for uid: 501

Easy to misread, because printing the user domain still lists Mach names registered by GUI-domain services. The names are there. The services are not.

Command reference

The commands below use the four variables from the top of this page.

LABEL
The Label string from inside the plist. launchd identifies the job by this string alone.
PLIST
Filesystem path to the property list. Only bootstrap takes a path. Everything else works from the label.
DOMAIN
The domain target, gui/$(id -u) for a user agent or system for a daemon. The man page adds a trailing slash. Both forms work, and leaving it off keeps $DOMAIN/$LABEL readable.
TARGET
The service target, $DOMAIN/$LABEL. One job in one domain, and what every subcommand except bootstrap expects.
IntentLegacyModern
load launchctl load "$PLIST" launchctl bootstrap "$DOMAIN" "$PLIST"
unload launchctl unload "$PLIST" launchctl bootout "$TARGET"
start (run now) launchctl start "$LABEL" launchctl kickstart "$TARGET"
stop (kill running instance) launchctl stop "$LABEL" launchctl kill TERM "$TARGET"
restart n/a launchctl kickstart -k "$TARGET"
enable launchctl load -w "$PLIST" launchctl enable "$TARGET"
disable launchctl unload -w "$PLIST" launchctl disable "$TARGET"
status launchctl list "$LABEL" launchctl print "$TARGET"
list disabled n/a launchctl print-disabled "$DOMAIN"
reload after editing the plist launchctl unload "$PLIST" && launchctl load "$PLIST" launchctl bootout "$TARGET"; launchctl bootstrap "$DOMAIN" "$PLIST"

Gotchas

Error reporting in legacy and modern subcommands

Error reporting is the practical difference. Legacy subcommands pick their domain from context. Root targets system, everyone else the domain of their current session, and the command does not say which. They also report almost nothing. Compare stop on an agent that is loaded but idle with stop on a label that does not exist.

$ launchctl stop dk.thrysoee.my-launch-agent      # exit 0, silent
$ launchctl stop no.such.job.xyz                  # exit 3, silent

$ launchctl kill TERM "$TARGET"
No process to signal.                             # exit 3

$ launchctl kill TERM "$DOMAIN/no.such.job.xyz"
Could not find service "no.such.job.xyz" in domain for user gui: 501   # exit 113

kill separates "the service exists, nothing is running" from "no such service" and prints which. stop returns 0 for the first and 3 for the second with no output, so a typo in a label looks the same as success.

Example: install an agent that loads at every login

Load a new plist from ~/Library/LaunchAgents into the current session, and have launchd load it again at every login.

LABEL=dk.thrysoee.my-launch-agent
PLIST=~/Library/LaunchAgents/$LABEL.plist
DOMAIN=gui/$(id -u)
TARGET=$DOMAIN/$LABEL

plutil -lint "$PLIST"                    # well-formed plist?
plutil -p "$PLIST" | grep Label          # does the Label match $LABEL?
chmod 644 "$PLIST"

launchctl enable "$TARGET"               # reset any stale disabled record
launchctl bootstrap "$DOMAIN" "$PLIST"   # load into the current login session
launchctl kickstart "$TARGET"            # run it now, skip if RunAtLoad is set

launchctl print "$TARGET"                # verify

Persistence across logout and reboot comes from the file's location, not from any command. launchd scans ~/Library/LaunchAgents at every GUI login and bootstraps what it finds there. bootstrap only affects the current session, which is why you run it once, at install time.

The enable line looks redundant on a fresh install. It is not. Disabled state is keyed by label, lives under /var/db/com.apple.xpc.launchd/, and outlives the plist. If the label was ever disabled, even under a plist deleted months ago, bootstrap fails like this:

$ launchctl bootstrap "$DOMAIN" "$PLIST"
Bootstrap failed: 5: Input/output error
Try re-running the command as root for richer errors.

Nothing in that message mentions the disabled flag. Check with launchctl print-disabled "$DOMAIN" | grep "$LABEL", run launchctl enable "$TARGET", and bootstrap again. Keeping enable in the recipe costs nothing and skips that diagnosis.

For a daemon rather than an agent, the same sequence uses DOMAIN=system, a plist in /Library/LaunchDaemons owned by root:wheel, and sudo on every launchctl line.

Example: stop, disable and remove an agent completely

Stop the agent, block launchd from bootstrapping it at future logins, and delete the plist.

LABEL=dk.thrysoee.my-launch-agent
PLIST=~/Library/LaunchAgents/$LABEL.plist
DOMAIN=gui/$(id -u)
TARGET=$DOMAIN/$LABEL

launchctl kill TERM "$TARGET" 2>/dev/null   # stop a running instance
launchctl disable "$TARGET"                 # persistent, survives reboot
launchctl bootout "$TARGET"                 # unload from the current session
rm "$PLIST"                                 # remove from disk

launchctl print "$TARGET"                   # expect: Could not find service
launchctl print-disabled "$DOMAIN" | grep "$LABEL"

The four steps do not overlap. kill ends the current run. disable records, across reboots, that this label must not be bootstrapped. bootout removes the job from this session. rm stops launchd bootstrapping it at next login. Without bootout the job stays loaded until logout, plist or no plist.

The disabled record survives the rm and blocks any future plist with the same label, which is useful when an installer might reinstall the agent. To clear it instead:

launchctl enable "$TARGET"

That sets the record to enabled rather than deleting it, so the label stays listed in print-disabled. An => enabled entry is inert; enabled is the default for any label without a record.

Example: the full stop, unload, load and start cycle

Use this after editing the plist, or when kickstart -k is not enough and the job has to be unloaded and loaded again. Every step takes a service target except bootstrap, which takes the path.

LABEL=dk.thrysoee.my-launch-agent
PLIST=~/Library/LaunchAgents/$LABEL.plist
DOMAIN=gui/$(id -u)
TARGET=$DOMAIN/$LABEL

launchctl kill TERM "$TARGET" 2>/dev/null   # stop, no-op if not running
launchctl bootout "$TARGET" 2>/dev/null     # unload, no-op if not loaded
launchctl bootstrap "$DOMAIN" "$PLIST"      # load
launchctl kickstart "$TARGET"               # start

Shorter, since bootstrap is synchronous and kickstart -k covers stop-then-start by itself:

launchctl bootout "$TARGET" 2>/dev/null
launchctl bootstrap "$DOMAIN" "$PLIST" && launchctl kickstart -k "$TARGET"

bootout fails when the service is not loaded, where legacy unload did not. The 2>/dev/null hides the message only; the exit status is still non-zero, so under set -e that line also needs || true. If bootstrap returns Bootstrap failed: 37: Operation already in progress, the bootout has not completed yet. Retry:

until launchctl bootstrap "$DOMAIN" "$PLIST"; do sleep 1; done