Building agents that last
Sep 30, 2026
4 min read

Run Claude Code on a schedule

Use headless mode to run Claude Code from launchd, cron or GitHub Actions, with a worked weekly-report job, tight permissions, a spending cap, and drafts you review before anything goes out.

Some jobs don't need you in the room: summarize last week's numbers, tidy the notes folder, draft Monday's report. Claude Code can do them while you sleep, as long as you set it up so that the worst case is "a bad draft in a folder", not "an email to your whole list".

This post covers headless mode, three ways to schedule it, a complete weekly-report job, and the guardrails that keep it cheap and safe.

Headless mode in one minute

Normally Claude Code is a conversation. Add -p (short for --print) and it runs once, prints the result, and exits. Anthropic calls this running Claude Code programmatically:

claude -p "Summarize the files in notes/ in five bullet points"

It exits with code 0 on success and non-zero on failure, so scripts can tell when something went wrong. The flags that matter for scheduled jobs:

FlagWhat it does
--allowedTools "Read"Tools Claude may use without asking. List only what the job needs.
--max-turns 15Stops the run after that many agent turns. Print mode only.
--max-budget-usd 2Stops once estimated API spend reaches that amount. Print mode only.
--output-format jsonReturns JSON with the result, session details and total_cost_usd.
--model sonnetPicks the model by alias or full name.
--bareSkips loading your hooks, skills, MCP servers and CLAUDE.md, for predictable runs. Needs an API key.

Two behaviors make headless mode safer than it sounds. First, on current versions a -p run starts in Manual permission mode when nothing else sets one, so any action that would normally ask you is simply denied. Second, nothing interactive can happen: no prompt is left waiting for you at 3 am.

Authenticate a job that runs without you

A scheduled job can't click "log in" in a browser. You have two options:

  • Your Claude subscription. Run claude setup-token once. It opens the browser sign-in and prints a token valid for one year (Pro, Max, Team or Enterprise). Set it as CLAUDE_CODE_OAUTH_TOKEN in the job's environment. Usage counts against your plan. Bare mode doesn't read this variable.
  • An API key from the Claude Console, set as ANTHROPIC_API_KEY. You pay per token, and you can set workspace spend limits in the Console.

Store the token in a file only you can read, outside any git repository. Never paste it into a prompt or a script you share.

Worked example: a weekly report

The job: every Monday at 7 am, read the CSV exports and notes you dropped in a folder during the week, and write a one-page report for you to read with coffee. Nothing gets sent anywhere.

~/weekly-report/
├── prompt.md
├── run-report.sh
├── inputs/      # CSV exports you drop in (signups, sales, traffic)
├── notes/       # your notes from the week
├── drafts/      # reports land here
├── runs/        # raw JSON and a cost log
└── archive/     # last week's inputs, moved here after each run

The instructions:

~/weekly-report/prompt.md
Read every CSV in inputs/ and every note in notes/. They cover the past week.

Write a one-page weekly report in markdown:
1. Three headline numbers, each compared with the previous week if the data has it.
2. What changed and the most likely reason, marked as a guess.
3. Open questions and anything that looks wrong in the data.
4. Three suggested actions for this week.

Rules:
- Use only numbers that appear in the files. If a number is missing, say so.
- Name the file each number came from.
- Output the report only, no preamble.

The script that runs it:

~/weekly-report/run-report.sh
#!/bin/bash
set -euo pipefail

# Scheduled jobs start with a minimal environment, so be explicit.
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
export CLAUDE_CODE_OAUTH_TOKEN="$(cat "$HOME/.config/weekly-report/token")"
CLAUDE="$HOME/.local/bin/claude"   # replace with the output of: which claude

cd "$HOME/weekly-report"
TODAY=$(date +%F)

"$CLAUDE" -p "$(cat prompt.md)" \
  --allowedTools "Read" \
  --max-turns 15 \
  --max-budget-usd 2 \
  --output-format json > "runs/$TODAY.json"

jq -r '.result' "runs/$TODAY.json" > "drafts/report-$TODAY.md"
echo "$TODAY $(jq -r '.total_cost_usd' "runs/$TODAY.json")" >> runs/costs.log

# Archive this week's inputs so next week starts clean.
mkdir -p "archive/$TODAY"
mv inputs/* notes/* "archive/$TODAY"/ 2>/dev/null || true

Notice what Claude is allowed to do: read. It doesn't write files, run commands or touch the network. The shell script, which you wrote and can read line by line, saves the output. That split is the safest pattern for scheduled jobs: Claude thinks, your script acts.

Make it executable with chmod +x run-report.sh, then run it once by hand and read the draft before you schedule anything.

Schedule it on a Mac with launchd

Apple's docs say launchd is the preferred way to run timed jobs, and that cron is deprecated in its favor. launchd also has a useful habit: if your Mac is asleep at the scheduled time, the job runs when it wakes. (If the Mac is off, it waits for the next scheduled time.)

Save this as ~/Library/LaunchAgents/com.you.weekly-report.plist, replacing you in the paths with your username:

~/Library/LaunchAgents/com.you.weekly-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.weekly-report</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/Users/you/weekly-report/run-report.sh</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Weekday</key>
    <integer>1</integer>
    <key>Hour</key>
    <integer>7</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
  <key>StandardOutPath</key>
  <string>/Users/you/weekly-report/runs/launchd.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/you/weekly-report/runs/launchd.err</string>
</dict>
</plist>

Load it, then trigger it once to test:

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

Prefer cron, or on Linux? One line in crontab -e does the same job (minute, hour, day of month, month, day of week). Cron skips runs while the machine is asleep.

0 7 * * 1 /bin/bash /home/you/weekly-report/run-report.sh >> /home/you/weekly-report/runs/cron.log 2>&1

Or run it in GitHub Actions

If the job is about a GitHub repository, or you want it to run with your laptop closed, use Anthropic's official action, anthropics/claude-code-action. The quickest setup is to run /install-github-app inside Claude Code, which installs the Claude GitHub App and saves your key or token as a repository secret. When a workflow gives the action a prompt, it runs in automation mode, no @claude mention needed.

This weekly version follows the scheduled example in Anthropic's docs:

.github/workflows/weekly-report.yml
name: Weekly Report
on:
  schedule:
    - cron: "0 7 * * 1"
jobs:
  report:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: read
      issues: read
      id-token: write
    steps:
      - uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          prompt: "Summarize last week's commits and open issues as a short report: what shipped, what's stuck, what needs a decision."
          claude_args: |
            --model sonnet
            --max-turns 10
            --allowedTools "mcp__github__list_commits,mcp__github__list_issues"

Things to know: GitHub runs schedules in UTC by default, only from the default branch, and in public repositories it disables schedules after 60 days without activity. The report appears in the workflow run log. With a plain-text prompt, Claude has no shell or GitHub API access until you grant tools, which is why the two read-only GitHub tools are listed. timeout-minutes makes GitHub cancel a stuck job.

Other ways to schedule

Claude Code also has built-in scheduling, if you'd rather not touch plists or YAML:

  • Desktop scheduled tasks run on your machine from the Claude desktop app, while the app is open and the computer is awake.
  • Cloud scheduled tasks (formerly routines) run on Anthropic's cloud, even with your computer off. Create them with /schedule. They're in research preview on Pro, Max, Team and Enterprise plans.
  • /loop repeats a prompt on an interval, but only while that session stays open.

Cost and safety

An unattended agent is an agent nobody is watching. Set it up so that's fine:

  • Cap every run. Use --max-turns and --max-budget-usd. The budget is checked against a client-side estimate and a run can go past it, so leave headroom. On the API, also set a workspace spend limit in the Console.
  • Watch the cost log. total_cost_usd in the JSON output is an estimate, but a sudden jump tells you something changed. On a subscription, /usage shows your plan limits.
  • Grant the least you can. Here that's Read. If a job needs more, add narrow rules like Bash(git log *) rather than all of Bash. Permissions without babysitting goes deeper.
  • Run in a folder you control. Without --bare, a -p run loads the folder's hooks and MCP servers without the usual trust prompt. Never point a scheduled job at a repository you haven't read.
  • Never send without review. The job writes drafts. You read them and you press send. If it ever needs to publish, email or post, that step stays human.

Sources