Claude Code · Advanced/Lesson 04 of 07~5 min

Script Claude Code with claude -p

Everything so far had you at the keyboard. claude -p is the same engine with the interface removed — one prompt in, one answer out, one exit code. Scripts and CI start here.

The Third Way engineering team||Verified against Claude Code v2.1.285 (stable), October 2026

01The goal

Write a script that runs Claude Code unattended, reads its JSON result and exit code, and limits the run to the tools and turns the job needs.

02The idea

claude -p "<prompt>" runs one prompt without the interactive interface, prints the answer, and exits 0 — or non-zero when the run fails. It reads stdin, so a script can pipe a diff in alongside the prompt.

With --output-format json you get one JSON object instead of prose: result holds the answer; is_error says whether the run failed inside; num_turns and session_id describe the run; permission_denials lists every tool call that was refused. A subtype field distinguishes success from the ways a run stops early — error_max_turns among them — and a run that stops at a limit has no result at all. Check the exit code and is_error before you trust result.

Nobody is there to answer a permission prompt, so pass a mode every time — a run where nothing sets one can start in auto mode. dontAsk is the mode built for this: it denies every call that would otherwise prompt, while reads, read-only commands, and anything your allow rules name still run. Two flags complete the fence: --allowedTools names what may run anyway, and --max-turns caps the turns. (--max-turns counts only the turns that call tools.)

03The walkthrough

  1. 01Ask one question and save the JSON: claude -p "In one sentence, what does this repository do?" --output-format json --permission-mode dontAsk > first.json — then echo $? for the exit code, and read the fields with jq. Reads in the working directory need no approval, so dontAsk let Claude look around freely.
  2. 02Ask for something that needs approval: "Run the test suite and say how many tests pass", same flags plus --setting-sources user so the run skips the project’s settings files — its allow rules and its sandbox auto-allow cannot approve the test command now. The run finishes, but permission_denials lists the refused Bash call and result explains that the tests could not run.
  3. 03Allow exactly that command, and cap the turns: add --allowedTools "Bash(npm test *)" --max-turns 3. This time subtype is success and the denials list is empty. The space before the * matters — and the trailing " *" also matches the bare command. A pipeline such as npm test 2>&1 | tail -20 passes too: each part is checked on its own, and tail is one of the read-only commands that never prompt.
  4. 04Hit the cap on purpose: a task that needs read-then-run in sequence, with --max-turns 1 and --allowedTools "Read,Bash(npm test *)". The exit code is non-zero, subtype is error_max_turns, and there is no result field. That is what a stopped run looks like — decide now how your script will treat one.

04Your turn

Write scripts/review-staged.sh — a review you can run before every commit. It pipes git diff --cached into one claude -p call, with the prompt right after -p; passes --output-format json, --permission-mode dontAsk, an --allowedTools list of exactly Read, Grep and Glob (on macOS, Linux and WSL, naming either Grep or Glob in the list brings both back), and a --max-turns between 1 and 10; saves the JSON to the path in its first argument; prints the result text; exits non-zero when claude does or is_error is true; and when nothing is staged, says so and exits non-zero without calling Claude at all. Commit it, stage a one-line change, run it once, and read the review before you commit that change. The capstone reuses this script.

↳The one line at the heart of it
git diff --cached | claude -p "Review these staged changes." \
--output-format json \
--permission-mode dontAsk \
--allowedTools "Read,Grep,Glob" \
--max-turns 8 > "$out"
The diff arrives on stdin, so the reviewer needs no shell to read it. Everything else is limits: a mode that cannot prompt, three read-only tools, and a turn cap.

Three unattended-run edges to keep. A -p run shows no trust dialog, but it still runs the hooks in a project’s .claude/settings.json and connects the servers in its .mcp.json — even in a folder you have never trusted. Before you script Claude Code in a repository you did not write, pass --setting-sources user. When the ANTHROPIC_API_KEY environment variable is set, a -p run always uses that key instead of your plan. And total_cost_usd in the JSON is a client-side estimate, not your bill.

↳Check yourself
Q1

What does --permission-mode dontAsk do with a call that would normally prompt?

  1. AIt queues it for when you return
  2. BIt denies it — reads, read-only commands and allow-rule matches still run
  3. CIt approves it for the session
→Check your answer
Answer: B

dontAsk auto-denies anything that would prompt. Reads and read-only commands never prompt, and --allowedTools entries are pre-approved — that trio is the whole least-privilege toolkit for unattended runs.

Q2

A run stops at --max-turns. What does the JSON look like?

  1. Asubtype success with a short result
  2. Bsubtype error_max_turns, and no result field
  3. Cis_error true and result null
→Check your answer
Answer: B

A capped run stops early with subtype error_max_turns and produces no result — one more reason to check subtype before trusting result.

Q3

Why pass --setting-sources user before scripting inside a repo you did not write?

  1. AIt skips the project’s settings files — hooks, allow rules, .mcp.json servers
  2. BIt switches to API-key billing
  3. CIt disables the sandbox
→Check your answer
Answer: A

A -p run executes a repository’s hooks and MCP servers even in a never-trusted folder. --setting-sources user leaves the project’s settings out of the run — and its deny rules out too, so pass those you need on the command line.

The Third Way engineering team
Delivery engineering
Work with us