Split diagram contrasting the interactive Claude Code REPL with a scripted claude -p one-shot piped into CI.

Interactive vs Non-Interactive Claude Code: When to Pair, When to Script


You already run claude, watch it read the repo, approve a tool call, nudge it, and move on. That’s one mode. There’s a second one you may not have wired up yet, and it’s the one that turns Claude Code from a thing you talk to into a thing you build with.

Same binary. Same tools. Same agent loop. The only thing that changes is whether you’re steering it live or handing it a job and walking away:

  • claude "prompt" — a live REPL. A pairing session.
  • claude -p "prompt" — one shot, prints to stdout, exits. A Unix command you can pipe, script, and put in CI.

Pick the wrong one and you either babysit a task that should have been a one-liner, or you fire off an unattended run that aborts the first time it needs a decision you didn’t pre-make. Here’s the split I actually use.

Two ways to run the same agent

The mental model is one sentence: the REPL is a conversation; -p is a command that returns a value.

Everything downstream follows from that. In the REPL you’re present — you approve tools mid-run, ask follow-ups, change your mind. With -p you’re not there, so every decision has to be made up front, and the output is something a script consumes. Nothing about the agent’s capability changes between them. What changes is who’s in the loop.

Interactive: claude and claude "prompt"

claude                          # start a session
claude "explain the auth module and suggest a refactor"   # seed it with a first prompt

Both open the REPL. claude "prompt" just skips the first thing you’d type. From there you get the interactive affordances:

  • Mid-run approvals — Claude asks before it runs a tool; you say yes or no.
  • Permission cyclingShift+Tab moves through permission modes without restarting.
  • Follow-ups — the conversation holds context, so you can steer turn by turn.

Reach for this when the task is exploratory, ambiguous, or needs human judgment somewhere in the middle — the cases where you can’t write the whole spec before you start. It’s a one-off you think through with Claude, not a step in a pipeline.

Non-interactive: claude -p / --print

claude -p "What does the auth module do?"

-p (or --print) runs the prompt once, prints the response, and exits. No REPL, no interactivity. Because it reads stdin and writes stdout, it behaves like any other command-line tool — which means you can pipe into it and redirect out of it:

cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt

For scripts and CI, add --bare:

claude --bare -p "Summarize this file" --allowedTools "Read"

--bare skips auto-discovery of hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md. Without it, claude -p loads the same context an interactive session would — including whatever happens to be configured in the working directory or ~/.claude. Bare mode is how you get the same result on every machine: a teammate’s hook or a project .mcp.json server won’t silently change the run, because bare mode never reads them. Only the flags you pass explicitly take effect.

Two things to know: in bare mode Claude has Bash, file-read, and file-edit tools, and you pass anything else in by flag (--append-system-prompt, --mcp-config, --settings, …). And it skips OAuth and keychain, so auth has to come from ANTHROPIC_API_KEY (or an apiKeyHelper in --settings).

The docs call --bare the recommended mode for scripted and SDK calls, and say it will become the default for -p in a future release. Wiring it in now is future-proofing, not extra work.

--bare is the reproducibility twin of the interactive-side debugging flag: when a live session misbehaves, --safe-mode strips the same customization layers so you can find which one broke. One skips your context for consistency; the other skips it to isolate a fault.

Making -p machine-readable

Text output is fine for a human reading a terminal. For a script, use --output-format:

  • text (default) — plain text.
  • json — structured JSON: the answer lands in .result, alongside session_id, total_cost_usd, and a per-model cost breakdown.
  • stream-json — newline-delimited JSON events, token by token.

Parse json with jq:

claude -p "Summarize this project" --output-format json | jq -r '.result'

Need the output to conform to a shape? Pair --output-format json with --json-schema. The result lands in .structured_output — the extract-and-pipe use case:

claude -p "Extract the main function names from auth.py" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}' \
  | jq '.structured_output'

For live progress, stream-json with --verbose --include-partial-messages emits an event per token. It also emits system/init (session metadata: model, tools, MCP servers, loaded plugins) and system/api_retry (attempt count, delay, error category) — useful for CI observability and failing a build when a plugin didn’t load.

Sessions without the REPL

Headless runs can still be multi-step. Two flags carry state across invocations:

  • --continue / -c — reload the most recent conversation in this directory.
  • --resume / -r <id|name> — resume a specific session.
claude -p "Review this codebase for performance issues"
claude -p "Now focus on the database queries" --continue

For multiple parallel conversations, capture the session_id and resume it by hand:

session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_id')
claude -p "Continue that review" --resume "$session_id"

Run both from the same directory — session lookup is scoped to the current project directory and its git worktrees.

Guardrails: bounding a headless run

An unattended -p run can loop on its own, so bound it. These two are print-mode only:

  • --max-turns N — cap agentic turns; error on overflow.
  • --max-budget-usd N — hard dollar ceiling per invocation, stop when hit.

Then set the permission posture. --permission-mode takes one of: default, acceptEdits, plan, auto, dontAsk, bypassPermissions, manual. The two that matter for scripting:

  • dontAsk — denies anything not in your permissions.allow rules or the built-in read-only command set. This is your locked-down CI mode.
  • acceptEdits — auto-approves file writes plus common filesystem commands (mkdir, touch, mv, cp). Other shell commands and network calls still need an allow rule, or the run aborts when one is attempted.

For finer control, allow specific tools with permission-rule syntax:

claude -p "Apply the lint fixes" --allowedTools "Bash(git diff *),Edit"

Mind the space before *: Bash(git diff *) prefix-matches git diff …, while Bash(git diff*) would also match git diff-index.

There’s also --dangerously-skip-permissions (equivalent to --permission-mode bypassPermissions). It approves everything. Justified in a throwaway sandbox or container you’ve isolated; reckless against a repo you care about or anything with network and secrets. Reach for dontAsk plus an explicit allow-list first. (The same review-vs-autonomy trade-off shows up in the editor, covered in Cursor auto-review vs YOLO.)

Real recipes

These are the payoff. Every one is a real -p invocation.

Explain a failure. Pipe a log in, get the root cause out:

cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt

Project linter as a package.json script. Piping the diff means Claude doesn’t need Bash permission to read it; the escaped quotes keep it portable to Windows:

{
  "scripts": {
    "lint:claude": "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.\""
  }
}

Commit helper. Review staged changes and write the message:

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

PR security review. Feed a PR diff in, reshape Claude’s role with --append-system-prompt, get JSON out:

gh pr diff "$1" | claude -p \
  --append-system-prompt "You are a security engineer. Review for vulnerabilities." \
  --output-format json

Batch loop. One -p call per item, collect the JSON:

for f in src/**/*.py; do
  claude --bare -p "Summarize what $f does in one sentence" \
    --allowedTools "Read" --output-format json | jq -r '.result'
done

User-invoked skills and custom commands work in -p too — put /skill-name in the prompt string and Claude Code expands it before running. Commands that open an interactive dialog, like /login, don’t work headless.

Which mode, when

Task shapeModeKey flags
Exploratory, ambiguous, needs mid-run judgmentInteractive
One-off you want to think throughInteractiveclaude "prompt"
Deterministic, repeatable, composable-p--bare, --allowedTools
Unattended / CI-p--bare, --permission-mode dontAsk, --max-budget-usd
Output another program parses-p--output-format json, --json-schema
Multi-step scripted workflow-p--output-format json + --resume "$session_id"

Rule of thumb: if a human needs to be in the loop, use the REPL. If a program is, use -p.

Gotchas

Things that bite the first time you script this:

  • No mid-run approvals. In -p there’s nobody to ask. Decide permissions up front, or the run aborts the first time it hits an un-approved tool. This is the single most common headless surprise.
  • Quoting and escaping. Shell quoting rules apply, and in package.json you escape the double quotes (\"…\") for Windows portability.
  • Piped stdin is capped at 10MB (as of v2.1.128). Exceed it and Claude Code exits with a clear error and non-zero status. For larger input, write it to a file and reference the path in the prompt instead of piping.
  • Cost. -p can loop autonomously. Pair --max-turns and --max-budget-usd, and track spend with total_cost_usd from --output-format json.
  • --bare skips your local context. Great for reproducibility, surprising if you expected your CLAUDE.md or MCP servers to load. If a bare run behaves differently than your interactive session, this is usually why.
  • Background tasks at exit. A background Bash task (dev server, watch build) is terminated ~5s after Claude returns its result and stdin closes (a fix landed in v2.1.163 so a never-exiting process no longer holds the run open forever). Background subagents and workflows are waited on instead, capped at ten minutes by default since v2.1.182.

TL;DR

Use the REPL to think with Claude. Use -p to build with it. The move: take something you keep doing by hand in the REPL, ship it as a one-liner, then graduate the one-liner to a package.json script, a git hook, or a CI job. Add --bare and a permission posture, and it runs the same everywhere.


Sources

All flags and behavior verified against primary docs on 2026-07-06.