launchd - Script Management in macOS
Edit - Oct 24, 2023
After upgrading to macOS Sonoma, my custom agent for automatically syncing some directories on my machine using rsync broke, due to what I believe to be a permissions issue. For this reason, I revisited this topic and learned more about this system in order to see if I could get around these permissions issues. This included adding more notes in general as I read through man pages and other articles.
Further, the original authoring of this document usually referred to the legacy use of launchctl, sometimes called launchctl 1. The updates here try to incorporate “launchctl 2” subcommands and usage.
What is it?
- Used on macOS for managing agents and daemons and can be used to run scripts at specified intervals
- macOS’s competitor to
cron, along with other things
- macOS’s competitor to
- Runs Daemons and Agents
- During boot,
launchdis invoked by the kernel to run as the first process on the system and to further bootstrap the rest of the system
What is a Daemon?
A daemon is a program running in the background without requiring user input.
- Used to perform daily maintenance tasks or do something when an event occurs at the OS level, like when a device is connected.
- Runs on behalf of the
rootuser of the machine - These should NOT attempt to display UI or interact directly with a user’s login session
- Any work that involves interacting with a user should be done through “agents”
What is an Agent?
- Basically the same thing as a daemon, but runs on behalf of the logged-in user, not the
rootuser
How to use it?
- Important Distinction: You don’t interact with
launchddirectly- Use
launchctlto load or unload daemons and agents instead
- Use
- Steps to set up an agent or daemon:
- Create a program that you want to run in the background
- Create a
.plistfile describing the job to run (See below for how to author one) - Store it in the relevant spot based on whether or not you’re creating a daemon or an agent, and why type of agent or daemon that you want to include (see screenshot below)
- Use
launchctlto load the job and set it to run- Note: See the section below on how to interact with
launchctland its various subcommands - Note that you only have to load jobs once when you first author / install the service. Upon reboot / login all agents & daemons will be loaded and run according to the
.plistrunning commands
- Note: See the section below on how to interact with

launchctl Subcommand Domains, Services, and Endpoints
Many subcommands in
launchctltake a specifier which indicates the target domain or service for the subcommand
Terminology
- Domain: Manages the execution policy for a collection of services
- Advertise endpoints in a shared namespace
- Service: virtual process that is always available to be spawned in respond to a demand
- Endpoint: Each service has a collection of endpoints
- Sending a message to one of those endpoints will cause the service to launch on demand
Target Domains
system/[service-name]- Targets the system domain or a service within the system domain
- Manages the root Mac boostrap and is considered a privileged execution context
rootprivileges are required to make modifications
user/<uid>/[service-name]- Targets the user domain for the given UID or a service within that domain
- User domain may exist independently of a logged-in user
- User domain do not exist on iOS
login/<asid>/[service-name]- Targets a user-login domain or service within that domain
- Created when the user logs in via the GUI
- Identified by the audit session identifier
- User-login domains do not exist on iOS
gui/<uid>/[service-name]- Another form of the login specifier
- Rather than specifying the domain by its ASID (audit session identifier)
- This targets the domain based on which user it is associated with and is generally more convenient
- Note: Usually you use
gui/501here for your logged in user, but you can access your user ID from runningecho $UIDin your terminal to find the exact number to use
pid/<pid>/[service-name]- Targets the domain for the given PID or service within that domain
Important Subcommands
Note: There are a number of subcommands listed in documentation that are now considered “legacy”, like load, unload, start, stop, list, etc… Below is the list of updated subcommands and their uses
Other Important Note: [service-target] (seen below in many commands) takes the form of <domain-target>/<service-id>
bootstrap <domain-target> [service-path]- Bootstraps domains and services
- Basically this is what you should use instead of traditional
loadcalls - Ex.
launchctl bootstrap gui/501 ~/Library/LaunchAgents/dev.johnturner.ObsidianFoamReconciler.plist
kickstart [service-target]- Instructs
launchdto run the specified service immediately - Replaces traditional
startcommands - Ex.
launchctl kickstart gui/501/dev.johnturner.ObsidianFoamReconciler
- Instructs
enable [service-target]: Enables the service in the requested domain- This state persists across boots of the device
- May only target services within the system domain or user and user-login domains
disable [service-target]: Disables the service in the requested domain- This state persists across boots of the device
- Once disabled, a service cannot be loaded in the domain until it is once again enabled
- May only target services within the system domain or user and user-login domains
How to write a .plist file
The majority of the learning that I needed in order to run my background job came from the first link in the Further Reading section below
- A
.plistfile is valid for loading intolaunchdwith just theLabelandProgramorProgramArgumentsattributes, but the background job won’t run, since it doesn’t know when to invoke it - It’s an
xmldocument - Some key attributes to include:
Label(required): The name of your job, should be unique and follow “reverse domain” naming conventionProgram(required ifProgramArgumentsisn’t present): The path to the executable on your systemProgramArguments(required ifProgramisn’t present): Array of string arguments including the path to your executable and any other arguments- All strings are concatenated together with spaces between them to make up the full command
ServiceDescription: Human-readable description of your serviceWorking Directory: Can set the working directory when your program runsRunAtLoad: Should we run the job at boot time (for daemons) / login time (agents)?StartCalendarInterval: Dictionary used to specifycron-like running intervals, like “run this every day at 3:00 AM”- Available keys:
MonthDayWeekdayHourMinute
- Important Note: omitted keys are interpreted as
*
- Available keys:
StartInterval: Used to run the background job every n secondsStandardErrorPath: Useful for indicating a specific log file location for when errors arise in your serviceEnvironmentVariables: Allows you to set different environment variables that can be accessed as part of your program- Common entries here include setting your
PATHwhen running different scripts.- Given that your service is running outside of the context of loading a terminal / shell session, your shims and other enhancements usually found in a
.bashrc,.bash_profile, or.zshrcfile won’t be loaded; therefore you have to load those up here or your service might not run as expected
- Given that your service is running outside of the context of loading a terminal / shell session, your shims and other enhancements usually found in a
- Common entries here include setting your
Example .plist file
This is an example .plist file that I use to run a background job to sync my Obsidian-based Second Brain to the git-based version of the repo, which then runs some formatting and other processes.
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.johnturner.ObsidianFoamReconciler</string>
<key>ServiceDescription</key>
<string>Regular sync between Obsidian Second Brain vault and my git-based Second Brain</string>
<key>Program</key>
<string>/Users/johnturner/second-brain/sync-from-obsidian.sh</string>
<key>WorkingDirectory</key>
<string>/Users/johnturner/second-brain</string>
<key>StandardErrorPath</key>
<string>/Users/johnturner/Desktop/reconciler-error.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>
/usr/local/opt/gettext/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
</string>
</dict>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>11</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>RunAtLoad</key>
<false />
</dict>
</plist>
Misc. Notes
- According to Apple’s documentation, “the appropriate location for executables that you launch from your job is
/usr/local/libexec”- Though, as seen in the example above, you can specify a different program location and working directory
Further Reading
launchdTutorial- Apple’s Intro to
launchd - Daemons and Services Programming Guide
- Medium Post on Setting up
launchdAgents and Daemons - man pages for
launchd - man pages for
launchctl - launchctl/launchd cheatsheet