Scripting Claude Code: Headless Runs, Pipes, Cron and Git Hooks
PLClaude Code scripting means running claude -p (print mode) from a shell script, git hook, cron job or CI step instead of an interactive session. A scripted run starts in Manual permission mode (auto mode on third-party providers or with telemetry off, from v2.1.285) with nobody to approve prompts, so it works only when the script grants exact permissions, caps turns and spend, and checks the JSON result.
You have a prompt that works: pipe in the diff, ask for a changelog entry, append it to CHANGELOG.md. You run it by hand before every release and retype it from memory each time. Then you move it into a cron job and it “succeeds” every night for a week while writing nothing, because every file edit was denied and the script only checked that the process exited. This page is for the developer or tech lead who wants those runs to be repeatable and to fail loudly. It assumes Claude Code is installed and signed in; the Automate section overview shows where scripting sits among hooks, routines and the Agent SDK.
What you get from scripting Claude Code
Section titled “What you get from scripting Claude Code”- A wrapper script that runs one unattended job, retries only transient failures, and exits non-zero unless the run really succeeded.
- Permission recipes for scripts, from read-only reports to scoped edits, plus the one project-settings trap that silently strips your allow rules.
- A pre-commit gate that returns a schema-validated verdict instead of text you have to grep.
- A cron job pattern with authentication, a throwaway branch, and a report a human merges.
- Copy-paste prompts for a release skill, a bulk migration, and a maintenance job.
Which automation surface fits the job?
Section titled “Which automation surface fits the job?”Pick the surface before you write the prompt. Most of the bugs in this page’s failure section come from running a job on the wrong one.
| You want to… | Use | Why |
|---|---|---|
| Repeat a multi-step task while you watch | A skill (/release) in an interactive session | You can answer questions and approve risky steps. |
| Run one job from a shell script, Makefile or git hook | claude -p | Stdin in, result out, exit code for the caller. The subject of this page. |
| Run a job on a schedule while your laptop is off | Routines | Cloud sessions on Anthropic infrastructure; research preview, one-hour minimum interval. |
| Run a job on a schedule on your own machine or server | cron + claude -p | Your network, your credentials, any interval. |
| Review PRs or react to issues in GitHub | anthropics/claude-code-action@v1 | Runs on GitHub events and schedules, posts to the PR. |
| React to every edit Claude makes inside a session | Hooks | Deterministic shell commands on lifecycle events, no prompt involved. |
| Build a long-lived program around the agent loop | Claude Agent SDK | Sessions, callbacks and permissions from Python or TypeScript. |
How does claude -p differ from an interactive session?
Section titled “How does claude -p differ from an interactive session?”Five differences decide whether a script works. All were checked against Claude Code 2.1.283 and the headless mode docs on 2026-09-26, except the v2.1.285 and v2.1.286 changes in items 1 and 5, which come from the Claude Code changelog (checked 2026-10-12).
- It starts in Manual mode on every plan. Auto mode is the interactive default from v2.1.283 (both release channels on 2026-10-12), but not for
-p, except on third-party providers or with telemetry off, where-pstarts in auto mode from v2.1.285. In Manual mode anything that would prompt is denied, because nobody is there to answer, and the run continues without it. - It skips the workspace trust dialog. The project’s hooks, its
envblock and every server in.mcp.jsonstill load and run. Thepermissions.allowrules in the project’s.claude/settings.jsondo not apply in a folder you never trusted interactively; Claude Code prints athis workspace has not been trustedwarning to stderr. A fresh CI or cron checkout is exactly such a folder, so pass permissions on the command line. - The exit code is meaningful. It is
0on success and non-zero when the run fails, including when--max-turnsis reached. A failure inside the run, such as missing authentication, is printed as the result on stdout. SIGTERM exits with143and records no result. - Stdin is capped at 10 MB. Above that, Claude Code exits with an error. Write larger inputs to a file and name the path in the prompt.
--bareskips local configuration. It skips hooks, plugins, auto memory and the auto-discovery of skills, MCP servers andCLAUDE.md, and it authenticates only withANTHROPIC_API_KEY(or anapiKeyHelperpassed through--settings), never your subscription login. Since v2.1.286 it also connects only the MCP servers named on the command line, sends no system reminders and starts no background tasks.claude --help(2.1.283) also states that skills “still resolve via /skill-name”, so a skill named explicitly in the prompt may still expand. Use--barewhen a job must behave the same on every machine, and run a skill-driven job under--bareonce before you rely on either behaviour; leave it off when the job depends onCLAUDE.md. The headless docs recommend--barefor scripts and say it will become the default for-pin a future release, so a job that depends onCLAUDE.mdshould load it explicitly (for example with--append-system-prompt-file CLAUDE.md) rather than rely on auto-discovery.
The output formats:
--output-format | You get | Use it for |
|---|---|---|
text (default) | The final answer only | Output a human reads, or a file you commit |
json | One result object: subtype, is_error, result, total_cost_usd, permission_denials, session_id | Every script that branches on the outcome |
stream-json | Newline-delimited events as they happen | Live progress and long-running jobs |
Add --json-schema with --output-format json and the validated object arrives in structured_output. That is the difference between parsing prose with grep and reading a field.
Grant a script exactly the permissions it needs
Section titled “Grant a script exactly the permissions it needs”Decide what the job may do before it runs, and write it on the command line so the grant travels with the script.
| Job | Flags |
|---|---|
| Read-only report or review | --permission-mode dontAsk (reads in the working directory and read-only commands still run) |
| Report that runs specific commands | --permission-mode dontAsk --allowedTools "Bash(npm audit *),Bash(npm outdated *)" |
| Edits confined to one directory | --permission-mode dontAsk --allowedTools "Edit(./src/api/**),Bash(npm test *)" |
| Broad changes where a classifier should judge each action | --permission-mode auto --permission-prompts none |
| Anything goes | --dangerously-skip-permissions, only inside a disposable sandbox with no network and no secrets |
Three rule details matter in scripts. The trailing * in Bash(npm test *) is a prefix match, and the space before * stops it matching npm test-e2e. Path rules are checked for Edit(...) and Read(...) only: a Write(reports/**) rule is accepted and never consulted, so write Edit(./reports/**) instead. And dontAsk denies everything that would prompt, which is what you want when nobody is watching: reads in the working directories and read-only commands still run, and your allow rules add the rest.
Write a wrapper that fails loudly
Section titled “Write a wrapper that fails loudly”Every unattended job on this page goes through one wrapper. It retries only when there is no result at all (a network or process failure), never when Claude ran and stopped for a reason, because a second attempt at a job that hit its turn cap only doubles the bill.
#!/usr/bin/env bash# scripts/claude-job.sh — run one unattended Claude Code job; exit non-zero unless it succeeded.# Usage: scripts/claude-job.sh "PROMPT" [extra claude flags...]set -euo pipefail
PROMPT="$1"; shiftRUN_DIR="${RUN_DIR:-.claude-runs}"mkdir -p "$RUN_DIR"OUT="$RUN_DIR/$(date +%Y%m%dT%H%M%S).json"
for attempt in 1 2 3; do claude -p "$PROMPT" \ --output-format json \ --permission-mode dontAsk \ --max-turns 25 \ --max-budget-usd 3.00 \ "$@" > "$OUT" 2>> "$RUN_DIR/stderr.log" && break # A result object means Claude ran and stopped on purpose: retrying will not help. jq -e '.type == "result"' "$OUT" > /dev/null 2>&1 && break [ "$attempt" -lt 3 ] || break # no sleep after the last attempt echo "attempt $attempt produced no result; retrying in $((attempt * 30))s" >&2 sleep $((attempt * 30))done
subtype=$(jq -r '.subtype // "missing"' "$OUT" 2> /dev/null || echo missing)is_error=$(jq -r '.is_error' "$OUT" 2> /dev/null || echo null)cost=$(jq -r '.total_cost_usd // 0' "$OUT" 2> /dev/null || echo 0)denied=$(jq -r '(.permission_denials // []) | length' "$OUT" 2> /dev/null || echo 0)echo "subtype=$subtype is_error=$is_error cost_usd=$cost denials=$denied" >&2
if [ "$denied" != "0" ]; then echo "denied tool calls (grant them or change the prompt):" >&2 jq -c '.permission_denials[]' "$OUT" >&2 [ "${ALLOW_DENIALS:-0}" = 1 ] || exit 1 # a denied call fails the job unless the caller opts infi[ "$subtype" = "success" ] && [ "$is_error" = "false" ] || exit 1jq -r '.result' "$OUT"Callers add their own permissions after the prompt, for example scripts/claude-job.sh "Summarize open TODOs" --allowedTools "Bash(git log *)". A run with any denied tool call exits 1 even when subtype is success, because that is exactly the job that “succeeds” while writing nothing. A job that expects some denials, such as a report that may try a command you chose not to grant, opts in with ALLOW_DENIALS=1 scripts/claude-job.sh …. --max-turns is accepted but not listed in claude --help (checked on 2.1.283; a capped run exits 1 with subtype error_max_turns); --max-budget-usd is the documented cap. The JSON files in .claude-runs/ are your audit trail: add the directory to .gitignore and keep the files for as long as you keep CI logs.
Leave the model on Claude Code’s default. For a high-volume report where cost dominates, add --model with a cheaper alias such as sonnet only after your own checks show the output still holds up; the models hub lists the current options and prices.
Pipe build output, diffs and logs into Claude
Section titled “Pipe build output, diffs and logs into Claude”Piping is the cheapest integration: the data arrives on stdin, so Claude needs no Bash permission to fetch it.
# Explain a failing build without letting Claude touch anythingnpm run build 2>&1 | claude -p "Explain the root cause of each distinct error, most fundamental first. Name the file and line. Do not propose fixes for errors caused by an earlier one."
# A project-specific typo linter as an npm script (from the headless docs)git diff main | claude -p "you are a typo linter. for each typo in this diff, report filename:line on one line and the issue on the next. return nothing else."
# A changelog entry from commits since the last taggit log --oneline "$(git describe --tags --abbrev=0)..HEAD" \ | claude -p "Write a Keep a Changelog entry for version 2.4.0 dated $(date +%F). Group under Added, Changed, Fixed, Removed. One line per user-visible change, past tense. Drop chore and CI commits." \ > /tmp/changelog-entry.mdRedirect the output to a file and review it before it goes anywhere; a pipe that writes straight into CHANGELOG.md turns one bad run into a bad commit.
Turn a repeated prompt into a skill your scripts can call
Section titled “Turn a repeated prompt into a skill your scripts can call”When a prompt has more than three steps, stop pasting it. Custom commands are now skills: a file at .claude/skills/release/SKILL.md and the older .claude/commands/release.md both create /release, and existing command files keep working. The custom commands guide covers the format; the parts that matter for scripting are disable-model-invocation: true, so Claude never starts a release on its own initiative, and $ARGUMENTS, which receives whatever follows the command name.
A -p run expands a skill named in the prompt, so the same file serves you interactively and your scripts unattended:
scripts/claude-job.sh "/release minor" \ --allowedTools "Edit(./package.json),Edit(./CHANGELOG.md),Bash(npm test *),Bash(git add *),Bash(git commit *),Bash(git tag *)"The allow list leaves out git push on purpose. The script tags locally; a human or CI pushes after reading the tag’s diff.
Gate commits with a pre-commit review
Section titled “Gate commits with a pre-commit review”A git pre-commit hook runs before every commit, in your terminal, whether or not Claude wrote the code. That makes it a different thing from a Claude Code hook, which runs on Claude’s own tool calls inside a session. Use the git hook for a last check on the staged diff; use Claude Code hooks for formatting and blocking inside the session.
This hook asks for a verdict against a JSON schema, so the script reads a field instead of grepping for BLOCK: in prose that might say “no BLOCK: issues found”.
#!/usr/bin/env bash# .git/hooks/pre-commit — block the commit on a critical finding in the staged diffset -euo pipefail
diff=$(git diff --cached --no-color)[ -z "$diff" ] && exit 0
gitleaks git --staged # deterministic secret scan first; exits 1 on a leak and blocks the commit
schema='{"type":"object","properties":{"verdict":{"type":"string","enum":["pass","block"]},"findings":{"type":"array","items":{"type":"string"}}},"required":["verdict","findings"]}'
if ! out=$(printf '%s' "$diff" | claude -p \ "Review this staged diff for critical problems only: credentials or API keys in the code, SQL built by string concatenation, a removed authorization check, an error swallowed in auth or payment code. Ignore style and naming. Return verdict block only for a problem you can name by file and line; put each one in findings as 'file:line - problem'." \ --output-format json --json-schema "$schema" \ --permission-mode dontAsk --max-turns 3 --max-budget-usd 0.50); then echo "pre-commit: Claude review did not complete; commit allowed. Re-run before you push." >&2 exit 0fi
if [ "$(jq -r '.structured_output.verdict // "missing"' <<< "$out")" = "block" ]; then echo "pre-commit: blocked by Claude review" >&2 jq -r '.structured_output.findings[]' <<< "$out" >&2 echo "Fix the findings, or bypass once with: git commit --no-verify" >&2 exit 1fiSave it as .git/hooks/pre-commit and run chmod +x .git/hooks/pre-commit: git silently skips a hook that is not executable, so a copied file without the bit is a gate that never runs.
Two design decisions are baked in. The hook fails open when Claude cannot run (offline, rate-limited), because a gate that blocks every commit on a network error gets deleted within a day; the CI review is the backstop. And a model is a second line for secrets, not the first: the hook runs Gitleaks (open source, about 29.5k GitHub stars in September 2026) before the Claude call, because pattern matching catches a known key format every time and a model catches it most of the time. Install it with brew install gitleaks (the README also lists Docker images and a source build); gitleaks git --staged scans only what you are about to commit. Before v8.19.0 the same scan was gitleaks protect --staged, which still works but is hidden from --help.
Keep the hook fast. A pre-commit check that takes a minute trains people to type --no-verify. If your diffs are large, run this in pre-push instead.
Schedule recurring jobs with cron
Section titled “Schedule recurring jobs with cron”Use cron when the job needs your network, your credentials, or an interval under an hour; use routines when it should run with your laptop closed. A cron job that edits code follows four rules: its own checkout, a throwaway branch, least-privilege flags, and a report that a person reads.
#!/usr/bin/env bash# crontab: 7 6 * * 1 /opt/jobs/dep-audit.sh >> /var/log/dep-audit.log 2>&1set -euo pipefail
export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" # cron's PATH lacks the native installsource "$HOME/.config/claude-cron.env" # exports CLAUDE_CODE_OAUTH_TOKENcd /srv/checkouts/billing-api # a checkout no human works ingit fetch -q origin && git checkout -q -B chore/dep-audit origin/main
/opt/jobs/claude-job.sh \ "Run npm audit --json and npm outdated --json. For each high or critical advisory, grep our src/ for imports of the affected module and mark it reachable or not, naming the importing file. Write reports/dep-audit.md with one table: package, severity, reachable, fixed version, breaking changes in that version. Do not edit package.json or the lockfile." \ --allowedTools "Bash(npm audit *),Bash(npm outdated *),Edit(./reports/**)" \ > /dev/null || { echo "dep-audit failed $(date -Is)" >&2; exit 1; }
git add reports/dep-audit.mdgit commit -q -m "chore: weekly dependency audit $(date +%F)" || exit 0 # nothing changedgit push -q -f origin chore/dep-auditgh pr create --fill --head chore/dep-audit 2> /dev/null || true # reuses the open PR on later weeksAuthentication is the part that breaks first. Cron has no browser and no keychain session, so generate a one-year token with claude setup-token and export it as CLAUDE_CODE_OAUTH_TOKEN from a file only the job’s user can read, or use an ANTHROPIC_API_KEY if the job bills to the API. The last line opens a pull request the first time; on later weeks gh pr create fails because one is already open, and the force-push has updated it. The minute is 7, not 0: jobs scheduled on the hour pile up with everyone else’s.
Wire the failure line into whatever pages your team. A scheduled job whose failures land only in a log file is a job nobody knows has stopped.
Run bulk changes as a loop, not one giant prompt
Section titled “Run bulk changes as a loop, not one giant prompt”A single prompt that says “migrate every file” gives you one enormous diff and no way to tell which file broke the build. A shell loop gives each file its own run, its own test gate and its own commit:
for f in $(git grep -l "from 'moment'" -- ':(glob)src/**/*.ts'); do scripts/claude-job.sh "Migrate $f from moment to date-fns. Convert format tokens (YYYY-MM-DD becomes yyyy-MM-dd). Change only this file." \ --allowedTools "Edit(./$f)" > /dev/null \ && [ "$(git diff --name-only)" = "$f" ] \ && npx vitest related "$f" --run \ && git add -- "$f" && git commit -qm "refactor: $f moment -> date-fns" \ || { echo "FAILED: $f" >> migration-failures.txt; git checkout -- . && git clean -fdq src/; }doneThe edit grant names only the current file, and the git diff --name-only check fails the iteration if any tracked file other than that one changed, so “change only this file” is enforced rather than requested. The tests run in the loop, after Claude exits, so a run that skips or misreports them cannot produce a commit. The :(glob) prefix makes ** also match files directly under src/, and git clean removes any new file a failed run left behind.
For changes across dozens of independent units in one session, the bundled /batch skill splits the work into 5 to 30 units, each in its own worktree; see batch operations. The loop above is the right tool when you want one commit per file and a list of the ones that failed.
How do you verify what an unattended run did?
Section titled “How do you verify what an unattended run did?”Nobody reads every line of a nightly job’s diff, and nobody should have to. Check the evidence instead, in this order:
- The run succeeded. The wrapper’s exit code,
subtypeofsuccess,is_erroroffalse, and an emptypermission_denialslist. A run with denials may have “succeeded” at a smaller job than the one you asked for. - The script ran the checks, not the model. The job ends with
npm testornpx tsc --noEmitexecuted by your script after Claude exits. A model’s summary saying “all tests pass” is not a test result. - The change stayed in bounds.
git diff --stat origin/main...HEADshows only the paths the allow list permits, and the file count is in the range you expect. Fail the job if a lockfile changed when the prompt said it must not. - Structured output validated. When a script consumes Claude’s answer, it reads
structured_outputproduced under--json-schema, never free text. - A second agent reviewed the diff. For code changes, run
/code-review(orclaude ultrareviewfrom the shell for a cloud multi-agent review, billed per run after 3 free runs on Pro and Max and not available to Zero Data Retention organizations, and not on Amazon Bedrock, Google Cloud or Microsoft Foundry) on the branch before a human looks. - Spend is logged.
total_cost_usdfrom every run goes into the log; a job whose cost doubles week over week is doing something new.
The human sign-off is the merge. Scheduled jobs push branches and open pull requests; they do not push to main. For the full package a reviewer should receive, see the evidence bundle.
When scripted Claude Code runs break
Section titled “When scripted Claude Code runs break”The job “succeeds” and changes nothing. Every write was denied: -p starts in Manual mode (auto mode on third-party providers or with telemetry off, from v2.1.285) and project permissions.allow rules do not apply in an untrusted checkout. Recovery: print permission_denials from the JSON result, then add the exact rules with --allowedTools on the command line.
The job stops with error_max_turns or error_max_budget_usd. The cap did its job. Do not retry with the same prompt; narrow the task (one directory, one package) or raise the cap once and watch num_turns in the result to see how much it needed.
The cron job works by hand and fails at 06:07. Cron’s environment has a minimal PATH and no login session. Recovery: set PATH explicitly in the script, export CLAUDE_CODE_OAUTH_TOKEN from claude setup-token, and test with env -i HOME="$HOME" /bin/bash /opt/jobs/dep-audit.sh to reproduce cron’s bare environment.
A cloned repository runs its own hooks and MCP servers. Under -p the trust dialog never appears, so a repository’s settings hooks run and its .mcp.json servers connect. Recovery: for code you did not write, run with --bare --setting-sources "" --strict-mcp-config. Bare mode skips auto-discovery of hooks, skills, plugins, MCP servers and CLAUDE.md; --setting-sources "" loads no settings file at all, so neither the repository’s settings nor your own user settings (with their env block and allow rules) apply, and --strict-mcp-config ignores its .mcp.json. The two flags close what bare mode alone leaves open and keep the intent explicit if a later release changes what bare mode reads. And never run such a job with secrets in the environment that the repository’s own code could reach.
--bare fails with an authentication error. Bare mode never reads your subscription login. Recovery: export ANTHROPIC_API_KEY, or drop --bare and accept that local configuration loads.
A background dev server stops after 30 minutes, or the job waits for it. In an unattended session (-p, the Agent SDK, CI, or a cloud session), a background Bash or PowerShell command stops when its time limit ends: the timeout Claude passes with run_in_background, or 30 minutes by default, up to 2 hours. In a -p run whose prompt you pass as text, both defaults are 10 minutes, and the run stays open after its result until a background command that the main conversation started exits or reaches that limit. The limit arrived in v2.1.285 and applied in every session until v2.1.288, so on stable (2.1.287 on 2026-10-12) it also stops background commands in an interactive terminal; from v2.1.288 (latest only) terminal, desktop app, and VS Code sessions have no limit. Recovery: start long-lived services in a separate CI step, outside Claude Code, or raise the limit with BASH_DEFAULT_TIMEOUT_MS above 1800000 (above 600000 for a text prompt) or BASH_MAX_TIMEOUT_MS above 7200000; a lower value never shortens a limit. Source: Claude Code tools reference and headless documentation, checked 2026-10-12.
A parser breaks on a new phrasing. Text output varies between runs. Recovery: switch to --output-format json with --json-schema and read structured_output.
The retry loop exits on the first failure. Under set -e, a plain result=$(claude -p …) that fails ends the script before your retry logic runs. Recovery: test the command inside &&, || or if, as the wrapper above does.
/release runs something else. A skill and a command file with the same name resolve to the skill, and a personal skill in ~/.claude/skills/ wins over a project skill of the same name, so the script can run a different file on your laptop than on the cron host. Recovery: give automation skills specific names such as /release-prep, and type / in a session to see what a name resolves to.
The job is killed and leaves half a change. SIGTERM exits with 143 and records no result. Recovery: send SIGINT to let the turn end, and always run jobs on a branch so git checkout -- . is a safe reset.