How to Create Custom Launch Agents and Daemons Using launchd on macOS

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:

LocationTypeScopeRuns As
~/Library/LaunchAgents/Launch AgentCurrent user onlyCurrent user
/Library/LaunchAgents/Launch AgentAll usersLogged-in user
/Library/LaunchDaemons/Launch DaemonSystem-wideroot (or specified user)
/System/Library/LaunchAgents/Launch AgentSystem (Apple only)Logged-in user
/System/Library/LaunchDaemons/Launch DaemonSystem (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:

KeyTypePurpose
LabelStringUnique identifier for the job (required).
ProgramArgumentsArrayThe command and its arguments to execute (required).
RunAtLoadBooleanIf true, runs the job immediately when loaded.
StartIntervalIntegerRuns the job every N seconds.
StartCalendarIntervalDictionaryRuns the job at a specific time (like cron).
WatchPathsArrayRuns the job when a specified file or directory changes.
QueueDirectoriesArrayRuns the job when items appear in a specified directory.
KeepAliveBoolean/DictRestarts the job if it exits (for persistent services).
StandardOutPathStringRedirects stdout to a log file.
StandardErrorPathStringRedirects stderr to a log file.
EnvironmentVariablesDictionarySets environment variables for the job.
WorkingDirectoryStringSets 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:

CommandPurpose
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 listList 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:wheel and sudo chmod 644).
  • Script runs but produces no output: Always configure StandardOutPath and StandardErrorPath to capture output. Without these keys, all output is silently discarded.
  • Environment variables missing: launchd does not source your shell profile (.zshrc, .bash_profile). If your script depends on custom environment variables or PATH entries, set them explicitly using the EnvironmentVariables key in the plist, or use full absolute paths to all commands within the script.
  • Job runs at wrong times after sleep: launchd will execute missed StartCalendarInterval jobs 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.

Get the best tech tips delivered straight to your inbox.

Join thousands of readers mastering Apple, Google, Microsoft, and Linux.