← All guides

Claude Code Works in Your Terminal But Fails in Cron

You automated a Claude Code job, tested it in your terminal, and it worked. You put it in crontab and it fails every night with 'Not logged in - Please run /login'. You are already logged in. The problem is not your login, it is which session your job runs in.

Agent scheduled and still not running?

Book a rescue session — screen-share, we find the cause and fix it live. If I can't find it, you don't pay.

Bottom Line

  • The error is a lie. You are logged in. The credentials exist and are valid.
  • Cause: the macOS Keychain. Claude Code keeps its OAuth token there, and Keychain items unlock with your GUI login session. Cron runs outside it.
  • Fix: use launchd, not cron. A LaunchAgent bootstrapped into the gui/$(id -u) domain runs inside the logged-in session and can read the Keychain.
  • Tell-tale sign: every other step in your scheduled job succeeds. Only the model call fails.
  • Running /login again will not fix it and will cost you an hour before you believe that.

The Symptom

You have a job like this and it works perfectly when you paste it into a terminal:

#!/bin/bash
cd ~/projects/reports
claude -p "Summarize today's log and write reports/today.md"

You add it to crontab:

30 21 * * * /Users/you/bin/nightly-report.sh >> /tmp/report.log 2>&1

And every night /tmp/report.log contains some version of:

Not logged in · Please run /login

So you run /login. It says you are already authenticated. You run the script by hand — it works. You add PATH to the crontab, because that is the usual cron answer. Still fails. You start wondering whether cron is running the script at all, so you add an echo at the top, and the echo appears. The script runs. Only Claude fails.

The Cause

Claude Code does not keep your credentials in a dotfile. On macOS it stores the OAuth token in the Keychain, in an item named Claude Code-credentials.

Keychain access is scoped to a session. When you log into your Mac, the login keychain unlocks and processes running inside that GUI session can read items you have granted them. A cron job does not run inside your GUI login session. It runs in a bare launchd-spawned context with no connection to the logged-in user’s keychain state.

So when Claude Code starts up under cron and asks the Keychain for its token, it does not get “access denied” in a way that produces a useful message. It gets nothing, concludes there is no stored credential, and reports the only thing it knows how to report: you are not logged in.

That is why the error is so misleading. It is a truthful description of what the process could see, and a completely wrong description of the state of your account.

This is also why the rest of your pipeline works. Fetching a file, running a Python script, writing to disk — none of that needs the Keychain. You get a log full of successful steps and one failure, which reads like a Claude bug rather than a scheduling bug.

The Fix: launchd in the GUI Domain

On macOS, the scheduler that can run inside your login session is launchd, specifically a LaunchAgent bootstrapped into the gui/ domain.

Create ~/Library/LaunchAgents/com.you.nightly-report.plist:

<?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.you.nightly-report</string>

    <key>ProgramArguments</key>
    <array>
        <string>/Users/you/bin/nightly-report.sh</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key><integer>21</integer>
        <key>Minute</key><integer>30</integer>
    </dict>

    <key>StandardOutPath</key>
    <string>/tmp/nightly-report.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/nightly-report.err</string>

    <key>RunAtLoad</key>
    <false/>
</dict>
</plist>

Then load it into the GUI domain. This part is the whole point, so do not skip it:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.nightly-report.plist

Verify it is registered:

launchctl print gui/$(id -u)/com.you.nightly-report | head -20

Force a run right now instead of waiting until 21:30:

launchctl kickstart -p gui/$(id -u)/com.you.nightly-report

To reload after editing the plist:

launchctl bootout gui/$(id -u)/com.you.nightly-report
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.nightly-report.plist

Then remove the crontab entry, or you will get two runs a night and spend a while confused about why the output is duplicated.

Why sudo launchctl load Makes It Worse

The advice you will find most often is sudo launchctl load -w /Library/LaunchDaemons/.... Do not use it here.

A LaunchDaemon runs at the system level, before and independently of any user login. That is strictly further from the Keychain than cron was. You will get the same “Not logged in” error with more setup in between. load is also the deprecated interface; bootstrap and bootout are the current ones and they give real error messages when something is wrong.

The rule: agent jobs that need your credentials are LaunchAgents in gui/, never LaunchDaemons.

Checking Whether This Is Actually Your Bug

Before rewriting anything, confirm the diagnosis in about thirty seconds. Add this as a temporary cron entry:

*/5 * * * * /usr/bin/security find-generic-password -s "Claude Code-credentials" >> /tmp/kc.log 2>&1

If /tmp/kc.log fills with errors while the same command works instantly in your terminal, you have confirmed it: the Keychain is unreachable from cron, and no amount of /login will change that. Remove the entry afterwards.

The Same Trap, Other Tools

This is not a Claude Code quirk so much as a macOS scheduling quirk that agent tools walk into because they all store OAuth tokens the same way. If a scheduled job of yours fails only when scheduled, and the tool involved authenticates through a browser, suspect this first:

  • Any CLI that logs in through a browser OAuth flow and persists the token to the login keychain.
  • gh and other tools when configured with the keyring credential helper rather than a token file.
  • Anything reading credentials that a GUI app wrote.

The general shape is worth keeping: a scheduled job runs in a different security context than your terminal, and credentials are the first thing to differ. When “works by hand, fails on schedule” shows up, check the credential path before you check anything else.

Still failing after the launchd switch?

There are three or four other reasons a headless agent run dies that look identical from the log. Book a rescue session and we'll find yours on the call.

Need OpenClaw fixed live?

Remote rescue sessions for gateway, auth, tunnel, VPS, and model access problems.

See Rescue Session

Read next

Fix OpenClaw macOS: Gateway Won't Stay Running / Telegram Bot Silent
Fix OpenClaw on macOS: gateway dies when you close the terminal, and the Telegram bot stops responding. Install the launchd service and fix binding + permissions.
Your Agent Is Ignoring CLAUDE.md. Here's Where It's Reading Instead
Claude Code follows rules you never wrote and ignores the ones you did. Usually a nested CLAUDE.md in a subfolder is silently overriding your root file.
Your Parallel Agents Are Overwriting Each Other's Work
Multi-agent runs that finish with half the work missing. File conflicts are the number one killer of agent teams, and the fix is assignment, not coordination.
The Soldered Memory Trap: Why 'Buy Less Now, Upgrade Later' Fails on Unified-Memory AI Boxes
Macs, Strix Halo mini-PCs and the DGX Spark all solder their memory. You buy your RAM ceiling once, permanently. Worse, in 2026 vendors deleted configs mid-generation — Apple removed the 64GB Mac mini M4 Pro and the 256GB/512GB Mac Studio. The config you planned to upgrade to may not exist when you go back.