claude -p exits 0 when the login is dead

Christian Drexler

I have a few jobs that call Claude Code from a schedule. A wrapper script starts claude -p with a prompt, takes what comes back and does something with it, such as writing a draft into a folder. Each run leaves one line in a log with the exit code. For eleven days every line said the run was fine.

For those same eleven days no draft had appeared. I noticed because the results were missing, not because anything told me.

What had happened

The machine that runs these jobs sits in a cupboard without a screen. Claude Code on it had been logged in once, through the normal browser flow, and the login had been living in that user session ever since. At some point the session could no longer refresh it.

From then on, every scheduled run did this:

$ claude -p "Write today's draft ..."
Failed to authenticate: OAuth session expired and could not be refreshed
$ echo $?
0

One line of text, and exit code zero. From the command line tool's point of view nothing had crashed. It had started, found out it could not talk to anyone, said so politely and ended.

My wrapper checked the exit code and nothing else. So it logged success, and the scheduler logged success, and the log was a column of green that meant nothing.

Judge the output, not the exit code

The fix is not clever. Before the real prompt, the wrapper now asks a question with a known answer and looks at the text that comes back.

probe=$(claude -p "Reply with the single word OK" --model haiku 2>&1)

if ! printf '%s' "$probe" | grep -q "OK" ||
     printf '%s' "$probe" | grep -qi "authenticate\|not logged in"; then
  echo "claude probe failed: $probe" >&2
  notify "Claude job cannot authenticate on $(hostname)"
  exit 1
fi

The probe costs a fraction of a cent and two seconds. If it does not return the word, the wrapper stops with a real error code and sends a message to the chat I actually read. The small model is enough for a one-word answer.

The real call gets the same treatment afterwards. Whatever the job is supposed to produce, the wrapper checks that it is there and has a plausible size. A draft file that does not exist, or one that contains a single line starting with "Failed", is a failed run, whatever the exit code claims.

out=$(claude -p "$PROMPT" 2>&1)
if [ "${#out}" -lt 400 ] || printf '%s' "$out" | grep -qi "failed to authenticate"; then
  notify "Claude job produced no usable output"
  exit 1
fi

The number 400 is arbitrary. Pick something comfortably below the shortest real result and far above any error message.

Give a headless machine its own token

The second half of the fix removes the cause. A login that lives in a desktop session is the wrong kind of credential for a job that runs at four in the morning. Claude Code can issue a long-lived token for exactly this case.

On a machine where you are logged in normally:

claude setup-token

It prints a token. Put it into a file on the headless machine that only the job's user can read, and export it in the wrapper before the first call.

# once: paste the token, press Enter, then Ctrl-D
( umask 077; cat > ~/.config/claude/oauth_token )

# in the wrapper
export CLAUDE_CODE_OAUTH_TOKEN="$(cat ~/.config/claude/oauth_token)"

With that variable set, the command does not depend on a browser login or a keychain at all. It also behaves the same whether the job is started by the scheduler, by you over SSH, or after a reboot when nobody has logged in at the screen. Over SSH the same machine had told me "Not logged in" while the desktop session still worked, which was confusing until I understood that the two were looking in different places.

The token expires too, eventually. That is fine, because the probe now catches it on the first failed run and tells me, and it no longer takes eleven days to find out.

The pattern behind it

This failure has a shape worth recognising, because it is the expensive kind. A job that crashes is cheap: something turns red and you fix it. A job that reports success while doing nothing costs you exactly as long as it takes a human to miss the result. That can be an hour or a quarter.

Command line tools that talk to a service fall into it easily. The tool worked. It could not reach the service or was not allowed in. It said so in a sentence. Whether that sentence comes with a non-zero exit code is a decision somebody made on a Tuesday, and it can change between versions.

So for anything scheduled I now assume the exit code tells me whether the process ran, and nothing about whether the work happened. The wrapper looks for the thing the job exists to produce. A file with content, a row in a table, a word in the reply. If that is missing, the run failed, and the job says so in a place where I will see it.