skribbl
productpricingfree!questionswriting
download
guides · 9 August 2026 · 5 min read

Claude Code headless mode: scripting an agent

The same agent, minus the terminal, plus a JSON stream you can actually parse.

skribbl/writing/guides
claude -p, and what it unlocks

Headless mode runs claude -p "prompt" non-interactively with parseable output and session resuming, and it is the building block for everything that is not a human sitting at a terminal: CI pipelines, cron-triggered agents, scripts that chain several calls together. The real commands, and what changes once nobody is watching the output stream live.

what headless mode is

Running claude with no arguments opens the interactive terminal UI: a REPL that waits for you to type, shows tool calls as they happen, and asks before it does anything destructive. That UI assumes a human is watching. Headless mode drops that assumption. You give it one prompt, it runs to completion, it prints a result, and it exits with a status code, the same shape as any other command-line tool you would put in a script.

The flag is -p, short for --print. It is not a lesser version of Claude Code, it is a different interface to the same agent, built for the cases where nobody is at the keyboard to read the output as it streams: a CI step, a pre-commit hook, a cron job, another program calling out to Claude Code as a subprocess.

the basic invocation

The simplest form takes a prompt as an argument and runs once:

claude -p "Summarize the diff between HEAD and origin/main in three bullet points"

It also reads from stdin, which is the more useful shape for pipelines, since it lets you feed in output from another command without shell-escaping it into an argument:

git diff origin/main | claude -p "Summarize this diff in three bullet points"

By default this still has full tool access, subject to whatever permission settings apply. In a script you usually want to be explicit about that rather than relying on defaults. Two flags matter here: --allowedTools to name what it may use, and --permission-mode to control whether it can ask you anything at all. In a non-interactive context there is nobody to answer a permission prompt, so an unattended run should either pre-approve what it needs or run in a mode that never prompts.

If a permission prompt has nowhere to go because stdin is not a TTY, the run cannot proceed past it. Decide the tool and permission scope up front rather than discovering this mid-pipeline.

output formats: text, json, stream-json

The default output is plain text, the same final answer you would read in the terminal UI, just without the surrounding chrome. For a script that needs to do anything with the result beyond printing it, that is the wrong format, because there is no reliable way to tell the answer apart from the agent narrating its own steps. --output-format fixes that.

json: one object, after the run finishes

claude -p "List every TODO comment in src/" --output-format json

The result is a single JSON object printed once the run completes:

{
  "type": "result",
  "subtype": "success",
  "session_id": "a1b2c3d4-...",
  "result": "Found 4 TODO comments:\n\n1. src/api/client.ts:42 ...",
  "is_error": false,
  "usage": {
    "input_tokens": 812,
    "output_tokens": 143,
    "cache_read_input_tokens": 5120
  },
  "total_cost_usd": 0.0134
}

That shape is easy to pull apart with jq in a shell script: check is_error before trusting result, log total_cost_usd per run, keep session_id if you plan to resume later.

stream-json: one JSON object per line, as it happens

For long-running tasks, waiting for the whole run to finish before you see anything is a real cost, especially in a CI log you want to tail. stream-json emits one JSON event per line as the agent works, so a caller can read incrementally:

claude -p "Refactor the auth module to use the new client" \
  --output-format stream-json | while read -r line; do
    echo "$line" | jq -r '.type'
  done

Each line is a discrete event, a message, a tool call, a tool result, ending in the same result object the plain json format returns as its last line. This is the format to reach for when you are building something that shows progress rather than just collecting a final answer.

resuming a session across separate invocations

Each -p call is, by default, a fresh session with no memory of a previous one. That is fine for one-shot tasks and wrong for anything that needs to build on earlier context, like a CI step that reviews a PR and then, in a later step, replies to a comment about that same review.

--resume reattaches to a prior session using the session_id from its JSON output:

# first call, capture the session id
SESSION_ID=$(claude -p "Review this PR for correctness issues" \
  --output-format json | jq -r '.session_id')

# later, same session, continues with full prior context
claude -p --resume "$SESSION_ID" \
  "The author pushed a fix for the null check. Re-check just that."

--continue is the related shortcut for resuming the most recent session without tracking an ID yourself, useful for a simple retry loop but not for anything running more than one session at a time, where you need the specific ID to avoid picking up the wrong conversation.

using it in CI

A CI step is exactly the headless case: no TTY, no human to answer a prompt, a need for an exit code and a machine-readable result. A minimal GitHub Actions step:

- name: AI review of the diff
  run: |
    git diff origin/main...HEAD > /tmp/diff.txt
    claude -p "Review this diff for bugs. Reply with just PASS or FAIL and why." \
      --output-format json < /tmp/diff.txt > /tmp/result.json

    if [ "$(jq -r '.is_error' /tmp/result.json)" = "true" ]; then
      echo "Claude Code run failed"; exit 1
    fi

    echo "$(jq -r '.result' /tmp/result.json)"
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

The pattern is always the same three parts: pipe in what the agent needs to see, run -p with a JSON output format, check the result programmatically before doing anything else with it. Whether that means gating a merge, filing a follow-up issue, or just posting the text as a PR comment is a decision the surrounding script makes, not something headless mode decides for you.

using it on a schedule

A cron job has the same constraint as a CI step, no interactive terminal, plus one more: nothing kicks it off by hand, so it has to be trustworthy enough to run completely unattended, repeatedly, without accumulating a mess when it fails partway.

# crontab entry: nightly dependency audit at 2am
0 2 * * * cd /srv/myapp && claude -p \
  "Check package.json for outdated dependencies with known CVEs. \
   If any are found, open a summary as /tmp/audit-$(date +\%F).md" \
  --output-format json --permission-mode acceptEdits \
  >> /var/log/claude-audit.log 2>&1

Two things matter more here than in a one-off CI run. First, permissions have to be fully decided in advance, since acceptEdits or an explicit --allowedTools list is the only way an unattended run can act instead of stalling on a prompt nobody will see. Second, logging has to be durable, because a cron job's stdout is not a scrollback you happen to be looking at, it is a log file you will only open after something has already gone wrong.

what headless mode does not give you

The tradeoff is the same one that makes headless mode useful in the first place. The interactive UI shows you tool calls and token spend as they happen because a human is right there to see them. A -p call run from cron at 2am has none of that watching it, by design, which is the whole point until something in the prompt or the repo state causes a run to loop, burn far more tokens than a normal run, or edit files nobody meant to touch.

total_cost_usd in the JSON output tells you what a run cost after it is already over. It does not warn you while a scheduled or CI-triggered agent is mid-run and still climbing, and log files are not something anyone checks in real time. That gap, between an agent running headless somewhere and a person noticing its spend or its activity, is exactly what a always-on view of running agents is for.

  • Know it is running. A cron-triggered or CI-triggered agent does not show up in any terminal you have open.
  • See spend before the run finishes. The JSON result gives you a final number, not a live one.
  • Notice a runaway early. A loop that burns tokens for twenty extra minutes looks identical to a normal run until you check the log.
Skribbl keeps a live menu-bar view of every Claude Code process it can see, headless ones included, with token spend updating while the agent is still working rather than only in the JSON result at the end. Try it, or read how it attaches to running sessions in the docs.
READ NEXT
Running Claude Code in CI/CDThe one setting that must never be on when the trigger is a stranger’s PR.6 minClaude Code hooks: a practical guide with examplesEvery event, the payload it carries, and the exit code that blocks a tool call.12 minClaude Code subagents: a practical guideThe isolation is the feature. Reach for it only when you actually need it.6 min
ON THIS PAGE
what headless mode isthe basic invocationoutput formatsresuming a sessionusing it in CIusing it on a schedulewhat headless mode does not give you
run them on a canvasSkribbl puts every agent, its terminal and what it is spending on one board. macOS, one day free.

get the next one by email.

One email when there is something worth reading. Unsubscribe is one click and it is in every issue.

download
productpricingdocsquestionswhat it iscomparereleaseswritingnewsletterlaunchesprivacycancel
give them infinity.© skribbl