# Claude Code Headless in CI: -p Mode and Safe Permissions

> Run Claude Code headless in CI with claude -p: bare mode, dontAsk and allowedTools permissions, JSON output, exit codes and cost caps for unattended jobs.

11 October 2026 · Technical · How-to · 9 min read · https://bridge-terminal.com/atlas/blog/claude-code-headless-ci-permissions/

## The short version

- To run Claude Code with nobody watching, use claude -p with a prompt and set the permissions yourself before the run starts.
- Add --bare so the run skips hooks, MCP servers, plugins and CLAUDE.md found on the runner. Without it, a -p run executes a checked-out repo's hooks with no trust prompt.
- Set the permission mode explicitly, since the built-in starting mode can be auto. Start with dontAsk and allow only the tools the job needs, using prefix rules such as Bash(git diff *).
- Request --output-format json and check the exit code. A failure inside the run, such as missing authentication, is printed on stdout as the result.
- Cap spend with --max-budget-usd (an estimate that can be exceeded) and set a CI job timeout. Pin the Claude Code version, because several behaviours need v2.1.259 or v2.1.286.

To run Claude Code headless in CI, call `claude -p "<prompt>"` and set the permissions yourself. Add `--bare` so the run ignores any hooks, MCP servers and `CLAUDE.md` it finds on the runner. Choose a permission mode such as `dontAsk`, and use `--allowedTools` to list only the tools the job needs. Request `--output-format json` so your pipeline can parse the result, and branch on the exit code.

This guide covers each of those settings and the problems that appear when nobody is around to answer a permission prompt. The flags come from Anthropic's documentation on running Claude Code programmatically and from its CLI reference. For Codex, including approval modes and sandboxing, see our [Codex CLI setup guide](https://bridge-terminal.com/atlas/blog/codex-cli-install-approval-modes-sandbox/).

## How Claude Code non-interactive mode works

Add `-p` (or `--print`) to a `claude` command to run it non-interactively. Claude Code takes the prompt, works on it, prints the result and exits:

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

Three behaviours matter in CI:

- **Exit codes.** Claude Code exits with 0 on success and with a non-zero code when the run fails, so your script can branch on the status. An invalid flag produces an error on stderr before the run starts. A failure during the run, such as missing authentication, is printed as the result on **stdout**. Text on stdout doesn't mean the run succeeded, so check the exit code.
- **Stdin works.** You can pipe data in and redirect the answer out. Piped stdin is capped at 10MB. Past that limit, Claude Code exits with an error and a non-zero status. For larger inputs, write the content to a file and give its path in the prompt.
- **Some commands are unavailable.** Skills and custom commands still work if you put `/skill-name` in the prompt. Built-in commands that only run in the terminal interface, such as `/login`, don't.

Piping also cuts down the permissions a job needs. The docs show a `package.json` script that pipes a diff into Claude, so Claude doesn't need Bash permission to read it:

```json
{
  "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.\""
  }
}
```

If your pipeline can collect the data itself, pass the data to Claude rather than giving Claude a tool to fetch it.

## Use bare mode so runs are reproducible

Without extra flags, `claude -p` finds and loads the same things an interactive session would: hooks, skills, custom commands, subagents, installed plugins, MCP servers, auto memory and `CLAUDE.md`, from both the working directory and `~/.claude`.

This matters most in CI. According to the documentation, a `-p` session without `--bare` 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. It shows no workspace trust dialog and asks for no approval per server. So if your pipeline checks out code you don't control, such as a pull request from a fork, that repository's configuration runs on your runner.

`--bare` skips all of that loading:

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

There is one exception. For a directory you add with `--add-dir`, bare mode still loads skills from its `.claude/skills/` folder, but it skips that directory's `.claude/commands/` and `.claude/agents/`.

In bare mode, Claude has the Bash, file read and file edit tools. Any other context has to be passed with a flag:

| To load | Use |
| - | - |
| 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 <file-or-json>` |
| A plugin | `--plugin-dir <path>`, `--plugin-url <url>` |

Bare mode also changes how the session behaves:

- Only MCP servers given on the command line connect.
- Background tasks don't run, so a command that reaches its timeout stops instead of moving to the background.
- Claude doesn't get the system reminders that normally come with tool results. For example, it isn't told when a file it read earlier has changed on disk.

Before v2.1.286, these limits were only partly in place.

Anthropic recommends `--bare` for scripted and SDK calls and says it will become the default for `-p` in a future release. Setting it now means your jobs won't change behaviour when that happens.

### Authentication in bare mode

Bare mode never reads OAuth credentials or the system keychain, so a subscription login on the runner won't work. For the Anthropic API, set `ANTHROPIC_API_KEY` in the environment to a key created in the Claude Console, or supply an `apiKeyHelper` in the `--settings` JSON. Amazon Bedrock, Google Cloud's Agent Platform and Microsoft Foundry still read their own provider credentials. Keep the key in your CI system's secret store, not in the workflow file.

## Safe permissions for unattended runs

In a headless run, nobody can answer a prompt. Decide in advance what is allowed, and make sure everything else is denied. Otherwise a run can hang, or be approved by a mode you didn't choose.

### Always set a permission mode

If nothing sets a permission mode, the run starts in Claude Code's built-in starting mode, which can be `auto`. Pass the mode you want explicitly. The headless docs describe three options:

- **`dontAsk`** denies every call that would otherwise prompt, and the docs suggest it for locked-down CI runs. Actions that need no approval in Manual mode still run, such as file reads in your working directories and the read-only command set. So do actions covered by your `--allowedTools` entries or `permissions.allow` rules. Some things are denied even when an allow rule matches, including `AskUserQuestion` and reads from network paths.
- **`acceptEdits`** lets Claude write files without prompting and auto-approves common filesystem commands such as `mkdir`, `touch`, `mv` and `cp`. Other shell commands (apart from read-only ones) and network requests still need an allow rule.
- **`auto`** has a classifier review most actions in place of a person.

```bash
claude -p "Apply the lint fixes" --permission-mode acceptEdits
```

For most CI jobs, start with `dontAsk` and add allow rules one at a time. Use `acceptEdits` for jobs whose purpose is to change files, such as applying formatter or lint fixes on a branch.

### Scope tools with allowedTools

`--allowedTools` approves specific tools ahead of time:

```bash
claude -p "Run the test suite and fix any failures" \
  --allowedTools "Bash,Read,Edit"
```

A bare `Bash` entry approves shell commands without asking, which is usually more than a CI job needs. The flag uses Claude Code's permission rule syntax, so you can approve command prefixes instead:

```bash
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 *)"
```

The space before `*` matters. `Bash(git diff *)` matches commands that start with `git diff`. Without the space, `Bash(git diff*)` would also match `git diff-index`.

If a run starts in `auto` mode, Claude Code drops a bare `Bash` entry as a broad allow rule, and the classifier checks each command instead.

### Turn prompts off completely

`--permission-prompts none` is meant for runs where nobody can answer. It needs Claude Code v2.1.259 or later, and earlier versions reject it as an unknown option. Permission rules, `PermissionRequest` hooks and the permission mode still decide each call first. Anything left that would prompt is denied, Claude is told that nobody can approve it and that it shouldn't retry, and the run continues. Tools that need a person's answer, such as `AskUserQuestion`, are removed:

```bash
claude -p "Update the dependency pins and run the tests" --permission-mode auto --permission-prompts none
```

In a plain `-p` run with no permission host, these requests are denied anyway, and the flag adds the instruction not to retry. The flag matters most in runs that do have a host, meaning an Agent SDK `canUseTool` callback or a `--permission-prompt-tool`. Without the flag, the run waits for that host to answer each request.

With `stream-json` output, each denial appears as a `permission_denied` system message, and the final result lists all of them in `permission_denials`. If you save that list as a CI artifact, you can see which rule to add or which request needs a closer look.

## Get output your pipeline can parse

`--output-format` accepts `text` (the default), `json` or `stream-json`. The `json` format returns the result along with the session ID and other metadata. To get a fixed shape, add `--json-schema`. The answer then appears in `structured_output`:

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

If the schema is invalid, `claude` exits with an error. The `format` keyword (for example `"format": "email"`) is accepted as an annotation but not enforced, so validate those fields yourself.

The JSON payload also includes `total_cost_usd` and a cost breakdown per model. Anthropic describes these as client-side estimates that can differ from your actual bill. Use them to track spending, not as billing data.

A review job can add a role prompt and still keep the default system prompt:

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

### Fail the build when a server or plugin didn't load

When you pass `--mcp-config`, Claude Code checks each entry at startup and skips invalid ones, such as a `url` entry with no `type`. The run then continues and exits cleanly. In a terminal you would see a warning on stderr. When a CI runner captures or redirects stderr, though, no warning is printed.

The skipped entries are then reported only in the `system/init` event, which you can read from `stream-json` output:

- `mcp_server_errors` lists skipped servers (v2.1.219 or later).
- `plugin_errors` lists plugins that failed to load.

Both keys are left out when there are no errors, so fail the job if either one is present and non-empty. Otherwise a review step can pass without the tool it depends on.

## Keep runs bounded

CI jobs need limits on time and cost. The docs describe these controls:

- **Background work.** After its turn, a `-p` run can stay open while background work that Claude started finishes. By default, Claude Code stops whatever is still running after 10 minutes of continuous idle waiting and drops its partial result. Some work is exempt. For example, a background command started by the main conversation keeps the run open until the command exits or hits its own time limit. `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` changes the 10-minute cap, and setting it to `0` removes the cap. In bare mode, no background tasks run.
- **Budget.** `--max-budget-usd` stops the run once estimated spend reaches the amount you set. It checks against the client-side estimate, and spend can go past the cap, so leave headroom. Once the cap is reached, Claude Code also stops any remaining background work instead of waiting for it. `--max-turns` limits the number of agentic turns and exits with an error when the limit is reached.
- **Cancellation.** If your runner sends SIGTERM, Claude Code exits with code 143. The current turn is left unfinished with no result, Bash process trees are terminated, and `SessionEnd` hooks run. If you want the turn to end properly, send SIGINT first.

## Multi-step jobs: resume by session ID

Some jobs work better in stages, such as reviewing first and summarising afterwards. Capture the session ID, then resume that session explicitly:

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

Use `--resume` with an ID rather than `--continue`. `--continue` loads the most recent conversation in the current directory, which may not be the one you want when several jobs share a runner. After a resume, the reported cost covers the whole conversation, earlier runs included. Those earlier totals don't count toward `--max-budget-usd`.

## A checklist for headless CI jobs

1. Use `claude --bare -p`, and load prompts, settings and MCP servers only through explicit flags.
2. Authenticate with `ANTHROPIC_API_KEY` (or your cloud provider's credentials) from the CI secret store.
3. Set `--permission-mode` yourself, starting with `dontAsk`.
4. In `--allowedTools`, use prefix rules like `Bash(git diff *)` instead of a bare `Bash`.
5. Pipe data in where you can, so Claude needs fewer tools.
6. Use `--output-format json`, add `--json-schema` when later steps depend on the output's shape, and check the exit code.
7. Fail the job on non-empty `mcp_server_errors` or `plugin_errors`, and save `permission_denials`.
8. Set `--max-budget-usd` with headroom, plus a job timeout in your CI system.
9. Pin the Claude Code version on your runners, for example with `claude install <version>`.

The same principles apply when you run many agents instead of one. [Atlas](https://bridge-terminal.com/atlas/) runs each engineer as its own Claude Code session with a written role and its own working folder. It leaves production go-lives, spending, deleting data and sending anything outside to a person.

## Summary

Running Claude Code headless comes down to two things. Make each run independent of the machine it lands on, and make permissions an explicit allowlist instead of relying on a default. `--bare`, an explicit `--permission-mode`, narrow `--allowedTools` rules and JSON output cover most of that. The other checks catch failures that would otherwise go unnoticed: the exit code, `mcp_server_errors` and `permission_denials`.

Several behaviours described here depend on recent versions, including `--permission-prompts` (v2.1.259) and the full bare-mode limits (v2.1.286). Pin your version, and reread the [programmatic usage docs](https://code.claude.com/docs/en/headless) whenever you upgrade.

## Questions people ask

### How do I run Claude Code in CI without a person present?

Call claude -p with your prompt. This runs Claude Code non-interactively, and it exits when it's done. Add --bare, set a permission mode such as dontAsk, list the tools the job needs with --allowedTools, request --output-format json and branch on the exit code.

### What does the --bare flag do in Claude Code?

It stops Claude Code from loading hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory and CLAUDE.md from the machine, so the run behaves the same everywhere. Without it, a -p run executes a repository's hooks and connects its MCP servers with no trust prompt, even when the repository is a pull request from a fork.

### Which permission mode should I use for a CI job?

Set one explicitly, because the built-in starting mode can be auto. For most jobs, start with dontAsk, which denies every call that would otherwise prompt, and add allow rules one at a time. acceptEdits suits jobs whose purpose is to change files, such as applying lint fixes.

### How do I authenticate Claude Code in bare mode?

Bare mode never reads OAuth credentials or the system keychain, so a subscription login won't work. Set ANTHROPIC_API_KEY from your CI secret store, or supply an apiKeyHelper in the --settings JSON. Amazon Bedrock, Google Cloud's Agent Platform and Microsoft Foundry still read their own provider credentials.

### How can I limit cost and runtime for a headless Claude Code run?

--max-budget-usd stops the run once estimated spend reaches the amount you set. Leave headroom, because spend can go past the cap. --max-turns limits agentic turns and exits with an error when the limit is reached. Also set a job timeout in your CI system.

## About Atlas: From one headless agent to a team of them

This article covers scoping a single unattended Claude Code run. Atlas applies the same idea across a team: each engineer is its own Claude Code session with a written role and its own working folder. Production go-lives, spending, deleting data and sending anything outside are left to a person, and an optional daily budget warns at 50% and 80% and pauses at 100%. — https://bridge-terminal.com/atlas/

## Sources

- [Run Claude Code programmatically - Claude Code Docs](https://code.claude.com/docs/en/headless)
- [CLI reference - Claude Code Docs](https://code.claude.com/docs/en/cli-reference)
- [Choose a permission mode - Claude Code Docs](https://code.claude.com/docs/en/permission-modes)

_Researched and drafted with Claude, then checked against the sources above. Atlas is an independent product, not affiliated with Anthropic or OpenAI._
