ASK KNOX
beta
LESSON 21

launchd over Cron: The Persistent Service Architecture

Cron schedules execution. launchd manages the entire service lifecycle — it starts the service, monitors it, and restarts it when it crashes. A cron job that crashes at 2am runs nothing the next time. A launchd daemon is back within seconds.

8 min read·Infrastructure

There is a failure mode that kills more automated systems than any bug: the scheduled job that crashes silently, runs nothing, and tells no one.

Cron does not know your job crashed. It does not care. It fires the next run at the scheduled time whether the last run succeeded, failed, or never completed. If your job depends on a previous successful run — a database that was populated, a file that was created, a service that needs to be healthy — cron gives you no mechanism to detect or recover from that dependency being broken.

What Cron Actually Does

Cron is a time-based scheduler. It looks at a table of schedules and fires commands at the right times. That is the entire contract. It has no awareness of whether the previous run succeeded. It has no ability to restart a crashed process. It keeps no record of run outcomes beyond whatever the command itself wrote to stdout.

For simple, stateless, fire-and-forget tasks — database backups that are not time-critical, log rotation, periodic cleanup — cron is sufficient. The job runs, the job ends, cron schedules the next one.

For anything that needs to be , cron is the wrong tool.

What launchd Actually Does

launchd is macOS's service supervisor. It manages the full lifecycle of a process: start it on schedule, monitor it while running, detect if it exits unexpectedly, and restart it automatically.

The difference is fundamental. Cron fires a command. launchd owns a service.

When the Agent Gateway platform watchdog crashes at 3am due to a transient network error — which happens in any real production system — launchd detects the exit, waits a brief interval, and restarts the daemon. The downtime is measured in seconds, not until the next cron interval. The monitoring infrastructure that needs to be monitoring other things stays up.

A cron equivalent would be unmonitored for the entire interval — potentially hours — until the next scheduled run fires.

The Plist Pattern

Every persistent service gets a .plist file in ~/Library/LaunchAgents/. The plist is the full service definition:

<?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.operator.watchdog</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/python3</string>
    <string>/Users/operator/.config/agent/watchdog.py</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/bin:/usr/local/bin</string>
  </dict>
  <key>StandardOutPath</key>
  <string>/Users/operator/.config/agent/logs/watchdog.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/operator/.config/agent/logs/watchdog.err</string>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
</dict>
</plist>

KeepAlive: true is the key. It tells launchd: if this process exits for any reason, restart it. That single flag is the difference between a scheduled job and a managed service.

One shape per service — do not mix them. The plist above is a persistent daemon: KeepAlive: true, no calendar schedule, launchd keeps it running continuously. A periodic job is the opposite shape: it uses StartCalendarInterval and omits KeepAlive entirely:

<!-- Periodic job: runs at the top of every hour, exits, waits for the next interval -->
<key>StartCalendarInterval</key>
<dict>
  <key>Minute</key>
  <integer>0</integer>
</dict>
<!-- No KeepAlive. -->

Combining the two breaks both: KeepAlive: true on a script that exits makes launchd respawn it immediately and forever (throttled to roughly every 10 seconds), which nullifies the calendar schedule and turns your hourly job into a continuous respawn loop.

The macOS TCC Rule

There is a critical gotcha for macOS launchd users on Apple Silicon: using the system Python (/usr/bin/python3) in launchd plists triggers TCC (Transparency, Consent, Control) permission requests. TCC governs access to protected resources like the filesystem, network, and microphone — and when a launchd daemon hits a TCC gate without an active user session to approve it, the access is silently denied.

The fix is simple: always use Homebrew Python at /opt/homebrew/bin/python3 in your plist ProgramArguments. The reason is not that Homebrew binaries skip TCC — protected paths are gated regardless of binary origin — but that a user-installed binary can be registered as its own responsible process and granted access, while Apple's SIP-protected system binary has no grantable identity in the launchd context and is therefore denied. Grant the Homebrew binary the access it needs and it works. Also include an EnvironmentVariables block with PATH starting at /opt/homebrew/bin so any subprocess calls also resolve Homebrew tools first.

This matters because a service that launches successfully but fails silently due to TCC permission denial is harder to debug than a service that never starts. The log will show it ran. The outcome will show it did nothing. TCC is the invisible wall.

The env -u CLAUDECODE Prefix

When triggering cron or launchd jobs from within a Claude Code session, prepend env -u CLAUDECODE to the command. Without this, some tools detect the CLAUDECODE environment variable and behave differently — deferring actions, logging differently, or skipping steps that assume interactive operation.

env -u CLAUDECODE python3 /path/to/script.py — this unsets the variable for the child process, ensuring the script runs in its normal, non-agent-context mode. This is not optional when triggering crons from Claude Code. It is a reliability requirement.

The launchd daemon does not wait for perfect conditions. It executes, detects failure, and restores. That bias for action under adversity is exactly what persistent services require.

The Logging Pattern

Each plist specifies StandardOutPath and StandardErrorPath. Logs accumulate at known, predictable paths. This is not a suggestion — it is an operational requirement.

When something goes wrong with an automated system at 2am, the first question is always: what happened? If logs are scattered across temp files, piped to /dev/null, or simply not configured, you have no answer. If every service writes to a known path, you have a complete audit trail.

The pattern: ~/.config/agent/logs/<service-name>.log and ~/.config/agent/logs/<service-name>.err. Consistent. Findable. Structured.

Loading and Managing Services

# Load a new service (legacy subcommand — still works)
launchctl load ~/Library/LaunchAgents/com.operator.watchdog.plist

# Unload — stop and disable (legacy subcommand — still works)
launchctl unload ~/Library/LaunchAgents/com.operator.watchdog.plist

# Modern equivalents (Apple's preferred domain-targeted forms):
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.operator.watchdog.plist
launchctl bootout   gui/$(id -u)/com.operator.watchdog

# Check status
launchctl list | grep operator

# Run immediately (without waiting for schedule)
launchctl kickstart -k gui/$(id -u)/com.operator.watchdog

load/unload are the legacy interface; Apple now favors the domain-targeted bootstrap/bootout forms (the same family as kickstart). Both still function on current macOS — mix them knowingly, not accidentally.

Once loaded, launchd owns the service. It will survive terminal sessions, survive sleep, and survive most crashes — unless the machine itself restarts, in which case RunAtLoad: true brings it back on login.


Drill

List every scheduled or automated process you currently run. For each one, answer two questions:

  1. Does it have a launchd plist in ~/Library/LaunchAgents/?
  2. Does the plist specify StandardOutPath and StandardErrorPath?

For every process that answers "no" to either question: write the plist today. It takes ten minutes. The alternative is a process that crashes silently and runs nothing — discovered only when something downstream breaks and you spend an hour tracing back to the root cause.


Bottom Line: Cron schedules. launchd manages. For any process that needs to be continuously operational — watchdogs, content pipelines, monitoring daemons, trade alerts — launchd is the correct infrastructure primitive. Write a plist. Set KeepAlive: true. Use /opt/homebrew/bin/python3 and include an EnvironmentVariables PATH block to avoid TCC issues on Apple Silicon. Configure logging paths. Load it with launchctl. Done. Your service is now a first-class citizen of the operating system, and the OS itself is responsible for keeping it alive.