cd /news/developer-tools/5-launchd-traps-i-hit-running-claude… · home topics developer-tools article
[ARTICLE · art-55416] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

5 launchd Traps I Hit Running Claude Code Automation 24/7 on macOS

A developer running 20 launchd jobs for Claude Code automation on macOS documented five traps encountered when scheduling tasks. The traps include cron being non-functional on modern macOS, unsupported periodic syntax in StartCalendarInterval, missing absolute paths for output logs, incorrect Label format, and PATH issues causing 'command not found' errors. Verified workarounds involve migrating to launchd with StartInterval, using absolute paths, and setting env.PATH in Claude's settings.json.

read6 min views1 publishedJul 11, 2026

In earlier posts I walked through a mechanism that auto-generates skills and context auditing. The whole point of these is to run them automatically at a fixed time every night — that's where the value comes from. Right now my setup has 20 launchd

jobs running (skill generation, context audits, a morning briefing generator, Vault ingestion, and more).

The catch: setting up scheduled jobs on macOS is far trickier than you'd expect. This post covers the 5 traps I actually hit and fixed, along with verified workarounds.

cron

is dead on modern macOS You register a job with crontab -e

and it never runs even once. That's the first trap. On modern macOS (Sequoia and later) the cron daemon effectively doesn't run, and jobs are silently skipped. You don't even get an error.

Here's how to check whether it's alive.

log show --predicate 'process == "cron"' --last 7d

If this returns zero entries, cron isn't running. Just migrate to launchd

. A launchd

job looks like this plist.

<key>Label</key><string>com.you.skill-harvest</string>
<key>ProgramArguments</key>
<array>
  <string>/bin/bash</string>
  <string>/Users/you/.claude/scripts/skill-harvest.sh</string>
</array>
<key>StandardOutPath</key><string>/Users/you/.claude/logs/skill-harvest.log</string>
<key>StandardErrorPath</key><string>/Users/you/.claude/logs/skill-harvest.log</string>

and running immediately looks like this.

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.skill-harvest.plist
launchctl kickstart gui/$(id -u)/com.you.skill-harvest   # force a run to confirm it works

Note:launchctl list

/launchctl load

are legacy APIs. The modern ones arelaunchctl print

/bootstrap

/kickstart

. Mixing old and new leads to confusion.

*/5

directly in StartCalendarInterval

Trying to write "every 5 minutes" the way you would in cron gets you stuck. StartCalendarInterval

specifies specific times, and periodic syntax like */5

isn't supported.

StartInterval

(in seconds; 300

for 5 minutes)StartCalendarInterval

entries in an A few smaller traps that also bite:

Label

must be in com.you.name

format. ProgramArguments

StandardOutPath

/ StandardErrorPath

as absolute paths, output vanishes into /dev/null

.node: command not found

— the minimal-PATH problem of GUI launches This is where I got stuck the hardest. Claude Code hooks (PostToolUse

, etc.) kept failing every time with /bin/sh: node: command not found

— even though node

works fine in the terminal.

The cause: the /bin/sh -c of a child process spawned by a GUI-launched app doesn't read the login shell's profile (.zshrc); it only has a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). So

node

in nvm or Homebrew isn't visible. launchd jobs fail for the same reason.The fix is to add env.PATH

at the top level of ~/.claude/settings.json

and have it inherited by child processes.

"env": {
  "PATH": "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/you/.local/bin"
}

The key is to always include /opt/homebrew/bin. If you hardcode only nvm's version-specific path (

.../node/v24.x/bin

), it goes stale the moment you do a major Node upgrade and becomes not found

again. Keeping /opt/homebrew/bin/node

as a fallback means it won't break.Here's how to reproduce and verify.

env -i HOME="$HOME" /bin/sh -c 'PATH="/usr/bin:/bin"; node --version'
env -i HOME="$HOME" /bin/sh -c 'PATH="/opt/homebrew/bin:/usr/bin:/bin"; node --version'

timeout

You wrap a script in timeout 60 some-command

and get timeout: command not found

. macOS doesn't ship the GNU coreutils timeout by default. Many articles and rules are written assuming

timeout

exists, so copy-pasting them silently misfires.

brew install coreutils   # installs gtimeout

Making your script handle both improves portability.

TIMEOUT_CMD="timeout 60"
command -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"
$TIMEOUT_CMD some-command

Incidentally, the bash version problem has the same root. The bash bundled with macOS is 3.2, which lacks 5.x features like $EPOCHREALTIME

(microsecond-precision time). If you're writing measurement scripts, set the shebang to #!/opt/homebrew/bin/bash

to explicitly use 5.x.

exit 78 (EX_CONFIG)

crash loop The last one was the nastiest. I had a real case where a job with KeepAlive

crash-looped infinitely, restarting about 15,000 times over 6 days. No process ever came up, and no logs were left. Looking at launchctl print gui/$(id -u)/<label>

showed last exit code = 78: EX_CONFIG

, with runs

climbing every few seconds.

78 is not the app's own exit — it's launchd's synthesized "couldn't initialize the service," i.e. a failure at the spawn stage. The fact that the log wasn't growing at all (frozen mtime) confirms it. If running the script manually starts up normally, the program itself is healthy and it's dying only under launchd.

Diffing against a healthy job's plist, I found three causes that mattered.

~/Documents

, ~/Desktop

, etc.) → change it to ~/.claude/logs/

or ~/Library/Logs/

(this was the most likely culprit)#!/usr/bin/env bash

resolves, under the plist's PATH, to an unsigned Homebrew bashProgramArguments

to ["/bin/bash", "script.sh"]

to explicitly use the Apple-signed interpreterWorkingDirectory

unspecified/

. If the app writes data with relative paths, it writes to an unintended location (like /tmp

, which is wiped on reboot) and loses the dataOrder matters in the repair procedure.

launchctl bootout gui/$(id -u)/<label>
lsof -nP -iTCP:<port> -sTCP:LISTEN
launchctl bootstrap gui/$(id -u) <plist>
launchctl print gui/$(id -u)/<label> | grep -E 'state =|runs =|last exit'

Warning:KeepAlive=true

does not fix a spawn failure. Without aThrottleInterval

(around 30 seconds), an infinite loop at intervals of a few seconds pilesruns

up into the tens of thousands.

One last landmine. When inspecting a plist, if you get the arguments to plutil -extract ... -o <file>

wrong, you overwrite and destroy the original plist with the output (one of my job's plists actually became a 76-byte JSON fragment). When investigating with plutil

, always output to -o -

(stdout) or a separate file.

Trap Workaround
cron doesn't run Check liveness with log show → migrate to launchd
Can't write */5
StartInterval (seconds) or expand into an array
node: command not found
Add /opt/homebrew/bin to env.PATH in settings.json
No timeout
brew install coreutilsgtimeout fallback
exit 78 loop
Log path outside TCC / explicit /bin/bash / WorkingDirectory / ThrottleInterval

Automation isn't "set it and forget it" — it's only complete once liveness checking is part of it. If you build the habit of periodically checking with launchctl print

whether the log's mtime is advancing as expected, you'll notice jobs that have silently died much sooner.

Across these three posts, I've built an environment where skills grow, context stays light, and automation runs around the clock. Next, I plan to write about the mechanism that rolls all of this up into a morning briefing.

*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.

Follow along: Portfolio · X · GitHub*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/5-launchd-traps-i-hi…] indexed:0 read:6min 2026-07-11 ·