{"slug": "claude-code-in-launchd-my-nightly-claude-p-job-worked-6-of-14-nights", "title": "Claude Code in launchd: My Nightly claude -p Job Worked 6 of 14 Nights", "summary": "A developer documented running Claude Code headless via a macOS launchd agent for 14 nights, during which the nightly `claude -p` job produced a usable morning note on only 6 of those nights. The failures traced to launchd's minimal PATH (exit 127 because `claude` and its `node` shebang weren't found), the default working directory of `/` preventing CLAUDE.md from loading, and headless permission prompts being auto-denied while still exiting 0. The fixes were setting PATH and WorkingDirectory in the plist, adding StandardOutPath/StandardErrorPath logging, pre-approving tools with `--allowedTools`, and verifying the output file exists.", "body_md": "For two weeks I woke up and checked for a markdown file that should have been written at 3:00 AM. On 8 of 14 mornings it either wasn't there, was empty, or showed up after I'd already finished my coffee.\n\nThe job was simple. A macOS launchd agent ran Claude Code headless with `claude -p` every night, read yesterday's commits across four repos, and wrote a one-page morning note. It worked perfectly every single time I ran it from my terminal. That is exactly the trap: running Claude Code in launchd is a different environment from your shell, and launchd doesn't tell you when something goes wrong.\n\nHere's the autopsy, night by night, plus the plist that finally worked.\n\n`PATH=/usr/bin:/bin:/usr/sbin:/sbin``.zshrc`. `claude` isn't on that PATH, so you get exit 127.`#!/usr/bin/env node`, so `node` must be on PATH too. Set `PATH` in the plist's `EnvironmentVariables`.`/`.` CLAUDE.md` never loads. Set `WorkingDirectory`.` claude -p` can't answer permission prompts.`--allowedTools` and verify the output file exists.`pmset` to wake the machine, or accept late runs.\nOne plist in `~/Library/LaunchAgents`, one shell command, one target file. The first version looked like this:\n\n```\n<key>ProgramArguments</key>\n<array>\n  <string>/bin/zsh</string>\n  <string>-c</string>\n  <string>claude -p \"$(cat ~/notes/nightly-prompt.md)\" > ~/notes/morning.md</string>\n</array>\n<key>StartCalendarInterval</key>\n<dict>\n  <key>Hour</key><integer>3</integer>\n  <key>Minute</key><integer>0</integer>\n</dict>\n```\n\nThe prompt asked Claude to run `git log --since=yesterday` in four repos, group the commits by theme, flag anything that touched auth or billing code, and write the summary to `~/notes/morning.md`. In my terminal: about 40 seconds, about $0.30 on API billing, a nice clean note. I loaded it with `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.me.nightly.plist` and went to bed feeling clever.\n\nBecause launchd jobs don't inherit your shell's PATH. A LaunchAgent starts with `/usr/bin:/bin:/usr/sbin:/sbin`, and `zsh -c` in non-interactive mode doesn't read `.zshrc`, which is where my npm global bin and Homebrew paths live.\n\n**Nights 1 to 3: empty `morning.md`.** The redirect created the file, `claude` was never found, and stderr went nowhere because I hadn't set `StandardErrorPath`. I only found the exit code by running `launchctl print gui/$(id -u)/com.me.nightly` and reading `last exit code = 127`.\n\nThree nights lost to a missing log path. Lesson one, before any other fix: always set these two keys.\n\n```\n<key>StandardOutPath</key><string>/Users/me/logs/nightly.out.log</string>\n<key>StandardErrorPath</key><string>/Users/me/logs/nightly.err.log</string>\n```\n\nBecause the `claude` binary from npm is a Node script, and its first line is `#!/usr/bin/env node`. `env` searches PATH for `node`. Fix the path to `claude` and you just move the 127 one layer down.\n\n**Night 4:** I changed the command to the full path of `claude`. The error log now had a new message:\n\n```\nenv: node: No such file or directory\n```\n\nSame exit code, different villain. The real fix is to set PATH for the whole job instead of patching individual binaries:\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>/opt/homebrew/bin:/Users/me/.npm-global/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>\n</dict>\n```\n\nRun `which claude node git` in your normal terminal and copy those directories. Don't guess.\n\nBecause in print mode nobody is there to click \"allow.\" When Claude asks to use a tool you haven't pre-approved, the request is denied, the model gets told no, and it does the polite thing: explains that it couldn't complete the task. The process still exits 0.\n\n**Nights 5 and 6:** exit code 0. `morning.md` contained a well-written paragraph saying it didn't have permission to run `git log` and suggesting I grant access. Technically a success. Practically a very articulate failure.\n\nThere was a second bug hiding behind it. launchd's default working directory is `/`. So Claude started in the filesystem root, didn't load my project's `CLAUDE.md` (which held the repo list and the formatting rules), and had to guess everything. Even with permissions fixed, night 6's partial output used the wrong repo names.\n\nThe fix was two things:\n\n```\n<key>WorkingDirectory</key>\n<string>/Users/me/notes</string>\n```\n\nand an explicit tool allowlist on the command:\n\n```\nclaude -p \"$(cat nightly-prompt.md)\" \\\n  --allowedTools \"Read,Write,Bash(git log:*)\" \\\n  --output-format json > /Users/me/logs/last-run.json\n```\n\nScope the Bash permission to the exact command prefix you need. A nightly unattended agent with blanket Bash access is a bad idea I don't need to test.\n\nI also stopped trusting exit codes. The wrapper script now checks that `morning.md` was modified in the last hour and is longer than 200 bytes. If not, it sends me a notification. That check alone would have saved me nights 1 through 6.\n\n```\nif [ -z \"$(find morning.md -mmin -60 -size +200c 2>/dev/null)\" ]; then\n  osascript -e 'display notification \"nightly note missing\" with title \"claude -p\"'\nfi\n```\n\n**Night 7:** first real success. 3:00:04 start, 38 seconds runtime, $0.31 from `total_cost_usd` in the JSON output.\n\nIt runs late, once. If the machine is asleep at the scheduled time, launchd starts the job the next time it wakes, and multiple missed intervals collapse into a single run. Plain `cron` on macOS is worse: a missed time is simply skipped.\n\n**Nights 9 and 12:** my laptop was closed and on battery. The note showed up at 7:52 and 8:15, the moment I opened the lid. It was correct. It was also useless, because the whole point was to have it before I sat down.\n\nTwo options:\n\n`sudo pmset repeat wakeorpoweron MTWRFSU 02:55:00`. On a laptop this is only reliable when it's plugged in.\nI did the first one and leave the laptop on the charger overnight. Nights 13 and 14 ran at 3:00.\n\n| Nights | Result | Cause | \n|---|---|---|\n| 1-3 | Empty file | `claude` not on launchd PATH, no stderr log | \n| 4 | Empty file | `#!/usr/bin/env node` couldn't find`node` | \n| 5-6 | Polite apology | Tool permission denied in `-p` mode, cwd was`/` | \n| 7, 8, 10, 11, 13, 14 | Worked on time |  | \n| 9, 12 | Worked, 5 hours late | Laptop asleep, job fired on wake | \n\nSix of fourteen on time. Two late. Six broken. Total spend across all fourteen nights was about $2.40, because the broken runs mostly cost nothing: you can't get billed by a binary that never starts.\n\nThe embarrassing part is that none of these were Claude Code bugs. Every failure was environment: PATH, shebang, working directory, permissions, power management. The model was fine. My assumption that \"it works in my terminal\" meant anything was the bug.\n\n```\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>Label</key><string>com.me.nightly</string>\n  <key>ProgramArguments</key>\n  <array>\n    <string>/bin/zsh</string>\n    <string>/Users/me/notes/run-nightly.sh</string>\n  </array>\n  <key>WorkingDirectory</key><string>/Users/me/notes</string>\n  <key>EnvironmentVariables</key>\n  <dict>\n    <key>PATH</key>\n    <string>/opt/homebrew/bin:/Users/me/.npm-global/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>\n  </dict>\n  <key>StartCalendarInterval</key>\n  <dict>\n    <key>Hour</key><integer>3</integer>\n    <key>Minute</key><integer>0</integer>\n  </dict>\n  <key>StandardOutPath</key><string>/Users/me/logs/nightly.out.log</string>\n  <key>StandardErrorPath</key><string>/Users/me/logs/nightly.err.log</string>\n</dict>\n</plist>\n```\n\nTest it without waiting for 3 AM: `launchctl kickstart -k gui/$(id -u)/com.me.nightly`. Then read both log files. If you only do one thing from this post, do that before your first real night.\n\nBecause launchd runs your Claude Code job in a stripped-down environment: a minimal PATH without your Node or npm directories, no shell profile, a working directory of `/`, and no human to approve tool permissions. Set `PATH` and `WorkingDirectory` in the plist, pass `--allowedTools` scoped to the exact commands the prompt needs, log stdout and stderr to files, verify the output file instead of the exit code, and schedule a `pmset` wake if the Mac might be asleep. Those five fixes are the difference between my 6-of-14 first two weeks and a job that now lands at 3:00 sharp.\n\n*Written by the developer behind [Preterview](https://preterview.com/en), an interview prep platform.*", "url": "https://wpnews.pro/news/claude-code-in-launchd-my-nightly-claude-p-job-worked-6-of-14-nights", "canonical_source": "https://dev.to/ji_ai/claude-code-in-launchd-my-nightly-claude-p-job-worked-6-of-14-nights-3572", "published_at": "2026-10-04 16:37:08+00:00", "updated_at": "2026-10-04 16:42:34.271479+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "mlops"], "entities": ["Claude Code", "Anthropic", "launchd", "macOS", "npm", "Homebrew", "Node.js", "zsh"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/claude-code-in-launchd-my-nightly-claude-p-job-worked-6-of-14-nights", "markdown": "https://wpnews.pro/news/claude-code-in-launchd-my-nightly-claude-p-job-worked-6-of-14-nights.md", "text": "https://wpnews.pro/news/claude-code-in-launchd-my-nightly-claude-p-job-worked-6-of-14-nights.txt", "jsonld": "https://wpnews.pro/news/claude-code-in-launchd-my-nightly-claude-p-job-worked-6-of-14-nights.jsonld"}}