CCLearn

Non-interactive automation — claude -p, stream-json and CI

verified against Claude Code 2.1.261 · 2026-09-0914 min

By the end you can

  • Run a scripted task with claude -p and the output format its consumer can read
  • Use --bare so a run loads the same thing on every machine
  • Set permissions for a run nobody is watching
  • Gate CI on mcp_server_errors and plugin_errors rather than on exit 0
  • Continue and resume a scripted conversation by session ID
  • Say what happens to background work when a run ends or is signalled

Every hook in 9.1 through 9.4 fires inside a session you are sitting in. If the gate is open you can still see the tool call go through. This lesson removes the last human: a run that starts from a cron entry or a CI job, does its work, and prints something a script has to believe.

The command is one flag.

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

What changes is everything around it. The trust dialog does not appear. The permission prompt has nobody to ask. And the run tells you it succeeded in the only way a shell can, which is the failure this lesson is mostly about.

What a -p run loads that you did not ask for

Start here, because it is the part people discover late. A -p run is not a stripped-down session. It loads what an interactive session in that directory would load — and it does so without the one dialog that normally stands in front of a repository you have never opened.

Without --bare, a -p session runs the hooks in a project's .claude/settings.json and connects the servers in its .mcp.json, even in a folder you've never trusted. A -p session shows no workspace trust dialog and no per-server approval prompt. — Run Claude Code programmatically

The hooks reference says the same thing from the other end, and names the mechanism: workspace trust is not skipped, it is assumed.

-p or SDK session: Claude Code never shows the dialog and treats the folder as trusted, so hooks committed in a repository's .claude/settings.json run in a folder you've never trusted. — Hooks reference

Which makes the trust table worth reading in the one column that applies to you. These are the rows for a -p run in a folder that was never trusted (Permissions):

What the repository suppliesIn a -p run
Hooks in settings files, the env block, helper commandsUsed
permissions.allow rules and additionalDirectoriesNot used; a warning goes to stderr
Servers in .mcp.jsonConnected without asking, approved or not
Frontmatter hooks in a project subagentNot used — but see below
Inline mcpServers in a subagent's frontmatterNot used — but see below
A headersHelper on a .mcp.json serverNot run; static headers only — but see below

Those last three rows are the newest, and each of them ran the other way until recently:

RowWhen trust started gating it
Frontmatter hooks in a project subagentv2.1.218 — "before v2.1.218, these hooks could run from folders you hadn't trusted"
Inline mcpServers in a subagentv2.1.238 — "before v2.1.238, Claude Code loaded these servers in both situations"
A headersHelper on a .mcp.json serverv2.1.238 — "before v2.1.238, a claude -p or SDK session ran these helpers without checking trust"

Three holes in the same wall, found and closed one at a time. Which does not soften the point below; it sharpens it, because the largest opening was never a hole. It is the first row of the trust table, and it is open by design. Read the asymmetry. The repository's arbitrary shell commands run; the repository's permission grants do not. The docs explain only the second half: allow rules and additionalDirectories "grant capability", so they wait for the dialog, while deny and ask rules are unaffected "since they only restrict" (Permissions). Nothing gates the hooks, and hooks "execute shell commands with your full user permissions" (Hooks reference). The checked-in file you would most want reviewed is the one that executes.

--bare: the same run on every machine

Most of the above closes with one flag, and it is the flag that makes a scripted run reproducible.

Add --bare to reduce startup time by skipping auto-discovery of hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory, and CLAUDE.md. — Run Claude Code programmatically

A teammate's hook in their ~/.claude and a server in the project's .mcp.json both stop existing, because bare mode never reads them. What is left is deliberately small: Bash, file read, and file edit. Everything else you load back by name.

To loadPass
System prompt additions--append-system-prompt, --append-system-prompt-file
Settings--settings <file-or-json>
MCP servers--mcp-config <file-or-json>
Custom agents--agents <json>
A plugin--plugin-dir <path>, --plugin-url <url>

One exception is worth knowing before it confuses you: a directory named with --add-dir still contributes its .claude/skills/ folder, while its .claude/commands/ and .claude/agents/ folders stay skipped.

And one consequence bites on the first CI run rather than the tenth:

In bare mode, Claude Code never reads OAuth credentials or the system keychain. — Run Claude Code programmatically

So a subscription login on your laptop is not what authenticates the run. Set ANTHROPIC_API_KEY, or supply an apiKeyHelper in the --settings JSON. Bedrock, Google Cloud's Agent Platform and Microsoft Foundry keep reading their own provider credentials.

bash
claude --bare -p "Summarize README.md" --allowedTools "Read"

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. It is not the whole fence, though, and this is the row of the table it does not close: under --bare the project's env block and helpers such as awsAuthRefresh in its settings files still apply, and apiKeyHelper is read only from --settings (Permissions). The instruments that reach further: --setting-sources user reads neither the project's settings files nor its .mcp.json, --settings '{"disableAllHooks": true}' turns hooks off for that run and outranks project settings, and disabledMcpjsonServers rejects one server by name in every session type. The third documented way to keep a repository's .mcp.json server out of an unattended run is --strict-mcp-config, after which Claude Code uses only the servers you pass with --mcp-config. It became usable unattended in v2.1.246, when it stopped waiting at startup for approval of the project servers it had already declined to load (MCP) — before that, a background session sat waiting there for servers it was never going to use.

Three output formats, and who is reading

FormatWhat you getReach for it when
text (default)The response, as textA human or a log reads it
jsonOne object: result, session_id, usage and costA script needs a field
stream-jsonNewline-delimited JSON eventsSomething renders progress live

--output-format json carries total_cost_usd and a per-model breakdown — both documented as client-side estimates that can differ from your bill. Add --json-schema and the structured value lands in structured_output; a schema using format, such as "format": "email", is accepted but treated as an annotation rather than enforced.

That acceptance arrived in v2.1.205, and what it replaced is the shape every failure in this lesson has: "before v2.1.205, Claude Code silently ignored an invalid schema and returned unstructured text, and treated any schema containing format as invalid." The two halves bit together — one "format": "email" made the whole schema invalid, and an invalid schema was discarded without a word. The run exited 0, structured_output never appeared, and the only symptom was a script reading a field that was not there. A bad schema now stops the run instead: claude exits with Error: --json-schema is not a valid JSON Schema and the validator's diagnostic.

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

The streaming format needs three flags, not one:

Use --output-format stream-json with --verbose and --include-partial-messages to receive tokens as they're generated. — Run Claude Code programmatically

The last line of the stream is a result message with the final response text, cost, and session metadata. — Run Claude Code programmatically

That last line is the one your script parses. Everything before it is progress. Subagent messages (Level 8) appear in the stream with parent_tool_use_id set to the tool call that spawned them, where a main-conversation message carries null. By default only their tool_use and tool_result blocks are emitted, not their text, so do not expect a subagent's reasoning to be in the file you captured.

Permissions with nobody in the room

A -p run does not inherit a comfortable default:

For -p, the built-in starting permission mode is Manual on every plan, so pass the permission mode you want. — Run Claude Code programmatically

manual and default name the same mode — the CLI takes --permission-mode manual, and default is the config value hooks and SDK integrations use. Both the Manual label and the alias require v2.1.200, so a script that must also run on an older CLI writes default (Lesson 1.3).

Manual with no one to ask is a run that gets very little done. Three ways to settle it before it starts:

  • --allowedTools "Bash(git diff *),Read" — pre-approve named tools using permission rule syntax. The trailing * is prefix matching, and the space matters: Bash(git diff*) without it would also match git diff-index.
  • --permission-mode dontAsk — nothing runs that a rule did not pre-approve; the locked-down CI option.
  • --permission-mode acceptEdits — file writes go through, other shell commands and network requests still need a rule. Lesson 10.1 builds what each of the six modes auto-approves.

Then there is the flag for the case where a host exists but no person does:

With the flag, your run doesn't consult the host or wait on it. Anything that would prompt is denied unless a PermissionRequest hook allows it, Claude is told that nobody can approve the request and not to retry it, and the run continues. — Run Claude Code programmatically

--permission-prompts none also removes the tools that need a person, such as AskUserQuestion, so Claude cannot call them. Note the escape hatch inside that quote — it is a PermissionRequest hook, the event from 9.1, doing exactly what 9.2 said a hook may do: tighten, or answer on your behalf. And in sessions that cannot show a prompt at all, "Claude Code still runs these hooks, and if no hook returns a decision, it denies the tool call" (Hooks reference).

The flag is very new, and its failure mode on an older build is worth holding onto, because the Claude Code in a CI image is rarely the one on your laptop. It "requires Claude Code v2.1.259 or later. Earlier versions reject it with an unknown-option error" (Run Claude Code programmatically) — so on an older runner it does not lock the run down, it stops the run from starting, and the job fails before the first turn on an argument you added to make it safer. --permission-mode dontAsk is the fallback that works either way; the PermissionRequest hook is still yours, and the denial-by-default in an unattended session is still what happens.

Failing CI on the thing that actually broke

Here is the trap, and it is Lesson 5.5's "green that isn't" wearing a shell exit code.

Claude Code exits with code 0 on success and a non-zero code when the run fails, so your scripts can branch on the exit status. — Run Claude Code programmatically

True, and not enough. "The run failed" and "the run had the tools you configured" are different claims, and only the first one reaches the exit code.

Claude Code validates each --mcp-config entry at startup and skips entries that fail validation, for example a url entry with no type. The run continues and exits cleanly. — Run Claude Code programmatically

That is 9.5's url-with-no-type error arriving in a place where nobody sees it. A run whose database server never loaded still answers the prompt, still writes a result, still exits 0 — it just answers from whatever it could reach. And the consolation warning is not there either:

When you redirect stderr, or when a program such as a CI runner or an SDK host captures it, Claude Code prints no warning and reports the skipped entries only in the mcp_server_errors field. — Run Claude Code programmatically

The signal is in the system/init event, and it is shaped for a gate. Both keys are omitted when there is nothing to report, so a non-empty array is the whole test.

Before you build on it, check the runner's version rather than your own. mcp_server_errors "requires Claude Code v2.1.219 or later", and so does the stderr warning it replaces. Below that there is neither: no warning on a terminal, no warning in CI, and no field to read. A gate written against the field does not fail there — it passes, every time, because an omitted key and an empty array are the same value to jq. That is the worst shape a check can have, and it is the shape you get if you copy the snippet below onto an older runner without reading this paragraph. Where you cannot be sure of the version, gate on the mcp_servers array instead: assert that every server you configured is present in it and reports connected, which is a positive check that an absent field cannot satisfy.

FieldHolds
mcp_serversServers in the session, each with name and status
mcp_server_errorsSkipped --mcp-config entries: name, type, message
pluginsPlugins that loaded, each with name and path
plugin_errorsLoad-time errors: plugin, type, message

Affected servers are absent from mcp_servers; affected plugins are demoted and absent from plugins. So the gate reads the error arrays, not the success ones:

bash
claude --bare -p "$PROMPT" --mcp-config ci-servers.json \
  --output-format stream-json --verbose > run.jsonl || exit 1

jq -e 'select(.type=="system" and .subtype=="init")
       | (.mcp_server_errors // []) + (.plugin_errors // []) | length == 0' \
  run.jsonl > /dev/null || { echo "a server or plugin did not load"; exit 1; }

A server that loaded but needs signing in is a different failure with the same symptom. There is no /mcp panel in a non-interactive run, so Claude Code cannot run the OAuth flow for you; as of v2.1.196, with MCP tool search on (the default), it tells Claude the server's tools are unavailable until you authorize it, so Claude can name the server that needs sign-in instead of answering as if it were never configured (MCP). Complete that sign-in from an interactive session with /mcp or claude mcp login <name> — a scheduled job cannot do it for you.

Read the version in that sentence, because it is doing the work. The naming behaviour is what Claude Code does "as of v2.1.196", and the same page names the alternative it replaced: "responding as if the server weren't configured". That is what an older runner gives you. The run finishes, the exit code is zero, the result reads like an answer, and nothing in the output holds the server's name or the word authorize — the tools were never offered, so Claude never had a reason to mention them. An expired sign-in looks exactly like a prompt whose answer got thinner. Which is the second reason the mcp_servers check above is the gate and the model's answer is not: a positive assertion that each configured server is present and connected fails the job on a lapsed token, and no amount of reading the result text will.

One more field belongs in the same read. Under --output-format stream-json, denials appear as permission_denied system messages and the final result message lists them in permission_denials. A run that was denied at every step did not fail; it just did nothing. That distinction is invisible from the exit code and plain from that array.

Continuing a scripted conversation

Lesson 3.5 gave you --continue and --resume for the session you walked away from. Both take -p, with two differences worth pinning. The small one: --continue skips background sessions.

bash
claude -p "Continue that review" --resume "$session_id"

The two commands need not run from the same directory — Claude Code finds a session by its ID in any project on the machine, a behaviour that requires v2.1.223 or later. The large difference, and the piece that catches people scripting a multi-step job:

When you resume with -p, Claude Code doesn't restore any other stored permission mode. It starts the run in the permission mode a new claude -p run would start in, so pass --permission-mode or --dangerously-skip-permissions again if the deferred session used one. — Hooks reference

An interactive --resume restores the mode the session was in; a -p resume starts where a fresh claude -p starts, which is Manual, unless your command line or settings say otherwise. A resumed step that suddenly does nothing is usually this and not the prompt.

Signals, background work, and the ten-minute ceiling

What happens at the end of a -p run depends on what is still running.

  • A background Bash task — a dev server, a watch build — is terminated about five seconds after Claude returns its final result and stdin closes. The grace period exists so a task finishing right after the result can still deliver it.
  • A background subagent or workflow holds the process open until it completes, because its result is part of the final output. That wait ends after 10 minutes of continuous idle waiting, at which point Claude Code stops what is running and drops its partial result. CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS changes the limit; 0 removes it.
  • An async hook (9.3) is killed at teardown and finalized with outcome cancelled. If its work must outlive the run, it has to start a fully detached process.
  • SIGTERM, from kill or a supervisor, exits 143. The in-progress turn is left unfinished with no result recorded, running Bash process trees are terminated, and SessionEnd hooks run — nothing else does. Send SIGINT first if you want the turn to end rather than be abandoned. Resuming the session continues the turn SIGTERM left unfinished.

And the input side has a hard edge: piped stdin is capped at 10MB, above which Claude Code exits with a clear error and a non-zero status. Write large input to a file and name the path in the prompt instead.

What a run has to prove

An unattended run has to answer three questions, and only the third one has ever been the exit code.

  1. Did it load what I configured? mcp_server_errors, plugin_errors, and --bare so the answer is the same on every machine.
  2. Was it allowed to do the work? The permission mode you passed, and permission_denials in the result.
  3. Did it succeed? The exit status, and the result message.

Level 5 taught you not to accept "the tests pass" without the artefact. This is the same sentence in a machine's voice: exit 0 is a claim, and the init event is the evidence.

Checkpoint

  1. Your nightly job runs claude -p in a repository you cloned yesterday and never opened. Which of that repository's checked-in configuration executes on your machine, and which part of it is ignored? Which flag closes the gap?

    Show answer

    The executable half runs; the restricting half is ignored. A -p session shows no workspace trust dialog and treats the folder as trusted, so the repository's hooks in its settings files, its env block and its helper commands are all used, and the servers in its .mcp.json are connected without asking. What is not used is its permissions.allow rules and additionalDirectories, which grant capability and so wait for the dialog you never saw — a warning goes to stderr — while its deny and ask rules are unaffected, since they only restrict. Read the asymmetry: the repository's arbitrary shell commands run, its permission grants do not, and hooks execute shell commands with your full user permissions.

    --bare closes it, by skipping auto-discovery of hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory and CLAUDE.md; what is left is Bash, file read and file edit, and you load back what you want by name with --settings, --mcp-config, --agents and the rest. Two things it does not close, both worth knowing before the first run: the project's env block and helpers such as awsAuthRefresh still apply, and bare mode never reads OAuth credentials or the system keychain, so set ANTHROPIC_API_KEY or supply an apiKeyHelper in the --settings JSON. --setting-sources user reaches further still — it reads neither the project's settings files nor its .mcp.json.

  2. A CI step passes --mcp-config with three servers, one of which has a url and no type. The step exits 0 and the review it produced looks thin. What happened, where is it recorded, and why did nothing appear in the job log?

    Show answer

    The bad entry was skipped and the run answered from the two servers that loaded. Claude Code validates each --mcp-config entry at startup and skips entries that fail validation — a url with no type is the docs' own example — and the run continues and exits cleanly. That is 9.5's configuration error arriving where nobody sees it: the review is thin because Claude answered from whatever it could reach.

    It is recorded in the system/init event, in mcp_server_errors (name, type, message); the skipped server is also absent from mcp_servers. Nothing appeared in the job log because stderr was captured — when you redirect stderr, or a CI runner or SDK host captures it, Claude Code prints no warning and reports the skipped entries only in that field. Gate on it by failing when mcp_server_errors plus plugin_errors is non-empty; both keys are omitted when there is nothing to report, so a non-empty array is the whole test. Check the runner's version before you rely on that, though: mcp_server_errors requires v2.1.219 or later, and below it there is no warning and no field, so a jq gate reading it passes every time — an omitted key and an empty array are the same value. Where you cannot be sure, assert positively instead that every server you configured appears in mcp_servers and reports connected.

  3. You script three claude -p --resume steps. The first two edit files; the third suddenly refuses to write anything, with no error. What is the most likely cause?

    Show answer

    The permission mode was not carried over. When you resume with -p, Claude Code doesn't restore any other stored permission mode: it starts the run in the mode a new claude -p run would start in, which is Manual on every plan. So whatever the first two steps passed — --permission-mode, or --dangerously-skip-permissions — has to be passed again on the third, and Manual with nobody in the room to answer a prompt is a step that does very little and reports no error. A resumed step that suddenly does nothing is usually this and not the prompt.

    Pass the mode on every step: --permission-mode acceptEdits for file writes, --allowedTools with permission-rule syntax, or dontAsk for the locked-down version. And if you want to see the refusals rather than infer them, run with --output-format stream-json: denials appear as permission_denied system messages and the final result message lists them in permission_denials. A run that was denied at every step did not fail, it just did nothing — a distinction that is invisible from the exit code and plain from that array.

  4. Your run spawns a subagent that stalls, and the process sits waiting on it for twelve minutes. What does the process do at minute ten, and what happens to the subagent's answer?

    Show answer

    At minute ten it stops waiting and tears the work down. A background subagent or workflow holds the process open until it completes, because its result is part of the final output, but that wait ends after 10 minutes of continuous idle waiting, at which point Claude Code stops what is running and drops its partial result. So the subagent's answer is gone entirely — not truncated, not delivered late. CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS changes the limit, and 0 removes it.

    Worth holding the other two teardown behaviours next to it, because they differ: a background Bash task is terminated about five seconds after Claude returns its final result and stdin closes, and an async hook is killed at teardown and finalized with outcome cancelled. Background work that must outlive the run has to start a fully detached process.

Sources