Nightshift Dev
Agent Ops

How to run Claude Code headless on a schedule

By Albert Nahas, 10 min read, updated

To run Claude Code headless, pass -p with a prompt: claude -p "your task" runs the full agent loop without the interactive UI, prints the result and exits. To run it on a schedule, call that command from cron, launchd or a GitHub Actions schedule trigger, or skip your own machine entirely and create a cloud scheduled task (formerly called routines) with /schedule.

The command is the easy part. What keeps an unattended agent safe is everything around it: which tools it may use, how many turns it gets, how much it can spend, and how you stop it without logging into a server. I run scheduled agents on my product OpenWhen, and they open and merge real pull requests. This is the setup, including the parts I got wrong.

The basic headless call

claude -p "Summarize what changed in this repo since yesterday" \
  --output-format json

--output-format takes text (the default), json or stream-json. The JSON payload has the answer in result, plus session_id and total_cost_usd, so a script can log what each run cost:

out=$(claude -p "Run the test suite and fix failures" --output-format json \
  --allowedTools "Bash(npm test *),Read,Edit")
echo "$out" | jq -r '.result'
echo "$out" | jq -r '.total_cost_usd' >> costs.log

That cost figure is a client-side estimate, not your bill, but it's close enough to spot a run that went sideways.

Other flags you'll use with -p:

FlagWhat it does
--allowedToolsTools that run without a permission prompt
--disallowedToolsDeny rules; a bare tool name removes the tool entirely
--toolsRestricts which built-in tools exist at all
--permission-modedefault, acceptEdits, plan, auto, dontAsk, bypassPermissions
--permission-prompts noneDenies anything that would prompt, and tells Claude not to retry
--max-turnsCaps agentic turns, exits with an error when hit (print mode only)
--max-budget-usdStops the run when estimated spend reaches the cap (print mode only)
--append-system-promptAdds instructions on top of the default system prompt
--continue, --resumeContinue the last conversation, or a specific session ID
--bareSkips hooks, skills, plugins, MCP servers and CLAUDE.md for a reproducible start
--json-schemaWith --output-format json, returns structured_output matching your schema

Permissions: the part most people get wrong

--allowedTools uses permission rule syntax. Bash(git diff *) allows any command that starts with git diff. The space before * matters: without it, Bash(git diff*) also matches git diff-index.

claude -p "Look at my staged changes and create a commit" \
  --allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)"

Here's the trap. --allowedTools approves tools. It doesn't restrict anything on its own. If you also pass --dangerously-skip-permissions, every call is approved anyway and your allowlist does nothing.

It's an easy mistake, and I've made it. A runner like this looks guarded and isn't:

const args = [
  "-p", prompt,
  "--allowedTools", "Read,Write,Bash,Edit,Glob,Grep",
  "--dangerously-skip-permissions", // approves everything, so the line above means nothing
];

What actually restricts a run:

  • --tools "Read,Edit,Bash" removes every other built-in tool from Claude's context.
  • --disallowedTools "Bash(rm *)" denies matching calls even if something else would allow them.
  • --permission-mode dontAsk denies every call that would otherwise prompt, so only your allow rules and the read-only defaults run.
  • --permission-prompts none (Claude Code v2.1.259 or later) does the same for runs that have a permission host, and tells Claude not to retry a denied call.

Since v2.1.302, a claude -p run with no mode set starts in Manual mode, and calls that need approval are denied unless something approves them. That's a sane default for cron. If I were setting up a new unattended job today, I'd write it like this:

claude -p "$PROMPT" --allowedTools "$TOOLS" \
  --permission-mode dontAsk \
  --allowedTools "Read,Edit,Bash(npm test *),Bash(git diff *),Bash(gh pr create *)" \
  --max-turns 40 \
  --max-budget-usd 3 \
  --output-format json

--max-turns exits non-zero when it hits the cap, so your wrapper should log that as "ran out of turns", not page you as a crash. A run killed with SIGTERM exits 143.

In CI, add --bare. Without it, claude -p loads hooks from the project's .claude/settings.json and connects servers in its .mcp.json, with no trust dialog. Bare mode ignores both and needs ANTHROPIC_API_KEY set, because it never reads your subscription login.

Option 1: cron or launchd on your own machine

A plain crontab line is enough to start:

# Every weekday at 07:05
5 7 * * 1-5  cd /path/to/repo && ./scripts/run-agent.sh ops >> logs/cron.log 2>&1

I'm on a Mac, so my local agents run from launchd. A weekly one fires Mondays at 07:00:

<key>ProgramArguments</key>
<array>
  <string>/path/to/run.sh</string>
  <string>weekly</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>

launchd runs with a thin environment, which bit me twice. First, claude and node weren't on its PATH, so the wrapper sets PATH explicitly. Second, macOS privacy controls don't let a launchd job read ~/Desktop without Full Disk Access, and one of my repos lived there. Now the wrapper checks it can read the charter file first and fires a desktop notification with the fix when it can't.

The wrapper is where the safety lives. It should do four things around the Claude call:

#!/bin/bash
set -u
SITTING="${1:-weekly}"
ROOT="$HOME/dev/myproduct"
CHARTER="$ROOT/docs/AGENT_LOOP.md"
STAMP="$(date +%Y-%m-%d)"
LOG="$ROOT/logs/$STAMP-$SITTING.log"

# 1. Kill switch: the charter's status line decides
MODE="$(head -5 "$CHARTER" | sed -n 's/^status: //p')"
[ "$MODE" = active ] || { echo "charter not active, exiting" >> "$LOG"; exit 0; }

# 2. Once per day, even if launchd fires twice
GUARD="$ROOT/logs/.done-$STAMP-$SITTING"
[ -f "$GUARD" ] && exit 0

# 3. Run with hard caps
cd "$ROOT" && claude -p "$PROMPT" --allowedTools "$TOOLS" \
  --permission-mode dontAsk --max-turns 40 --max-budget-usd 5 >> "$LOG" 2>&1
STATUS=$?
[ $STATUS -eq 0 ] && touch "$GUARD"

# 4. Keep two weeks of logs
find "$ROOT/logs" -name '*.log' -mtime +14 -delete
exit $STATUS

The kill switch is the line I'd keep if I had to drop everything else. Each charter starts with status: active. Changing it to paused and pushing stops the agent on its next run, from my phone, with no SSH. The prompt also tells Claude to read that line first and stop if it isn't active, so the check exists in two places.

Option 2: GitHub Actions on a cron

The official action is anthropics/claude-code-action@v1. Give it a prompt and it runs in automation mode without waiting for an @claude mention. CLI flags go in claude_args:

name: Daily report
on:
  schedule:
    - cron: "7 6 * * *"
jobs:
  report:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
      issues: read
      id-token: write
    steps:
      - uses: actions/checkout@v6
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          prompt: "Summarize yesterday's commits and open issues, and flag likely duplicates"
          claude_args: |
            --max-turns 20
            --allowedTools "mcp__github__list_commits,mcp__github__list_issues"

With a plain-text prompt, Claude has no shell or GitHub API access until you grant tools in claude_args or a settings input. You can also pass a skill as the prompt, like /triage, after the checkout step. Two GitHub quirks: scheduled workflows only run from the default branch, and in public repos GitHub disables the schedule after 60 days without repository activity. Set timeout-minutes on the job too; it's a second cap that doesn't depend on Claude.

Option 3: cloud scheduled tasks (routines)

Scheduled tasks, which the docs called routines until recently, run on Anthropic's infrastructure, so they keep going when your laptop is closed. You create them at claude.ai/code/routines or from the CLI with /schedule:

/schedule daily PR review at 9:07am
/schedule list
/schedule run

Each run clones the repositories you attached, starts from the default branch, and pushes to claude/-prefixed branches unless your prompt says otherwise. Use GitHub branch protection to control what it can push to. Custom cron expressions are allowed, but anything more frequent than hourly is rejected, and five-field expressions are read as UTC. Pick a few minutes past the hour; runs scheduled exactly on the hour can start several minutes late.

My daily agent runs here. Its charter tells it to grade and merge PRs, check support tickets, pull KPIs and write a daily report, and the charter, not the routine's prompt, is what I edit to steer it. The routine prompt is a few lines that say "read the charter and follow it". That split means I almost never touch the scheduler.

Scheduled tasks need a claude.ai subscription login, and they run as you: commits and PRs carry your GitHub user.

Which one to pick

Cron or launchdGitHub ActionsCloud scheduled task
Runs when laptop is offNoYesYes
Local files, browser, desktop appsYesNoNo
BillingYour login or API keyAPI key or OAuth tokenYour subscription
Fastest cadenceAnyGitHub cronHourly
Best forAgents that drive a browser or local toolsRepo chores tied to CIRepo work that should never depend on your machine

An agent that drives a logged-in browser has to be local. One that only needs the repo and an API belongs in the cloud. That's the whole decision.

What I'd do next

The scheduler only decides when the agent runs. What it does is decided by the charter it reads, explained in the agent charter guide, and what it can't do is enforced by Claude Code hooks.

Questions people ask

How do I run Claude Code headless?

Pass the -p flag with your prompt, for example claude -p 'fix the failing tests'. It runs the full agent loop non-interactively, prints the result and exits with a status code your script can check.

Can I run Claude Code from cron?

Yes. Call claude -p from a crontab line or a wrapper script, set PATH explicitly since cron has a minimal environment, and pass --allowedTools, --max-turns and --max-budget-usd so the run is bounded.

Does --allowedTools restrict what Claude can do?

No, it only pre-approves tools. To restrict, use --tools to limit the built-in tools, --disallowedTools for deny rules, or --permission-mode dontAsk so anything not allowed is denied.

What are Claude Code routines?

Routines, now called scheduled tasks, are saved Claude Code configurations that run in Anthropic's cloud on a schedule, an API call or a GitHub event. You create them at claude.ai/code/routines or with /schedule, and they can't run more often than hourly.

How do I run Claude Code in GitHub Actions on a schedule?

Use anthropics/claude-code-action@v1 in a workflow with an on.schedule cron trigger and a prompt input. Pass flags such as --max-turns and --allowedTools through the claude_args input.

How do I stop a scheduled Claude Code agent quickly?

Put a status line at the top of the file the agent reads and have both the wrapper script and the prompt check it first. Flipping it to paused stops the next run without touching the scheduler.