Vibe Code Textbook

Harnesses · · 1,443 words · 7 min read

Claude Code and Codex headless: running a coding agent in CI safely

The flags for non-interactive Claude Code and Codex CLI runs, the caps that bound a job, the risks the docs name, and a CI wrapper exercised in dry-run mode.

headless ci claude code codex automation

You get the flags that turn Claude Code and Codex CLI into non-interactive commands, the three caps that keep an unattended run bounded, the risks each vendor's documentation names in its own words, and a bash wrapper for CI that I ran in this session in a dry-run mode that substitutes a local stub for the agent. No live agent call was made while writing this.

The basic invocation

Claude Code's non-interactive mode is the -p flag. The headless page: "Add the -p (or --print) flag to any claude command to run it non-interactively." The example it leads with is claude -p "Find and fix the bug in auth.py" --allowedTools "Read,Edit,Bash". The command "exits with code 0 on success and a non-zero code when the run fails, so your scripts can branch on the exit status". Stdin is read like any Unix tool, so cat build-error.txt | claude -p 'explain the root cause' works, with one limit the page states: "Piped stdin is capped at 10MB."

Codex's equivalent is a subcommand. Its non-interactive page says codex exec "streams progress to stderr and prints only the final agent message to stdout", which makes piping the answer onward trivial. Two stdin patterns are supported: pass the instruction as an argument and piped content becomes context, or omit the argument (or pass -) and the whole prompt comes from stdin. --json switches stdout to JSON Lines with event types including thread.started, turn.started, item.completed, and turn.completed; -o <path> writes the final message to a file; --ephemeral keeps the session off disk; codex exec resume --last continues the previous run.

Bare mode, and why it exists

The most important flag on the Claude Code page is one that changes what loads. --bare skips "auto-discovery of hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory, and CLAUDE.md". The page explains the risk it removes: "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." In CI that means a pull request can carry a hook or an MCP server that your unattended agent will run. The page's note says --bare "is the recommended mode for scripted and SDK calls, and will become the default for -p in a future release".

Bare mode has a cost: it "doesn't use your subscription login", so you set ANTHROPIC_API_KEY, and it gives Claude only the Bash, file read, and file edit tools unless you pass context back with --settings, --mcp-config, --append-system-prompt, or --add-dir.

Permissions without a human

Three permission modes make sense unattended. The headless page says -p starts in Manual on every plan, "so pass the permission mode you want". dontAsk is the locked-down choice: "Claude Code denies anything not in your permissions.allow rules or the read-only command set". acceptEdits lets it write files and run mkdir, touch, mv, and cp while other shell commands "still need an --allowedTools entry". auto puts a classifier in the loop instead of you. The cli reference lists --dangerously-skip-permissions as "Equivalent to --permission-mode bypassPermissions", and the permission-modes page reserves that for "Isolated containers and VMs only".

Since v2.1.259 there is also --permission-prompts none, for jobs "when nobody is available to answer permission prompts": anything that would prompt is denied, "Claude is told that nobody can approve the request and not to retry it, and the run continues". With stream-json output, those denials appear as permission_denied messages and are listed in the final result's permission_denials.

Allow rules use the same syntax as interactive sessions, which I covered in /posts/coding-agent-permission-models-compared.html. The page's commit example is a good template: --allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)", with the reminder that "The space before * is important: without it, Bash(git diff*) would also match git diff-index."

The three caps

An unattended agent needs a ceiling on turns, on money, and on time. The cli reference documents the first two. --max-turns will "Limit the number of agentic turns (print mode only). Exits with an error when the limit is reached. No limit by default." --max-budget-usd is the "Maximum dollar amount to spend on API calls before stopping (print mode only)", and "Spend from subagents counts toward the cap". Time is the CI runner's job: the GitHub Actions page recommends you "Set workflow-level timeouts to avoid runaway jobs" alongside --max-turns.

Codex's sandbox provides a different kind of ceiling. Its --sandbox flag on exec defaults to read-only, with workspace-write and danger-full-access as the alternatives, so an unattended Codex job cannot write anything unless you say so.

Reading the result

With --output-format json, the headless page says "the response payload includes total_cost_usd and a per-model cost breakdown", and that "Both figures are client-side estimates and can differ from your actual bill." The SDK cost-tracking page is blunter: total_cost_usd values "are client-side estimates, not authoritative billing data", computed "from a price table bundled at build time", and you should "Do not bill end users or trigger financial decisions from these fields". For a CI cost gate that is fine, because the gate is a guard, not an invoice. To get structured output, add --json-schema and read the structured_output field; the page notes Claude Code exits with an error on an invalid schema.

If a supervisor kills the run, the page says SIGTERM makes Claude Code exit with code 143 and leave "the turn that was in progress unfinished"; SIGINT ends the turn cleanly instead.

The wrapper

The shipped headless-coding-agent-ci.sh puts all of that in one place. The real invocation is:

AGENT=(claude --bare -p
       --output-format json
       --permission-mode dontAsk
       --permission-prompts none
       --allowedTools "$ALLOWED_TOOLS"
       --max-turns "$MAX_TURNS"
       --max-budget-usd "$MAX_BUDGET")

The default allow list is Read,Bash(python -m pytest *),Bash(git diff *),Bash(git log *), which is enough to read a repository and run its tests and nothing more. After the run, a Python heredoc parses the JSON, prints the session id, turn count, and estimated cost, prints the result text, and exits 2 if the agent reported an error or 3 if the cost exceeds COST_CAP_USD. There is no jq dependency because CI images do not always have it and Python is already required by the projects I run this on.

DRY_RUN=1 replaces the agent with a Python one-liner that prints a JSON object shaped like the real result, with a fixed total_cost_usd of 0.0421. The stub is labelled in the script as not a model call. Its purpose is to let the control flow be tested on a machine with no credentials, which is also how it was tested here.

What ran here

I ran the wrapper twice under bash on Windows with Python 3.13.12, both times with DRY_RUN=1. First with the default cap of 1.50 dollars:

== running agent (turns<=12, budget<=$2.00, cap=$1.50, dry_run=1)
== agent exit status: 0
== session dry-run-session subtype=success turns=3 cost=$0.0421
== result:
DRY RUN: the stub printed this instead of a model answer. Prompt length: 77 chars
== ok

Then with COST_CAP_USD=0.01, which must fail:

== cost $0.0421 exceeds cap $0.01

and the shell reported exit status 3. bash -n passed. The claude binary is on this machine, but I did not call it, so the real branch of the script is unexercised in this session and should be treated as prescriptive until you run it with a key.

One bug surfaced during the dry run and is worth passing on. My first draft named the task variable PROMPT, and the stub reported the prompt as 4 characters long. Windows sets an environment variable called PROMPT to the value $P$G for cmd.exe, and bash inherited it, so the default in ${PROMPT:-...} never applied. The variable is now TASK_PROMPT. Any wrapper that takes its task from the environment should avoid that name.

GitHub Actions

If you would rather not manage the CLI yourself, the GitHub Actions page shows the minimal workflow, quoted here without the trigger boilerplate:

steps:
  - uses: actions/checkout@v6
    with:
      fetch-depth: 1
  - uses: anthropics/claude-code-action@v1
    with:
      anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

With no prompt input the action waits for an @claude mention; with one, it runs on any event. CLI flags pass through claude_args, and the page's example is claude_args: "--max-turns 5 --model claude-sonnet-5 --mcp-config /path/to/config.json". Codex's page describes a comparable openai/codex-action@v1 workflow that checks out the failing commit with read-only permissions, has Codex propose a fix, saves it as a patch artifact, and applies it in a separate job with write permissions, which is a sound pattern for any agent: the job that thinks and the job that writes should not share a token.

What I did not verify

I did not run claude -p or codex exec for real in this session, so exit codes, the JSON field names, and the --permission-prompts none behaviour are quoted from the documentation rather than observed. The stub's JSON has the fields the docs name, but a real result carries more.

Code and data

Sources

  1. Anthropic, "Run Claude Code programmatically" (Claude Code docs, read 2026-09-05)
  2. Anthropic, "CLI reference" (Claude Code docs, read 2026-09-05)
  3. Anthropic, "Claude Code GitHub Actions" (Claude Code docs, read 2026-09-05)
  4. Anthropic, "Track cost and usage" (Claude Agent SDK docs, read 2026-09-05)
  5. OpenAI, "Non-interactive mode" (Codex docs, read 2026-09-05)
  6. Anthropic, "Choose a permission mode" (Claude Code docs, read 2026-09-05)