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

> Source: <https://dev.to/ji_ai/claude-code-in-launchd-my-nightly-claude-p-job-worked-6-of-14-nights-3572>
> Published: 2026-10-04 16:37:08+00:00

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.

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

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.

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>
```

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
```

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>
```

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

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>
```

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
```

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
```

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

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:

`sudo pmset repeat wakeorpoweron MTWRFSU 02:55:00`. On a laptop this is only reliable when it's plugged in.
I did the first one and leave the laptop on the charger overnight. Nights 13 and 14 ran at 3:00.

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

```
<?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>
```

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.

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](https://preterview.com/en), an interview prep platform.*
