Skip to content

Instantly share code, notes, and snippets.

@possibilities
Created February 28, 2026 19:27
Show Gist options
  • Select an option

  • Save possibilities/14200eb9dcf164f96eec8b628ded542e to your computer and use it in GitHub Desktop.

Select an option

Save possibilities/14200eb9dcf164f96eec8b628ded542e to your computer and use it in GitHub Desktop.
macOS LaunchAgents tutorial - scheduling, keepalive, sockets, and practical patterns

macOS LaunchAgents

LaunchAgents are macOS's built-in system for running background tasks. They replace cron, are more capable, and integrate deeply with the OS. Every Mac has hundreds of them running right now.

The Mental Model

launchd is PID 1 on macOS — the first process, parent of everything. You give it a job description (a plist file), and it manages the lifecycle: starting, stopping, restarting, scheduling, and resource management.

Two flavors:

  • LaunchAgents — run as your user, in your login session. Can access your files, GUI, keychain.
  • LaunchDaemons — run as root (or another user), at system boot, no GUI access.

For personal automation, you almost always want LaunchAgents.

Where Plists Live

Directory Who When Loaded
~/Library/LaunchAgents/ You, for you At your login
/Library/LaunchAgents/ Admin, for all users At each user's login
/Library/LaunchDaemons/ Admin, system-wide At boot (as root)
/System/Library/Launch* Apple only Read-only, don't touch

Anatomy of a Plist

A plist is an XML file describing one job. The filename should match the Label (e.g., local.my-task.plist).

<?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>local.my-task</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/mike/.local/bin/my-script</string>
    <string>--verbose</string>
  </array>

  <key>RunAtLoad</key>
  <true/>

  <key>StartInterval</key>
  <integer>300</integer>
</dict>
</plist>

Only two keys are truly required: Label and either Program or ProgramArguments.

Scheduling: When Jobs Run

Run once at login

<key>RunAtLoad</key>
<true/>

Run every N seconds

<key>StartInterval</key>
<integer>1200</integer>  <!-- every 20 minutes -->

If the Mac is asleep when the interval fires, that run is skipped. The timer resets when the machine wakes.

Run on a calendar schedule (cron-style)

<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>
  <integer>4</integer>
  <key>Minute</key>
  <integer>0</integer>
</dict>

Available keys: Minute (0-59), Hour (0-23), Day (1-31), Weekday (0=Sun, 7=Sun), Month (1-12). Omit a key to mean "any" — so the above runs daily at 4:00 AM.

Unlike cron, if the Mac was asleep during a scheduled time, launchd fires the job on wake. Multiple missed intervals coalesce into one firing.

Multiple schedules:

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

Run when a file changes

<key>WatchPaths</key>
<array>
  <string>/path/to/watched/file</string>
</array>

Job starts whenever any listed path is modified. Useful for triggering actions on config changes. Note: filesystem watching is inherently racy — modifications can occasionally be missed.

Run when a directory has contents

<key>QueueDirectories</key>
<array>
  <string>/path/to/queue/</string>
</array>

Job stays alive as long as the directory is non-empty. Great for work-queue patterns — drop a file in, job processes it, deletes it, goes back to sleep.

Run when a disk mounts

<key>StartOnMount</key>
<true/>

Combine triggers

You can use multiple triggers together. A job with both RunAtLoad and StartInterval runs immediately at login, then repeats on the interval.

Keeping Jobs Alive

For long-running services (not one-shot scripts), use KeepAlive:

<!-- Always restart if it exits -->
<key>KeepAlive</key>
<true/>

Or conditionally:

<key>KeepAlive</key>
<dict>
  <!-- Restart only if it crashes (non-zero exit) -->
  <key>SuccessfulExit</key>
  <false/>
</dict>
<key>KeepAlive</key>
<dict>
  <!-- Only run while this file exists -->
  <key>PathState</key>
  <dict>
    <key>/tmp/my-service-enabled</key>
    <true/>
  </dict>
</dict>

KeepAlive implies RunAtLoad. Jobs that exit quickly and repeatedly get throttled (default: at most once per 10 seconds).

Environment and I/O

Set environment variables

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
  <key>MY_CONFIG</key>
  <string>/path/to/config</string>
</dict>

Important: launchd runs jobs with a minimal environment — not your shell profile. If your script needs Homebrew tools, set PATH explicitly either in the plist or in the script itself.

Redirect stdout/stderr to log files

<key>StandardOutPath</key>
<string>/tmp/my-task.log</string>
<key>StandardErrorPath</key>
<string>/tmp/my-task.log</string>

Set working directory

<key>WorkingDirectory</key>
<string>/Users/mike/code/myproject</string>

Process Control

Graceful shutdown timeout

When launchd stops a job, it sends SIGTERM first, then SIGKILL after a timeout:

<key>ExitTimeOut</key>
<integer>30</integer>  <!-- seconds before SIGKILL -->

Throttle interval

Jobs that exit quickly get throttled. Override the default 10-second cooldown:

<key>ThrottleInterval</key>
<integer>60</integer>  <!-- minimum 60s between launches -->

Resource limits

<key>SoftResourceLimits</key>
<dict>
  <key>NumberOfFiles</key>
  <integer>4096</integer>
</dict>
<key>HardResourceLimits</key>
<dict>
  <key>NumberOfFiles</key>
  <integer>8192</integer>
</dict>

Nice value (scheduling priority)

<key>Nice</key>
<integer>10</integer>  <!-- lower priority, be gentle on CPU -->

Process type (energy management)

<key>ProcessType</key>
<string>Background</string>  <!-- Background, Standard, Adaptive, Interactive -->

Background jobs get deprioritized for CPU and I/O — good for maintenance tasks.

Advanced Features

On-demand sockets

launchd can listen on a socket and only launch your job when a connection arrives:

<key>Sockets</key>
<dict>
  <key>MyService</key>
  <dict>
    <key>SockServiceName</key>
    <string>8080</string>
    <key>SockType</key>
    <string>stream</string>
  </dict>
</dict>

The job calls launch_activate_socket() to receive the file descriptor. This is how Apple implements many system services — zero resource usage until needed.

Launch events (IOKit matching)

Start jobs based on hardware events — USB device plugged in, Bluetooth device appears, etc:

<key>LaunchEvents</key>
<dict>
  <key>com.apple.iokit.matching</key>
  <dict>
    <key>USBDevice</key>
    <dict>
      <key>idVendor</key>
      <integer>1234</integer>
      <key>idProduct</key>
      <integer>5678</integer>
      <key>IOMatchLaunchStream</key>
      <true/>
    </dict>
  </dict>
</dict>

Hardware-specific loading

Only load on certain hardware:

<key>LimitLoadToHardware</key>
<dict>
  <key>model</key>
  <array>
    <string>MacBookPro18,1</string>
  </array>
</dict>

Managing Jobs with launchctl

Load and unload

# Load (start managing the job)
launchctl load ~/Library/LaunchAgents/local.my-task.plist

# Unload (stop managing, kills the process)
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/local.my-task.plist

List running jobs

# All jobs (huge list)
launchctl list

# Find yours
launchctl list | grep local.

# Output: PID  ExitCode  Label
#         1234  0        local.my-task      (running, PID 1234)
#         -     0        local.other-task   (idle, last exit 0)
#         -     1        local.broken-task  (idle, last exit 1 = error)

Kick a job (run it now)

# Run immediately (even if not scheduled yet)
launchctl kickstart gui/$(id -u)/local.my-task

# Kill and restart
launchctl kickstart -k gui/$(id -u)/local.my-task

Validate a plist

plutil -lint ~/Library/LaunchAgents/local.my-task.plist

Patterns and Tips

PATH is the #1 gotcha

launchd gives jobs a bare-bones PATH (/usr/bin:/bin:/usr/sbin:/sbin). Your script works in Terminal but fails as a LaunchAgent? It can't find Homebrew, cargo, or anything in /opt/homebrew/bin. Always set PATH:

#!/bin/bash
PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$HOME/.local/bin"

Don't fork or daemonize

launchd manages the process lifecycle. If your script forks and the parent exits, launchd thinks it crashed and may restart it. Just run in the foreground.

Use reverse-DNS labels

Convention: local.my-task for personal, com.company.task for organizational. The label should match the filename.

Scripts vs. binaries

ProgramArguments works with anything executable — shell scripts, Python, compiled binaries. Just make sure:

  • The file is executable (chmod +x)
  • The shebang works without your shell profile (#!/bin/bash, not #!/usr/bin/env zsh if zsh isn't on the default PATH)

Testing workflow

# 1. Write the plist
vim ~/Library/LaunchAgents/local.my-task.plist

# 2. Validate
plutil -lint ~/Library/LaunchAgents/local.my-task.plist

# 3. Load
launchctl load ~/Library/LaunchAgents/local.my-task.plist

# 4. Test-fire
launchctl kickstart gui/$(id -u)/local.my-task

# 5. Check results
launchctl list | grep my-task
cat /tmp/my-task.log

# 6. After editing the plist, reload:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/local.my-task.plist
launchctl load ~/Library/LaunchAgents/local.my-task.plist

LaunchAgent vs. Alternatives

Tool Best For
LaunchAgent Anything recurring, persistent, or event-driven. The native way.
cron Still works on macOS but deprecated. No wake-from-sleep, no socket activation.
Automator/Shortcuts GUI-driven, good for simple one-offs. Less control.
Login Items Just "run this app at login." No scheduling, no restart.
process-compose Multi-process orchestration (like docker-compose for local services). Complementary, not a replacement.

LaunchAgents are the foundation. Everything else is either a subset or builds on top.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment