macOS uses a system called launchd to manage background processes, scheduled tasks, and system services. Unlike Linux, which relies on systemd or cron for these functions, macOS centralises everything through launchd. It is the very first process that starts when your Mac boots (PID 1), and it is responsible for launching every other process on the system.
Understanding how to create custom Launch Agents and Launch Daemons gives you precise control over what runs on your Mac, when it runs, and how it behaves. Whether you need to run a backup script every hour, start a development server at login, or monitor a folder for changes, launchd is the correct macOS-native tool for the job.
The Difference Between Launch Agents and Launch Daemons
macOS distinguishes between two types of launchd jobs:
- Launch Agents run in the context of a logged-in user. They have access to the user’s GUI session, can display notifications, and interact with user-level resources. They load when the user logs in and unload when the user logs out.
- Launch Daemons run at the system level, regardless of whether any user is logged in. They typically run as root and are used for system-wide services such as web servers, file synchronisation engines, or hardware monitoring tools.
The key distinction is scope and timing. If you need a task to run only when you are logged in (e.g., syncing a folder, launching a development tool), use a Launch Agent. If you need a task to run at all times, even before any user logs in (e.g., a database server, a network monitoring tool), use a Launch Daemon.
Where Property List Files Are Stored
Every launchd job is defined by a Property List (plist) file—an XML file that specifies what program to run, when to run it, and how to handle its lifecycle. The location of the plist file determines whether it functions as a Launch Agent or a Launch Daemon:
| Location | Type | Scope | Runs As |
|---|---|---|---|
~/Library/LaunchAgents/ | Launch Agent | Current user only | Current user |
/Library/LaunchAgents/ | Launch Agent | All users | Logged-in user |
/Library/LaunchDaemons/ | Launch Daemon | System-wide | root (or specified user) |
/System/Library/LaunchAgents/ | Launch Agent | System (Apple only) | Logged-in user |
/System/Library/LaunchDaemons/ | Launch Daemon | System (Apple only) | root |
Never modify files in /System/Library/—these are managed exclusively by macOS and are protected by System Integrity Protection (SIP). Place your custom jobs in ~/Library/LaunchAgents/ for per-user agents or /Library/LaunchDaemons/ for system-wide daemons.
How to Create a Basic Launch Agent
This example creates a Launch Agent that runs a shell script every 30 minutes while you are logged in.
Step 1: Create the Script
First, create the shell script that the Launch Agent will execute. Open Terminal and run:
mkdir -p ~/Scripts
nano ~/Scripts/backup-notes.sh
Add the following content (adjust paths as needed):
#!/bin/bash
rsync -av --delete ~/Documents/Notes/ ~/Backups/Notes/
echo "$(date): Notes backup completed" >> ~/Logs/backup-notes.log
Save the file and make it executable:
chmod +x ~/Scripts/backup-notes.sh
mkdir -p ~/Logs ~/Backups/Notes
Step 2: Create the Property List File
Create the plist file in your per-user Launch Agents directory:
nano ~/Library/LaunchAgents/com.username.backup-notes.plist
Add the following XML content:
<?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.username.backup-notes</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/yourusername/Scripts/backup-notes.sh</string>
</array>
<key>StartInterval</key>
<integer>1800</integer>
<key>StandardOutPath</key>
<string>/Users/yourusername/Logs/backup-notes-stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/yourusername/Logs/backup-notes-stderr.log</string>
</dict>
</plist>
Replace yourusername with your actual macOS username. The Label must be unique across all loaded jobs—using reverse-domain notation (e.g., com.yourname.taskname) is the standard convention.
Step 3: Load the Launch Agent
Use launchctl to load and activate the job:
launchctl load ~/Library/LaunchAgents/com.username.backup-notes.plist
The agent is now active and will run backup-notes.sh every 1,800 seconds (30 minutes) while you remain logged in. It will also load automatically at every subsequent login.
Essential Property List Keys Explained
Understanding the available plist keys is essential for creating effective launchd jobs. Here are the most commonly used keys:
| Key | Type | Purpose |
|---|---|---|
Label | String | Unique identifier for the job (required). |
ProgramArguments | Array | The command and its arguments to execute (required). |
RunAtLoad | Boolean | If true, runs the job immediately when loaded. |
StartInterval | Integer | Runs the job every N seconds. |
StartCalendarInterval | Dictionary | Runs the job at a specific time (like cron). |
WatchPaths | Array | Runs the job when a specified file or directory changes. |
QueueDirectories | Array | Runs the job when items appear in a specified directory. |
KeepAlive | Boolean/Dict | Restarts the job if it exits (for persistent services). |
StandardOutPath | String | Redirects stdout to a log file. |
StandardErrorPath | String | Redirects stderr to a log file. |
EnvironmentVariables | Dictionary | Sets environment variables for the job. |
WorkingDirectory | String | Sets the working directory for the job. |
Scheduling Jobs at Specific Times with StartCalendarInterval
The StartCalendarInterval key works similarly to cron but uses a dictionary format. This example runs a script every weekday at 09:00:
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
<key>Weekday</key>
<integer>1</integer>
</dict>
The Weekday key uses 0 for Sunday through 6 for Saturday, matching the standard POSIX convention. If you omit a key (e.g., leave out Weekday), the job runs every day at the specified time. To run at multiple times, use an array of dictionaries:
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>17</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
</array>
This configuration runs the job at both 09:00 and 17:00 every day. If the Mac is asleep or shut down when the scheduled time arrives, launchd will run the job as soon as the machine wakes up or boots, ensuring no scheduled runs are permanently missed.
Triggering Jobs When Files Change with WatchPaths
The WatchPaths key monitors specified files or directories and triggers the job when any change is detected. This is ideal for processing files as they arrive—such as automatically converting images dropped into a folder or uploading documents to a remote server.
<key>WatchPaths</key>
<array>
<string>/Users/yourusername/Dropbox/Incoming/</string>
</array>
WatchPaths detects any filesystem modification within the specified path, including new files, deletions, renames, and content changes. This makes it significantly more responsive than polling-based solutions.
Creating a Persistent Service with KeepAlive
For services that must remain running at all times (e.g., a local development server or a monitoring agent), the KeepAlive key instructs launchd to automatically restart the process if it crashes or exits.
<key>KeepAlive</key>
<true/>
For more granular control, KeepAlive can accept a dictionary instead of a simple boolean. For example, to restart only on non-zero exit codes (i.e., only when the process crashes, not when it exits successfully):
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
Managing Launch Agents and Daemons with launchctl
The launchctl command is the primary tool for interacting with launchd. Here are the essential commands:
| Command | Purpose |
|---|---|
launchctl load <path> | Load and register a job. |
launchctl unload <path> | Unload and deregister a job. |
launchctl start <label> | Manually trigger a loaded job immediately. |
launchctl stop <label> | Stop a running job. |
launchctl list | List all loaded jobs and their status. |
launchctl list <label> | Show detailed status of a specific job. |
To check if your job loaded correctly, run:
launchctl list | grep com.username.backup-notes
The output shows three columns: the PID (or - if not currently running), the last exit status (0 means success), and the job label. If the exit status is non-zero, check the stderr log file for error messages.
Troubleshooting Common Issues
- Job does not load: Verify the plist syntax using
plutil -lint ~/Library/LaunchAgents/com.username.backup-notes.plist. Even a single misplaced character in the XML will cause the entire file to be rejected. - Permission denied errors: Ensure the script has execute permissions (
chmod +x). For Launch Daemons in/Library/LaunchDaemons/, the plist file must be owned by root:wheel with permissions 644 (sudo chown root:wheelandsudo chmod 644). - Script runs but produces no output: Always configure
StandardOutPathandStandardErrorPathto capture output. Without these keys, all output is silently discarded. - Environment variables missing:
launchddoes not source your shell profile (.zshrc,.bash_profile). If your script depends on custom environment variables or PATH entries, set them explicitly using theEnvironmentVariableskey in the plist, or use full absolute paths to all commands within the script. - Job runs at wrong times after sleep:
launchdwill execute missedStartCalendarIntervaljobs when the Mac wakes from sleep. This is normally desirable but can cause unexpected behaviour if your script is not idempotent.
Security Considerations
Launch Agents and Daemons are a frequent persistence mechanism used by malware on macOS. Regularly audit your Launch Agent and Daemon directories to ensure that only legitimate jobs are installed. You can list all currently loaded launchd jobs with:
launchctl list
Review any unfamiliar labels. Apple’s own jobs typically start with com.apple., while third-party applications use their own reverse-domain prefixes. Any job with an unusual or obfuscated label warrants investigation.
For Launch Daemons, always use the principle of least privilege. If your daemon does not need root access, specify a UserName key in the plist to run it as a non-privileged user. This limits the potential damage if the daemon is compromised.