{"slug": "5-launchd-traps-i-hit-running-claude-code-automation-24-7-on-macos", "title": "5 launchd Traps I Hit Running Claude Code Automation 24/7 on macOS", "summary": "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.", "body_md": "In earlier posts I walked through [a mechanism that auto-generates skills](https://zenn.dev/bokuwalily/articles/self-growing-skills) and [context auditing](https://zenn.dev/bokuwalily/articles/context-slimming). 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`\n\njobs running (skill generation, context audits, a morning briefing generator, Vault ingestion, and more).\n\nThe 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**.\n\n`cron`\n\nis dead on modern macOS\nYou register a job with `crontab -e`\n\nand 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.\n\nHere's how to check whether it's alive.\n\n```\nlog show --predicate 'process == \"cron\"' --last 7d\n```\n\nIf this returns **zero entries**, cron isn't running. Just migrate to `launchd`\n\n. A `launchd`\n\njob looks like this plist.\n\n```\n<key>Label</key><string>com.you.skill-harvest</string>\n<key>ProgramArguments</key>\n<array>\n  <string>/bin/bash</string>\n  <string>/Users/you/.claude/scripts/skill-harvest.sh</string>\n</array>\n<key>StandardOutPath</key><string>/Users/you/.claude/logs/skill-harvest.log</string>\n<key>StandardErrorPath</key><string>/Users/you/.claude/logs/skill-harvest.log</string>\n```\n\nLoading and running immediately looks like this.\n\n```\nlaunchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.skill-harvest.plist\nlaunchctl kickstart gui/$(id -u)/com.you.skill-harvest   # force a run to confirm it works\n```\n\nNote:`launchctl list`\n\n/`launchctl load`\n\nare legacy APIs. The modern ones are`launchctl print`\n\n/`bootstrap`\n\n/`kickstart`\n\n. Mixing old and new leads to confusion.\n\n`*/5`\n\ndirectly in `StartCalendarInterval`\n\nTrying to write \"every 5 minutes\" the way you would in cron gets you stuck. `StartCalendarInterval`\n\nspecifies **specific times**, and periodic syntax like `*/5`\n\nisn't supported.\n\n`StartInterval`\n\n(in seconds; `300`\n\nfor 5 minutes)`StartCalendarInterval`\n\nentries in an A few smaller traps that also bite:\n\n`Label`\n\nmust be in `com.you.name`\n\nformat. `ProgramArguments`\n\n`StandardOutPath`\n\n/ `StandardErrorPath`\n\nas absolute paths, output vanishes into `/dev/null`\n\n.`node: command not found`\n\n— the minimal-PATH problem of GUI launches\nThis is where I got stuck the hardest. Claude Code hooks (`PostToolUse`\n\n, etc.) kept failing every time with `/bin/sh: node: command not found`\n\n— even though `node`\n\nworks fine in the terminal.\n\nThe 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\n\n`node`\n\nin nvm or Homebrew isn't visible. launchd jobs fail for the same reason.The fix is to add `env.PATH`\n\nat the top level of `~/.claude/settings.json`\n\nand have it inherited by child processes.\n\n```\n\"env\": {\n  \"PATH\": \"/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/you/.local/bin\"\n}\n```\n\nThe key is to **always include /opt/homebrew/bin**. If you hardcode only nvm's version-specific path (\n\n`.../node/v24.x/bin`\n\n), it goes stale the moment you do a major Node upgrade and becomes `not found`\n\nagain. Keeping `/opt/homebrew/bin/node`\n\nas a fallback means it won't break.Here's how to reproduce and verify.\n\n```\n# reproduce with the old minimal PATH (not found appears)\nenv -i HOME=\"$HOME\" /bin/sh -c 'PATH=\"/usr/bin:/bin\"; node --version'\n# resolve with the new PATH (the version prints)\nenv -i HOME=\"$HOME\" /bin/sh -c 'PATH=\"/opt/homebrew/bin:/usr/bin:/bin\"; node --version'\n```\n\n`timeout`\n\nYou wrap a script in `timeout 60 some-command`\n\nand get `timeout: command not found`\n\n. **macOS doesn't ship the GNU coreutils timeout by default.** Many articles and rules are written assuming\n\n`timeout`\n\nexists, so copy-pasting them silently misfires.\n\n```\nbrew install coreutils   # installs gtimeout\n```\n\nMaking your script handle both improves portability.\n\n```\nTIMEOUT_CMD=\"timeout 60\"\ncommand -v gtimeout >/dev/null && TIMEOUT_CMD=\"gtimeout 60\"\n$TIMEOUT_CMD some-command\n```\n\nIncidentally, **the bash version problem has the same root**. The bash bundled with macOS is 3.2, which lacks 5.x features like `$EPOCHREALTIME`\n\n(microsecond-precision time). If you're writing measurement scripts, set the shebang to `#!/opt/homebrew/bin/bash`\n\nto explicitly use 5.x.\n\n`exit 78 (EX_CONFIG)`\n\ncrash loop\nThe last one was the nastiest. I had a real case where a job with `KeepAlive`\n\n**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>`\n\nshowed `last exit code = 78: EX_CONFIG`\n\n, with `runs`\n\nclimbing every few seconds.\n\n**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.\n\nDiffing against a healthy job's plist, I found three causes that mattered.\n\n`~/Documents`\n\n, `~/Desktop`\n\n, etc.) → change it to `~/.claude/logs/`\n\nor `~/Library/Logs/`\n\n(this was the most likely culprit)`#!/usr/bin/env bash`\n\nresolves, under the plist's PATH, to an unsigned Homebrew bash`ProgramArguments`\n\nto `[\"/bin/bash\", \"script.sh\"]`\n\nto explicitly use the Apple-signed interpreter`WorkingDirectory`\n\nunspecified`/`\n\n. If the app writes data with relative paths, it writes to an unintended location (like `/tmp`\n\n, which is wiped on reboot) and loses the data**Order matters** in the repair procedure.\n\n```\n# 1. stop the loop\nlaunchctl bootout gui/$(id -u)/<label>\n# 2. kill the orphan process holding the port, \"after confirming its owner\"\nlsof -nP -iTCP:<port> -sTCP:LISTEN\n# 3. fix the plist and reload it\nlaunchctl bootstrap gui/$(id -u) <plist>\n# 4. verify\nlaunchctl print gui/$(id -u)/<label> | grep -E 'state =|runs =|last exit'\n```\n\nWarning:`KeepAlive=true`\n\ndoes not fix a spawn failure. Without a`ThrottleInterval`\n\n(around 30 seconds), an infinite loop at intervals of a few seconds piles`runs`\n\nup into the tens of thousands.\n\nOne last landmine. When inspecting a plist, if you get the arguments to `plutil -extract ... -o <file>`\n\nwrong, 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`\n\n, always output to `-o -`\n\n(stdout) or a separate file.\n\n| Trap | Workaround |\n|---|---|\n| cron doesn't run | Check liveness with `log show` → migrate to launchd |\nCan't write `*/5`\n|\n`StartInterval` (seconds) or expand into an array |\n`node: command not found` |\nAdd `/opt/homebrew/bin` to `env.PATH` in `settings.json`\n|\nNo `timeout`\n|\n`brew install coreutils` → `gtimeout` fallback |\n`exit 78` loop |\nLog path outside TCC / explicit `/bin/bash` / `WorkingDirectory` / `ThrottleInterval`\n|\n\nAutomation 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`\n\nwhether the log's mtime is advancing as expected, you'll notice jobs that have silently died much sooner.\n\nAcross 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**.\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/5-launchd-traps-i-hit-running-claude-code-automation-24-7-on-macos", "canonical_source": "https://dev.to/bokuwalily/5-launchd-traps-i-hit-running-claude-code-automation-247-on-macos-32bi", "published_at": "2026-07-11 11:00:05+00:00", "updated_at": "2026-07-11 11:14:32.767055+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "ai-agents", "large-language-models", "ai-products"], "entities": ["Claude Code", "macOS", "launchd", "cron", "Homebrew", "nvm", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/5-launchd-traps-i-hit-running-claude-code-automation-24-7-on-macos", "markdown": "https://wpnews.pro/news/5-launchd-traps-i-hit-running-claude-code-automation-24-7-on-macos.md", "text": "https://wpnews.pro/news/5-launchd-traps-i-hit-running-claude-code-automation-24-7-on-macos.txt", "jsonld": "https://wpnews.pro/news/5-launchd-traps-i-hit-running-claude-code-automation-24-7-on-macos.jsonld"}}