Scheduling a Recurring Claude API Call With Cron: A Working Script and Error-Handling Checklist

A person holding a coffee cup while looking at a laptop on a wooden desk

Running a Claude API call on a schedule takes three pieces: a script that makes the request, a cron entry that fires it, and error handling for the specific HTTP codes the API actually returns — skip that last part and a scheduled job that fails at 3 a.m. will fail silently every night after. Here’s a working version of all three, plus the mistakes that usually show up first.

The script

This is a minimal shell script built on the request format documented in Anthropic’s Messages API reference. It reads the API key from an environment variable rather than hardcoding it, logs the response, and exits with a non-zero status on failure so cron’s own error reporting picks it up.

#!/usr/bin/env bash
set -euo pipefail

LOG="/var/log/claude-weekly-report.log"
RESPONSE=$(curl -s -w "\n%{http_code}" https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-opus-5-5",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Summarize this week'"'"'s open PRs from the attached log into three bullet points."}]
  }')

HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')

echo "$(date -u +%FT%TZ) [$HTTP_CODE] $BODY" >> "$LOG"

if [ "$HTTP_CODE" -ne 200 ]; then
  exit 1
fi

Two details matter here and are easy to skip when adapting a quick one-off: set -euo pipefail makes the script stop and fail loudly on an unexpected error instead of continuing past it, and capturing the HTTP status code separately from the body (via -w "\n%{http_code}") is what makes the error handling below possible — without it, you only have the response text to guess from.

Keeping the API key out of the script itself

The script above reads $ANTHROPIC_API_KEY from the environment rather than embedding it. Set it in a separate file the script sources, outside version control:

  • Put export ANTHROPIC_API_KEY="sk-ant-..." in a file like ~/.config/claude-cron.env, not in the script.
  • Add that file’s path to .gitignore before the first commit touches the project, not after.
  • If this script lives in a repo at all, a local gitleaks pre-commit hook catches the case where a key gets pasted into the script by accident during editing, which happens more often than typing it into a .env file wrong.

The cron entry

Cron doesn’t load your shell profile, so environment variables set in .bashrc or .zshrc aren’t visible to a cron job by default — this is the single most common reason a script that works fine when run manually fails silently under cron. Source the env file explicitly inside the crontab entry:

0 8 * * MON bash -c 'source ~/.config/claude-cron.env && /home/you/scripts/claude-weekly-report.sh'

Per the standard crontab syntax documented in the crontab(5) manual, the five fields are minute, hour, day-of-month, month, and day-of-week — 0 8 * * MON runs at 8:00 every Monday. Wrapping the command in bash -c '...' with the source call inline is what gets the environment variable into the job’s process, since cron itself won’t read that file for you.

Handling the errors the API actually returns

A scheduled job that only checks for success hides every other outcome inside one generic failure. The documented status codes tell you which failures are worth retrying and which aren’t:

CodeError typeWhat it meansWhat to do in a cron job
400invalid_request_errorMalformed request, or a spend limit was reachedDon’t retry — fix the request body or check billing
401authentication_errorAPI key is malformed, revoked, or expiredDon’t retry — the env file needs a new key
429rate_limit_errorRate limit or spend cap reachedSafe to retry later; don’t retry immediately in a tight loop
500api_errorUnexpected error inside Anthropic’s systemsSafe to retry once after a short delay
529overloaded_errorAPI temporarily overloadedSafe to retry later; this is not your account’s fault

The script above logs the raw status code for every run, so a week of 429s in the log is a visibly different problem from a week of 401s — the first means scheduling the job at a less contended time or batching requests, the second means the key in claude-cron.env is stale.

Common mistakes and how to fix them

  • Mistake: the script runs fine by hand but never produces output under cron. Cause: cron didn’t inherit the environment variable. Fix: source the env file inside the crontab’s command, as shown above, rather than assuming your shell profile applies.
  • Mistake: the job silently does nothing when the API is briefly overloaded. Cause: no distinction between a 529 (worth retrying) and a 401 (not worth retrying). Fix: branch on the logged HTTP code and only re-queue the job for codes documented as transient.
  • Mistake: paths in the script work locally but not under cron. Cause: cron runs with a minimal PATH and no working directory assumption. Fix: use absolute paths for the script, the log file, and any files it reads, instead of relative ones.
  • Mistake: the job appears to hang. Cause: curl with no timeout can wait indefinitely on a stalled connection. Fix: add --max-time 60 to the curl call so a stuck request fails instead of blocking the next scheduled run.
  • Mistake: nobody notices the job has been failing for weeks. Cause: logging to a file nobody checks. Fix: pipe failures to a notification step — even a simple mail command on non-zero exit — rather than relying on someone to tail the log.
  • Mistake: the log file grows forever and eventually fills the disk. Cause: the script appends to the same log on every run with nothing trimming it. Fix: either rotate it with the system’s existing logrotate setup, or have the script itself truncate entries older than a fixed number of days before appending the new one.

Testing the script before cron ever touches it

Run the script by hand first, with the same environment it will have under cron, before adding the crontab entry at all. The cleanest way to catch environment-related bugs early is to reproduce cron’s minimal environment on purpose:

env -i bash -c 'source ~/.config/claude-cron.env && /home/you/scripts/claude-weekly-report.sh'

env -i starts the command with an empty environment, which is much closer to what cron actually provides than your normal interactive shell. If the script fails here, it would have failed under cron too — and you’ve found that out at 2 p.m. with a terminal open, not at 8 a.m. with nobody watching. Once this runs cleanly and the log file shows a 200, add the crontab entry and let it run unattended for the first time on a day you can check the log afterward.

What this setup is good for, and what it isn’t

A cron-triggered API call works well for self-contained, stateless jobs: generating a weekly summary from a log file, turning overnight data into a morning digest, or producing a draft that a person reviews before it goes anywhere — the kind of output covered in prompts for weekly status reports or a sprint retrospective. Each run in this setup is independent; nothing carries over from the previous run unless you explicitly feed prior output back in as context, which also means you’re not paying to resend the same long system prompt every time if you’re using prompt caching on the static parts of the request.

It’s a poor fit for anything that needs to react to events as they happen rather than on a fixed schedule — a team spread across time zones posting updates whenever they come online is better served by the asynchronous approach in running an async standup with AI than by a single cron job firing at one fixed hour. It’s also the wrong tool for anything where a missed or delayed run has real consequences — cron has no built-in retry queue or alerting beyond whatever you add yourself, so a job that absolutely must run gains more from a managed scheduler with its own monitoring than from a crontab entry and a log file.

FAQ

Does this need to be bash specifically, or does Python work too?
Bash and curl keep the example minimal, but the same three pieces — a script, a scheduler, and status-code-aware error handling — apply just as well to a Python script using the requests or anthropic library. The cron entry changes to call python3 /path/to/script.py instead of a shell script; the environment-loading caveat and the error-code table apply exactly the same way, since both come from cron’s behavior and the API’s responses, not from the scripting language.

Why use cron instead of a cloud scheduler or serverless function?
Cron is the right tool when the job already runs on a machine you control and keep online — a home server, a VPS, a machine that’s on anyway. It has no dependency on a third-party platform staying available and no separate billing to track. A cloud scheduler becomes the better choice once you need the job to survive that one machine being off, or you want scaling and monitoring built in rather than hand-rolled with a log file and a cron entry.

Photo: Shixart1985 / Wikimedia Commons (CC BY 2.0)

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top