DEV Community

jidonglab
jidonglab

Posted on

Claude Code in launchd: My Nightly claude -p Job Worked 6 of 14 Nights

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.

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

Here's the autopsy, night by night, plus the plist that finally worked.

TL;DR

  • launchd gives your job PATH=/usr/bin:/bin:/usr/sbin:/sbin and does not read .zshrc. claude isn't on that PATH, so you get exit 127.
  • An absolute path to claude is not enough. The CLI starts with #!/usr/bin/env node, so node must be on PATH too. Set PATH in the plist's EnvironmentVariables.
  • The default working directory is /. Your project CLAUDE.md never loads. Set WorkingDirectory.
  • claude -p can't answer permission prompts. Unapproved tools get denied, the run still exits 0, and the "report" is an apology. Pass --allowedTools and verify the output file exists.
  • A sleeping Mac delays the job, it doesn't run it at 3:00. launchd fires missed calendar jobs once on wake. Use pmset to wake the machine, or accept late runs.

What was the setup?

One plist in ~/Library/LaunchAgents, one shell command, one target file. The first version looked like this:

<key>ProgramArguments</key>
<array>
  <string>/bin/zsh</string>
  <string>-c</string>
  <string>claude -p "$(cat ~/notes/nightly-prompt.md)" > ~/notes/morning.md</string>
</array>
<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key><integer>3</integer>
  <key>Minute</key><integer>0</integer>
</dict>
Enter fullscreen mode Exit fullscreen mode

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

Why does claude -p fail with exit 127 in launchd?

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

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.

Three nights lost to a missing log path. Lesson one, before any other fix: always set these two keys.

<key>StandardOutPath</key><string>/Users/me/logs/nightly.out.log</string>
<key>StandardErrorPath</key><string>/Users/me/logs/nightly.err.log</string>
Enter fullscreen mode Exit fullscreen mode

Why isn't an absolute path to claude enough?

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

Night 4: I changed the command to the full path of claude. The error log now had a new message:

env: node: No such file or directory
Enter fullscreen mode Exit fullscreen mode

Same exit code, different villain. The real fix is to set PATH for the whole job instead of patching individual binaries:

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/opt/homebrew/bin:/Users/me/.npm-global/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
Enter fullscreen mode Exit fullscreen mode

Run which claude node git in your normal terminal and copy those directories. Don't guess.

Why did claude -p exit 0 but write nothing?

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

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.

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

The fix was two things:

<key>WorkingDirectory</key>
<string>/Users/me/notes</string>
Enter fullscreen mode Exit fullscreen mode

and an explicit tool allowlist on the command:

claude -p "$(cat nightly-prompt.md)" \
  --allowedTools "Read,Write,Bash(git log:*)" \
  --output-format json > /Users/me/logs/last-run.json
Enter fullscreen mode Exit fullscreen mode

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

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

if [ -z "$(find morning.md -mmin -60 -size +200c 2>/dev/null)" ]; then
  osascript -e 'display notification "nightly note missing" with title "claude -p"'
fi
Enter fullscreen mode Exit fullscreen mode

Night 7: first real success. 3:00:04 start, 38 seconds runtime, $0.31 from total_cost_usd in the JSON output.

What happens to a launchd job when the Mac is asleep?

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

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.

Two options:

  1. Schedule a wake a few minutes before the job: sudo pmset repeat wakeorpoweron MTWRFSU 02:55:00. On a laptop this is only reliable when it's plugged in.
  2. Decide that "runs on wake" is fine and drop the time-sensitive framing.

I did the first one and leave the laptop on the charger overnight. Nights 13 and 14 ran at 3:00.

What does the final scorecard look like?

Nights Result Cause
1-3 Empty file claude not on launchd PATH, no stderr log
4 Empty file #!/usr/bin/env node couldn't find node
5-6 Polite apology Tool permission denied in -p mode, cwd was /
7, 8, 10, 11, 13, 14 Worked on time
9, 12 Worked, 5 hours late Laptop asleep, job fired on wake

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

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

The plist that works

<?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.me.nightly</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>/Users/me/notes/run-nightly.sh</string>
  </array>
  <key>WorkingDirectory</key><string>/Users/me/notes</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/Users/me/.npm-global/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key><integer>3</integer>
    <key>Minute</key><integer>0</integer>
  </dict>
  <key>StandardOutPath</key><string>/Users/me/logs/nightly.out.log</string>
  <key>StandardErrorPath</key><string>/Users/me/logs/nightly.err.log</string>
</dict>
</plist>
Enter fullscreen mode Exit fullscreen mode

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

Why does claude -p work in the terminal but fail in launchd?

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


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)