Claude Code under launchd: debugging five stacked failures in headless automation
Who this is forDevelopers who schedule Claude Code with launchd or cron on macOS and see silent failures, empty outputs, or false zero results in unattended runs.
Running an AI coding agent on a schedule sounds simple. You wire a command into launchd, collect the output, and send it somewhere useful. The difficulty is that a scheduled job runs in a very different environment from your terminal, and when it fails, the agent often returns a plausible answer instead of an error. This article walks through a daily to-do briefing that Claude Code was supposed to deliver to Discord every morning at 8:00. The job first stayed silent, then reported false zeros, then reported parse failures, and finally produced inconsistent counts. The root cause was not one bug. It was five environment problems stacked on top of each other. You will get the symptoms, the diagnosis for each layer, the final launchd configuration, and a checklist for building reliable AI agent automation on macOS.
Summary
The daily briefing workflow ran claude -p through macOS launchd to produce a to-do summary. It failed quietly because five environment problems overlapped. Each failure surfaced at a higher level as a vague symptom, such as “0 results” or “parse failure,” and those symptoms hid the real cause. After the fixes, the launchd setup became a checklist for launchd-based AI agent automation.
Environment
| Item | Value |
|---|---|
| OS | macOS Darwin 24.6.0 (Apple Silicon) |
| Shell | zsh |
| Scheduler | launchd (LaunchAgent, ~/Library/LaunchAgents/) |
| Execution target | claude -p with the daily to-do slash command and --model sonnet |
| Data sources | Gmail (@googleworkspace/cli v0.22.5) + Notion (claude.ai Notion MCP) |
| Alert channel | Discord webhook |
The execution target is a claude -p call that runs a custom slash command. The command name is in Korean in the original setup, and the full invocation appears in the final plist below. Gmail data comes from the @googleworkspace/cli tool, and Notion data comes through the claude.ai Notion MCP connector. Results are posted to a Discord webhook. Each of these pieces behaves differently under launchd than in an interactive shell, and that difference is the theme of the rest of this article.
Symptoms
The briefing was expected at 8:00 every morning. Instead, the following happened in order:
- First: The start notification arrived, but the completion notification never did. The process appeared to hang silently.
- Second: The job completed, but the briefing reported “Gmail unchecked: 0 items” and “Notion OAuth expired.” In reality, mail and tasks existed.
- Third: Gmail alone showed “parse failure,” while Notion worked normally.
- Fourth: With identical settings, the Notion task query returned 3, then 11, then 0, then 5 results across consecutive runs.
Each symptom looked like a different bug, but they shared a pattern. The agent was not failing loudly. It was converting failures into ordinary-looking output, so the diagnosis had to start from the environment rather than from the report.
Root causes: five layers stacked together
Layer 1. claude -p waits forever on a permission prompt
By default, Claude Code waits for approval when a tool is called. In headless print mode (-p), there is no terminal available to accept input, so the process waits indefinitely. From the outside, this looks like a job that started and never finished.
The fix was to add the --permission-mode bypassPermissions flag. Because this flag removes the interactive approval step, the actions the scheduled job is allowed to take should be reviewed before it is scheduled.
Layer 2. macOS has no timeout command
The plist wrapped the call as timeout 900 claude -p ... as a safety net. The shell returned command not found: timeout. A default macOS installation includes neither timeout nor gtimeout. Getting either one requires Linux coreutils or brew install coreutils. The safety net itself broke the whole pipeline, so the job never reached Claude at all.
The replacement was a Perl one-liner. Perl ships with macOS, so it works without extra installs:
perl -e 'alarm shift @ARGV; exec @ARGV' 900 claude -p ...
Layer 3. In launchd, claude.ai MCP is invalid or its OAuth has expired
The SKILL.md file said that Gmail and Notion data could be fetched either through the “claude.ai Gmail MCP” or through “a script.” In headless mode, the model chose the MCP path. The OAuth token had expired, so the call failed, and the model printed the authentication error as if it were a result.
The fix had two parts. First, SKILL.md now states explicitly that only the CLI and script paths may be used, and MCP must not be used. Second, the fetch.sh script was changed to print every error verbatim, including API errors and JSON parse failures. This stops the model from folding a failure into “0 results.”
Layer 4. Encrypted gws credentials do not work in a headless context
@googleworkspace/cli stores its refresh token encrypted in the OS keyring by default, which on macOS is the Keychain. A LaunchAgent runs inside the user session, but in a headless context the process cannot reach the keyring. The tool treated the credentials file as undecryptable and deleted credentials.enc. That means a single test run could erase the login.
The fix is to set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file. With this setting, the encryption key is stored in the local file ~/.config/gws/.encryption_key. The same variable must also be registered in the plist EnvironmentVariables so that launchd sets it. You must log in again with the variable already set, because the .encryption_key file is created during login.
Layer 5. launchd’s default PATH does not include the nvm bin directory
This was the hardest cause to find. The gws command is installed globally through npm, but Node itself is managed by nvm. The actual binary lives at ~/.nvm/versions/node/v22.17.0/bin/gws. The default PATH in the plist does not include that directory, and .zshenv does not source nvm. In a normal interactive session, nvm is usually loaded from .zshrc, which launchd never reads.
As a result, the bash process started by the plist reports gws: command not found. Its output is empty, and Claude receives that empty value. Claude then describes it as an empty value or a parse failure, and the real cause never appears in the report.
The fix was to prepend /Users/limjung/.nvm/versions/node/v22.17.0/bin to EnvironmentVariables.PATH in the plist. The same path was also set in an explicit export PATH="..." line inside the script, so the script does not depend on how it was launched.
How each layer’s symptoms were masked
| Actual failure | What the model reported |
|---|---|
claude -p hangs forever |
The start notification arrived, but no completion notification did |
timeout command missing, which broke the whole shell pipeline |
Log is 0 bytes, and no failure notification was sent at all |
| MCP OAuth expired | “Gmail unchecked: 0 items” (in reality, 10 emails existed) |
gws not found, so stdout is empty |
“response parse failure” |
| Non-deterministic keyword search (in-progress Backlog query, written in Korean) | Results changed between runs: 3 → 11 → 0 results |
The key observation is that an AI agent translates environment problems into “no results.” Without a mechanism that separates “the command ran and nothing applies” from “the command failed to run,” the root cause stays invisible. A report that looks like a normal answer gives no signal that anything went wrong. The scripts and the instructions both need to make failures distinguishable from empty results.
Final launchd plist structure
The final plist serves as the checklist for daily briefing automation. The job runs under /bin/zsh -c, exports PATH explicitly, writes its log to a dated file, and sends a success or failure notification based on the exit code. The complete configuration is shown below.
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-c</string>
<string>
source ~/.zshenv;
export PATH="/Users/limjung/.claude:/Users/limjung/.nvm/versions/node/v22.17.0/bin:/usr/local/bin:/opt/homebrew/bin:$PATH";
LOG=/tmp/daily-todo-$(date +%Y%m%d).log;
bash ~/.claude/notify-discord.sh "📋 시작" "...";
cd ~/Projects;
perl -e 'alarm shift @ARGV; exec @ARGV' 900 \
claude -p "/오늘할일" --model sonnet --permission-mode bypassPermissions \
> "$LOG" 2>&1;
EXIT=$?;
if [ $EXIT -eq 0 ]; then
bash ~/.claude/notify-discord.sh "✅ 완료" "로그: $LOG";
else
bash ~/.claude/notify-discord.sh "❌ 실패 (exit=$EXIT)" "로그: $LOG";
fi
</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/limjung/.nvm/versions/node/v22.17.0/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
<key>HOME</key><string>/Users/limjung</string>
<key>GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND</key><string>file</string>
<key>LANG</key><string>en_US.UTF-8</string>
<key>LC_ALL</key><string>en_US.UTF-8</string>
</dict>
Two parts of this configuration carry most of the fixes. The EnvironmentVariables block supplies the PATH and the keyring backend that launchd would otherwise omit. The script itself reports the exit code, so a failure produces a visible failure notification rather than a silent gap.
Redirect order matters. > file 2>&1 is the correct form. The reversed form, 2>&1 > file, sends stderr to the terminal (or wherever stdout pointed before the redirect), so the error log is never written to the file and cannot be inspected later.
General lessons for AI agent automation
1. The scheduler launches the agent in the barest shell
A tool that works in a terminal may not run under launchd or cron. Most PATH entries, locale settings, keyring access, and shell initialization files are not loaded when a scheduled job starts. The statement “it works in my terminal” carries no weight in an automated environment. Test the job under a stripped-down environment before trusting it.
2. An AI agent absorbs environment failures into “empty results”
A human developer who sees command not found immediately suspects PATH. An AI agent that receives empty output tends to interpret the situation as “nothing applies today” and writes “0 items” in its natural-language report. To prevent this translation, the script should emit failures and successes as explicitly distinct tokens. Examples include (API error 401) and (response parsing failed), with the instructions in SKILL.md telling the agent to report those tokens as-is and stop.
3. Nondeterminism is calmed with structured queries
A natural-language keyword search, such as the in-progress Backlog query written in Korean, can return different results from the same database on different runs. If the automation must be reliable, specify the data source directly and use structured filters, such as a status property or a date range, instead of keywords.
4. Confirm that an OAuth-based CLI supports headless mode
The @googleworkspace/cli tool defaults to keyring storage. In a headless run, that default can quietly delete credentials, which is a serious default for unattended use. Look in the official documentation for a headless option, which in this case is GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file, and make it a permanent setting before the job is scheduled.
5. Multi-layer debugging peels like an onion
This debugging session followed a common pattern. Each fix revealed the next problem. Anticipating every layer in advance is hard, but a basic procedure makes each layer cheaper to resolve. At each layer, confirm what actually ran. Useful techniques include simulating the headless environment, running the command manually with env -i and an explicit PATH, and separating stderr from stdout. With those habits, each layer needs to be fixed only once.
Sources
@googleworkspace/clidocumentation: https://github.com/GoogleWorkspace/ai-cli- Related local files:
~/Library/LaunchAgents/com.ggplab.daily-todo.plist
~/.claude/skills/오늘할일/SKILL.md
~/.claude/skills/오늘할일/fetch.sh
~/.claude/projects/-Users-limjung-Projects/memory/feedback_headless_claude_plist.md
~/.claude/projects/-Users-limjung-Projects/memory/reference_google_workspace_cli.md
Bottom line
The evidence supports one conclusion: a scheduled Claude Code run needs five things before it can be trusted. It needs an explicit permission mode so headless runs do not wait forever, a timeout mechanism that exists on macOS by default, an explicit PATH and environment that include the nvm-managed Node binaries, headless credential storage that does not depend on the keyring, and scripts that print every error verbatim so that a failure can never appear as zero results. Each of these was a separate layer, and each one hid the next. Treat the final plist and the checklist above as the baseline for any launchd-based AI agent job, and verify each layer with a manual run under a stripped-down environment before relying on the schedule.
Frequently asked questions
- Why does a Claude Code job started by launchd hang or report zero results?
- Common causes are a permission prompt that waits forever in headless print mode, a missing macOS timeout command, expired MCP OAuth tokens, and a PATH that lacks the nvm bin directory. Fix each layer, and make scripts print explicit errors so the model cannot report them as zero results.
- Why does gws say command not found under launchd when it works in my terminal?
- launchd's default PATH omits the nvm bin directory where the globally installed gws binary lives, so bash cannot find it and the output is empty. Add the nvm Node bin path to the plist EnvironmentVariables PATH and to the script's export line.
BuildnWrite helps teams build AI agents that keep running. About BuildnWrite ›