Most people use Claude Code as an interactive REPL. But there's a second mode — headless, non-interactive — that turns it into a scriptable tool you can wire into bash pipelines, git hooks, cron jobs, and CI.
The same model. None of the conversation. Just input and output.
The -p Flag
-p (or --print) runs Claude in print mode. Prompt goes in, result comes to stdout, Claude exits.
# One-shot
claude -p "Generate a TypeScript interface for a blog post"
# Pipe a file
cat src/lib/auth.ts | claude -p "Review this for security issues. Be specific."
# Multiple files
cat src/lib/actions/user.ts src/types/user.ts | claude -p "Are these types consistent?"
Output Formats
# Plain text (no markdown)
claude -p "List 3 improvements" --output-format text < src/lib/payments.ts
# JSON — pipe into jq
claude -p 'Analyze and return JSON: { "issues": [{"severity","description","line"}] }' \
--output-format json < src/lib/auth.ts \
| jq '.issues[] | select(.severity == "high")'
Real Script: Auto-Changelog
#!/bin/bash
# scripts/generate-changelog.sh
SINCE=${1:-"1 week ago"}
COMMITS=$(git log --oneline --since="$SINCE")
[ -z "$COMMITS" ] && echo "No commits since $SINCE" && exit 0
CHANGELOG=$(claude -p "$(cat <<EOF
Generate a changelog entry in markdown for these git commits.
Group by: Features, Bug Fixes, Improvements, Other.
Use bullet points. Be concise and developer-friendly.
Commits:
$COMMITS
EOF
)" --output-format text)
echo "## $(date +%Y-%m-%d)" > /tmp/entry.md
echo "" >> /tmp/entry.md
echo "$CHANGELOG" >> /tmp/entry.md
echo "" >> /tmp/entry.md
cat /tmp/entry.md CHANGELOG.md > /tmp/full.md
mv /tmp/full.md CHANGELOG.md
echo "Changelog updated."
./scripts/generate-changelog.sh # since 1 week ago
./scripts/generate-changelog.sh "1 day ago"
Real Script: Commit Message Generator
#!/bin/bash
# Usage: git diff --staged | ./scripts/commit-msg-gen.sh
DIFF=$(cat)
[ -z "$DIFF" ] && echo "No staged changes." && exit 1
claude -p "$(cat <<EOF
Generate a conventional commit message for this diff.
Format: <type>(<scope>): <description>
Types: feat, fix, refactor, docs, test, chore, perf
Keep under 72 characters. Output the message only.
Diff:
$DIFF
EOF
)" --output-format text
# Preview
git diff --staged | ./scripts/commit-msg-gen.sh
# Use directly
git commit -m "$(git diff --staged | ./scripts/commit-msg-gen.sh)"
Real Script: Batch PR Review
#!/bin/bash
# scripts/pr-review.sh
BASE=${1:-"main"}
FILES=$(git diff --name-only "$BASE"...HEAD)
echo "# PR Review Report — $(date)" > pr-review.md
for file in $FILES; do
case "$file" in *.md|*.json|*.lock|*.svg) continue ;; esac
[ ! -f "$file" ] && continue
echo "Reviewing $file..."
REVIEW=$(cat "$file" | claude -p "$(cat <<EOF
Review for: bugs, security issues, missing error handling, performance.
Be specific with line numbers. Say "Looks good." if nothing to flag.
File: $file
EOF
)" --output-format text)
echo "## $file" >> pr-review.md
echo "$REVIEW" >> pr-review.md
echo "" >> pr-review.md
done
echo "Done → pr-review.md"
Git Hook: Auto-suggest Commit Messages
# .git/hooks/prepare-commit-msg
#!/bin/bash
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
if [ "$COMMIT_SOURCE" = "" ]; then
STAGED=$(git diff --staged)
if [ -n "$STAGED" ]; then
SUGGESTION=$(echo "$STAGED" | claude -p \
"Write a conventional commit message. One line, under 72 chars." \
--output-format text 2>/dev/null)
if [ -n "$SUGGESTION" ]; then
echo "# Suggested: $SUGGESTION" > /tmp/msg
cat "$COMMIT_MSG_FILE" >> /tmp/msg
mv /tmp/msg "$COMMIT_MSG_FILE"
fi
fi
fi
chmod +x .git/hooks/prepare-commit-msg
In CI/CD
# .github/workflows/claude-review.yml
- name: Review changed files
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
FILES=$(git diff --name-only origin/main...HEAD)
for file in $FILES; do
case "$file" in
*.ts|*.tsx|*.js|*.jsx)
cat "$file" | claude -p "Flag security issues or bugs. Be specific." \
--output-format text \
--dangerously-skip-permissions \
>> review.txt
;;
esac
done
cat review.txt
Error Handling
RESULT=$(cat src/main.ts | claude -p "Analyze" --output-format text)
[ $? -ne 0 ] && echo "Claude failed" >&2 && exit 1
echo "$RESULT"
When to Use Headless vs Interactive
Use -p for: well-defined tasks, batch file processing, CI pipelines, automation with known input/output.
Use interactive for: exploratory work, refactoring sessions that need back-and-forth, anything where you'd naturally say "yes, do that" or "try this instead."
Don't force complex context-heavy tasks into a single -p call. The interactive session can read files, adjust course, and ask clarifying questions in ways one-shot prompts can't.
Full guide at stacknotice.com/blog/claude-code-headless-scripting-2026
Top comments (6)
Headless is great for the well-defined tasks you list, and your closing warning is the tell: don't force context-heavy work into a single -p call. The reason it strains is that every claude -p is stateless. Your cron job, git hook, and CI step each start from zero, so a decision you made last run (this dep is pinned for a reason, that file is generated so leave it, we tried approach X and reverted) is gone, and the one-shot re-derives or quietly contradicts it. The cheap fix that kept my automation honest was giving those headless calls a file to read and write: a small Markdown index of decisions that carry a status, cat it into the top of the prompt and append what changed at the end, so the stateless call inherits the why instead of guessing it. I packaged that pattern as an open thing for Claude Cowork that ports straight to a Claude Code setup since it is plain Markdown, cowork-os, MIT. If it saves you re-explaining context to your scripts, a star helps me prioritize. Which script would benefit most from reading that decision file first, the PR review or the changelog?
That's a sharp observation. The stateless problem hits PR review workflows much harder than most other automation because the reviewer has no memory of previous decisions. It evaluates the current diff in isolation, even when the team has already acknowledged and accepted certain issues.
A reviewer that flags the same authentication module as "too complex" on every PR—because it genuinely is, and because you're actively refactoring it—quickly becomes background noise. The signal is technically correct, but operationally useless.
The changelog case is different because the input is naturally bounded. It only needs to process new commits since the last run, so it's not repeatedly rediscovering facts about the codebase. It's summarizing recent history rather than reinterpreting long-term architectural decisions.
The markdown decision-file pattern is essentially applying the idea behind CLAUDE.md to automated workflows. The intuition is sound: headless agents need a lightweight state layer that survives between runs. Without it, every execution starts from zero and ends up re-litigating decisions that were already made weeks ago.
One design constraint I'd add is that decision files should be treated more like operational state than historical records. Keep entries short, dated, and focused on active decisions. Once a decision file starts accumulating months of context, it begins competing with the actual task prompt for attention, which recreates the context-management problem you're trying to solve.
The harder question is deciding what deserves persistence. Things like:
Skip generated files during review.
This module is already scheduled for refactoring.
This warning is a known issue until migration X is complete.
Use pattern Y for new implementations.
Those tend to age well because they influence current behavior.
Historical notes are trickier. Entries such as "we tried approach X and reverted it" can be valuable, but they also decay quickly. Six months later the constraints may have changed, the original reasoning may no longer apply, and the note becomes a stale constraint rather than useful context.
The pattern that seems most robust is treating persistent state as current policy, not project history. Policy helps the agent make decisions today. History explains why decisions were made. The first belongs in a decision file; the second usually belongs in documentation, ADRs, or commit history.
Policy versus history is the cleanest cut I have read on this, and "persistent state as current policy, not project history" is the line doing the work. I am stealing it.
The part I keep wrestling with is that the same entry is both, just at different times. "We tried approach X and reverted it" is live policy the day after (do not reattempt X), and it quietly turns into history the moment the constraint that killed X is gone. So the file's real job is not only to separate the two, it is to demote an entry from policy to history when its condition expires. Skip that and you get exactly the stale constraint you flagged: a policy line still asserting a rule whose reason left months ago.
That demotion will not happen on its own, and a human remembering to move lines is the thing that always fails. What works for me is a fixed review step at the close of each task: re-justify what stays as active policy, let the rest fall to a dated log. I open-sourced that habit as cowork-os for Claude Cowork, plain Markdown, your files stay yours, MIT. A star helps if it is useful.
So back to you: when a policy line ages out, what is your trigger to demote it? Do you attach an expiry or condition to the entry, or is it manual review, and if manual, what stops it from rotting the same way the CLAUDE.md line did?
The temporal decay framing is exactly right, and I think "same entry, different context" is the part most discussions around CLAUDE.md miss. The policy itself doesn't become wrong—the assumptions that justified it quietly disappear.
Your # because: annotation is a nice refinement because it captures the rationale in a structured way rather than relying on future-you to reconstruct it. Framing every policy as one of three types—a condition, a temporary constraint, or an ownership decision—also keeps the metadata lightweight enough that people will actually maintain it.
I think the next step is treating those annotations as review questions rather than metadata. Instead of reading a policy and asking, "Should this still exist?", you ask a much narrower question: "Is the reason this exists still true?" That's a significantly easier judgment to make.
I also like your observation about review cadence. The biggest obstacle isn't deciding whether a policy is stale; it's convincing yourself to do the review at all after you've already finished the real work. Limiting the review to only the entries touched during the task is a clever way to keep the cost proportional to the change. If it consistently stays under a couple of minutes, it's much more likely to become habit.
Where I think automatic expiry falls short is exactly where you pointed: most engineering constraints don't expire because time passes. They expire because reality changes. A dependency gets upgraded, a migration finishes, an architectural assumption disappears, or a performance bottleneck is eliminated. Those are state transitions, not calendar events, and they're difficult to encode without building an increasingly sophisticated rule system.
That's why I like separating active policy from historical decisions. They're serving different purposes. An active policy file answers, "What constraints should I follow right now?" A decision log answers, "Why did we make this choice?" Mixing the two gradually turns the policy file into an archive, while keeping them separate lets each evolve on its own cadence.
In that sense, the structure itself becomes the maintenance mechanism. Instead of trying to make every individual policy line self-expiring, you make the active policy document intentionally small. Anything that no longer influences today's decisions gets demoted to the historical log. That shifts the burden from remembering to annotate every rule perfectly to maintaining a clear distinction between what's operational and what's merely informative.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.