Headless AI Agents in CI
Runner sizing, secret injection, timeouts, cost caps and exit codes for coding agents that run with nobody watching
What "Headless" Actually Means
Same agent loop, no TTY, no human to approve the next step
A headless agent is not a cut-down agent. It is the same tool-calling loop you drive interactively, started with a flag that says "do not wait for me." Claude Code documents this plainly: "Add the -p (or --print) flag to any claude command to run it non-interactively." OpenAI documents codex exec for the same purpose, explicitly for teams that want to "Run as part of a pipeline (CI, pre-merge checks, scheduled jobs)." AWS makes the same claim for its CLI: "Headless mode lets you run Kiro CLI as part of your CI/CD pipeline to automate code reviews, generate tests, or troubleshoot build failures - no interactive terminal required."
What changes is what happens at a decision point. Interactively, a permission prompt stops the loop and a person answers it. In a pipeline nobody is there, so every one of those decisions has to be pre-committed as configuration: which tools are allowed, how many turns are permitted, how much money may be spent, and what the process should do when it runs out of any of those.
Still a full loop
Print mode is not a single completion call. The agent still reads files, runs tools and iterates, so every budget control matters more, not less
No prompt to say no
Permission decisions move from a human keystroke to a flag. Decide the tool allowlist before the job starts, because nothing will ask you later
Output is the contract
Nobody reads the scrollback. Structured output is the only way the rest of the pipeline learns what happened
Non-Interactive Invocations Across Tools
The flag differs, the shape does not
# Claude Code - print mode runs the full agent loop with no TTY
claude -p "Summarise the failing tests" --output-format json --max-turns 20
# OpenAI Codex - the documented non-interactive form
codex exec "Summarise the failing tests"
# Aider - send one message, process the reply, exit
# note the flag is --yes-always, not --yes
aider --message "Summarise the failing tests" --yes-always| Tool | Non-interactive form | License | Notes |
|---|---|---|---|
| claude -p | Print mode, full agent loop | Proprietary | Official anthropics/claude-code-action@v1 for GitHub Actions |
| codex exec | Pipeline, pre-merge, scheduled jobs | Apache-2.0 | OpenAI recommends openai/codex-action over installing the CLI by hand |
| aider --message | One message, reply, exit; disables chat mode | Apache-2.0 | Pair with --yes-always to auto-answer confirmations |
| OpenHands headless | Documented mode for scripting and CI/CD | MIT apart from the enterprise/ directory | GitHub's license detector reports "Other"; read the LICENSE before assuming |
| Kiro CLI headless | Code review, test generation, build triage | Proprietary | Kiro CLI itself implements the Agent Client Protocol |
Two details are worth pinning down because they are widely mis-stated. First, the direction of the Agent Client Protocol relationship: Kiro's own documentation says "Kiro CLI implements the Agent Client Protocol (ACP), an open standard that enables AI agents to work with any compatible editor." The CLI is the agent that plugs into ACP-compatible editors, not a consumer of them - see Agent Client Protocol for what that buys you. Second, Continue is Apache-2.0 but its repository carries a verbatim notice that it "is no longer actively maintained and is read-only for all users." Do not plan a pipeline around it. Cline is Apache-2.0 and active.
If you build against the Claude SDK rather than the CLI, note the rename: "The Claude Code SDK has been renamed to the Claude Agent SDK and its documentation has been reorganized." At SDK v0.1.0 the npm package moved from @anthropic-ai/claude-code to @anthropic-ai/claude-agent-sdk, PyPI from claude-code-sdk to claude-agent-sdk, and ClaudeCodeOptions to ClaudeAgentOptions. Pin the official GitHub Action too: its docs warn that v1 "introduces breaking changes that require updating your workflow files in order to upgrade to v1.0 from the beta version."
Sizing the Runner
The right question is what your test suite needs, not what the agent needs
The most common sizing mistake is treating an agent like a compiler. Between tool calls, a headless agent is doing almost nothing: it has issued an HTTPS request to a model provider and is waiting for the response. That wait dominates wall-clock time on most jobs. Buying a 16 vCPU runner so the agent "thinks faster" buys you a more expensive idle process, because the thinking happens somewhere else.
What does consume real resources is everything the agent triggers. It clones the repo, installs dependencies, runs your build, runs your test suite, maybe spins up a database container. Those are the workloads to size for, and they are the same workloads your existing CI already sizes for. If your test suite needs 8 GB of RAM, the agent job needs 8 GB of RAM plus a little headroom for the CLI process and its file cache. If your dependency install is 3 GB on disk, size the disk for that and cache it, because an agent that re-downloads the world on every turn wastes both time and your provider's patience.
Concurrency is where this gets expensive. Several agent jobs on one host will share CPU happily while they are all waiting on the network, then all wake up and run the test suite at the same moment. Size for the concurrent peak of the triggered work, not the average of the agent process.
vCPU: modest
Match your existing build job. Extra cores only help if the agent's shell commands are themselves parallel
RAM: size for the tests
The agent's own footprint is small; the test runner, language server and containers it starts are not
Disk: cache aggressively
A warm dependency cache removes minutes of wall clock that you would otherwise pay for at agent rates
Injecting Secrets Without Poisoning the Context
Anything in the agent's context can leave with the agent's next network call
There are two very different kinds of secret in an agent pipeline and conflating them is how credentials end up in a transcript. The first is the model provider key. That belongs in the CLI process environment and nowhere else: set it through your CI secret store, let the process read it, and never reference it in the prompt. The second kind is application secrets - the database password, the API token, the signing key that the agent's code or tests actually need. Those are harder, and the wrong answer is to dump them into the prompt so the agent "has what it needs."
The better pattern is brokering. A proxy holds the real credential, the agent calls the proxy, and the proxy attaches the credential after making a policy decision about whether this caller may make this call. The agent never sees the secret, so the secret cannot be repeated back, logged, or extracted. Where a broker is overkill, issue a short-lived scoped token that expires before the artifact retention window does, so a leaked transcript is a leaked expired token.
Treat context minimization as a security control, not a cost optimization. Every file the agent reads and every command output it captures becomes material that a successful prompt injection can ask it to send somewhere. Do not mount a .env the job does not need, do not run env and paste the result, and do not pipe a secret into the prompt on the theory that the transcript is private - the transcript is a CI artifact, and CI artifacts get downloaded.
Environment, never prompt
Provider keys go in the process environment through the CI secret store. They are never interpolated into prompt text or a config file the agent reads
Broker or scope
Application credentials sit behind a proxy that decides per call, or are minted as short-lived scoped tokens that outlive nothing
CI Hands an Agent All Three Legs by Default
The lethal trifecta is not a hypothetical in a pipeline; it is the default configuration
Simon Willison named the pattern in The lethal trifecta for AI agents (June 16, 2025). The three legs are "Access to your private data", "Exposure to untrusted content", and "The ability to externally communicate in a way that could be used to steal your data". His conclusion is blunt: "If your agent combines these three features, an attacker can easily trick it into accessing your private data and sending it to that attacker." Note that he does not call the third leg exfiltration - in the post he demotes that word to a parenthetical, saying he is "not confident that term is widely understood."
Now map that onto a pull-request review job. Private data: the entire repository, plus whatever is in the runner's environment. Untrusted content: the PR title, the issue body, the branch name, commit messages, dependency metadata, and the README of any transitive dependency the agent happens to open. External communication: the runner has outbound internet by default, and the agent can run curl if you let it use a shell. A stock CI agent has all three legs before you have written a line of prompt.
Private data
The checkout, the secret store, the runner environment - all reachable by a process you started on purpose
Untrusted content
PR titles, issue bodies, branch names and dependency READMEs are all attacker-writable in an open repository
External communication
Egress is open unless you close it. An allowlisted proxy is the difference between a finding and an incident
The controls belong on their own pages rather than duplicated here: see Prompt Injection Defense for content-boundary and egress patterns, and Agent Identity for giving the pipeline agent its own scoped principal instead of borrowing a human's. The one thing to do in the workflow file itself is narrow the tool allowlist. Claude Code exposes --allowed-tools (also spelled --allowedTools, with the docs now steering restriction toward --tools) and a --permission-mode that accepts default, acceptEdits, plan, auto, dontAsk, bypassPermissions and manual. A review job that only needs to read should be given read tools only.
Timeouts Live in Two Different Places
Turn limits come from the agent; wall clock has to come from the platform
This is the detail that surprises people. Claude Code has no general --timeout flag. If you write one into a workflow you will get an argument error, and if you assume one exists you will ship a job with no wall-clock ceiling at all. The in-tool budget controls are turn-based and money-based: --max-turns, documented as "Limit the number of agentic turns (print mode only). Exits with an error when the limit is reached. No limit by default", and --max-budget-usd. A wall-clock limit has to come from somewhere else: timeout-minutes in GitHub Actions, the equivalent in your platform, or coreutils timeout wrapped around the command.
Layer both, because they fail differently. Hitting --max-turns is a graceful exit: the process ends with an error status, having already flushed whatever it produced. A platform timeout is a kill: the job dies mid-write and you lose the tail of the log, which is precisely the part explaining why the agent was still going. If the only limit you have is the platform one, you will spend your debugging time reconstructing what happened from a truncated file. Stream the transcript to disk as it is produced so a hard kill still leaves you something to read.
name: Agent Code Review
on:
pull_request:
types: [opened, synchronize]
concurrency:
group: agent-review-${{ github.ref }}
cancel-in-progress: true
jobs:
review:
runs-on: ubuntu-latest
# The only real wall clock. The CLI has no --timeout flag.
timeout-minutes: 20
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install agent CLI
run: npm install -g @anthropic-ai/claude-code
- name: Run agent (non-interactive)
env:
# Pipeline-only key, separate billing from interactive use.
ANTHROPIC_API_KEY: ${{ secrets.CI_AGENT_API_KEY }}
run: |
claude -p "Review this checkout against .ci/review-rules.md.
Write findings as JSON to review-verdict.json.
Do not edit files." \
--output-format json \
--max-turns 25 \
--max-budget-usd 2.00 \
--allowed-tools "Read,Grep,Glob" \
> agent-transcript.json
- name: Upload transcript
if: always()
uses: actions/upload-artifact@v4
with:
name: agent-transcript
path: |
agent-transcript.json
review-verdict.json
retention-days: 14Cost Caps and Budget Circuit Breakers
Three layers, because any one of them can be bypassed by a loop you did not anticipate
An agent with no budget will keep going as long as its loop keeps producing tool calls. The nearest control is --max-budget-usd, which stops the run at a spend ceiling you choose per invocation. Set it to something a little above your observed p95 for that job rather than a round number you guessed, and revisit it when the prompt changes, because a prompt that starts asking the agent to read more files will move the distribution.
Above that sits the workflow ceiling. A per-invocation cap does nothing about a matrix job that runs 40 times, so the ceiling that matters organizationally is per workflow per day. And above both sits the control most teams skip: give the pipeline its own billing key, separate from the one your engineers use interactively. That is not bookkeeping. It means a runaway scheduled job cannot drain the budget developers rely on to get work done, and the spend graph stays legible about which of the two is growing.
Per invocation
--max-budget-usd stops one run. Tune it from measured spend, not from a guess
Per workflow
A daily ceiling for the whole pipeline catches the fan-out case a per-run cap cannot see
Separate billing key
A pipeline-only key means automation can never starve interactive work, and the spend graph stays legible
Exit Codes Have to Mean Something
"The agent broke" and "the agent found a problem" are not the same red X
This is the hardest part to get right and the easiest to get wrong quietly. A non-zero exit from an agent CLI can mean the process crashed, the API returned an error, the turn limit was reached, the budget was exhausted - or nothing went wrong at all and the agent is correctly reporting that your code has problems. If your pipeline collapses all of those into "failed", the check stops carrying information and people start ignoring it.
The fix is to stop letting the agent decide the build status. Have it emit a structured verdict - JSON written to a file, or --output-format json captured and parsed - and have a thin wrapper script map that verdict to an exit code. The wrapper owns the semantics, so the CI status means what you documented it to mean, and you can change the policy (fail on high severity only, warn on medium) without touching the prompt.
| Exit code | Meaning | CI should |
|---|---|---|
| 0 | Agent ran to completion, no findings | Pass the check |
| 1 | Agent ran fine and reported findings in the code | Fail the check, post the findings, do not retry |
| 2 | Agent error: crash, API failure, unparseable verdict, turn or budget limit | Mark infrastructure failure, alert the pipeline owner, retry is reasonable |
| 124 | Wall-clock timeout, the coreutils convention | Mark timeout distinctly; repeated hits mean the budget is wrong, not the code |
#!/usr/bin/env bash
# review.sh - run the agent, then decide the exit code ourselves.
set -uo pipefail
TRANSCRIPT="agent-transcript.json"
VERDICT="review-verdict.json"
TIMEOUT_SECS="${AGENT_TIMEOUT_SECS:-900}"
# coreutils timeout supplies the wall clock the CLI does not have.
timeout --signal=TERM --kill-after=30s "$TIMEOUT_SECS" \
claude -p "$(cat .ci/review-prompt.md)" \
--output-format json \
--max-turns 25 \
--max-budget-usd 2.00 \
--allowed-tools "Read,Grep,Glob" \
| tee "$TRANSCRIPT"
status="${PIPESTATUS[0]}"
if [ "$status" -eq 124 ]; then
echo "agent: wall-clock timeout after ${TIMEOUT_SECS}s"
exit 124
fi
if [ "$status" -ne 0 ]; then
echo "agent: exited $status - turn limit, budget limit or crash"
exit 2
fi
findings="$(jq -r '.findings // "error"' "$VERDICT" 2>/dev/null || echo error)"
if [ "$findings" = "error" ]; then
echo "agent: completed but produced no parseable verdict"
exit 2
fi
if [ "$findings" -gt 0 ]; then
echo "agent: $findings finding(s) - failing the check"
exit 1
fi
echo "agent: clean"
exit 0Note the tee and PIPESTATUS pairing. Redirecting straight to a file is simpler, but piping through tee means the transcript lands on disk incrementally and the live job log shows progress, which matters when someone is watching a twenty-minute job wondering whether it is stuck.
Artifacts, Logs and Rate Limits
Keep enough to answer "why did it do that" a week later, and do not fan out into a 429 wall
Capture three things separately, because they answer different questions. The transcript - --output-format stream-json streamed to a file - answers what the agent did and in what order. The diff, saved on its own, answers what changed. The commentary, the human-readable summary, is what goes on the pull request. Merging them into one blob means that reviewing the change requires reading the reasoning, and auditing the reasoning requires scrolling past the diff.
Upload all of it with an explicit retention window long enough to cover the gap between a change shipping and someone asking about it. Then treat those artifacts as repository content, because that is what they are: a transcript from a private repo quotes private source at length, and it inherits that repository's confidentiality classification. If the repo is not world-readable, its transcripts should not be either.
The last operational trap is fan-out. A matrix job running an agent across 30 repositories will hit your model provider's rate limits long before it hits any runner limit, and the failure looks like a flaky agent rather than a quota problem. Use a concurrency group to cap how many agent jobs run at once, stagger the start times rather than firing them all on the same cron minute, and handle HTTP 429 with exponential backoff and jitter instead of an immediate retry that arrives inside the same window. A pipeline that runs 5 agents reliably is worth more than one that launches 30 and completes 11.
Stream, do not buffer
stream-json to a file survives a hard kill. A buffered transcript that never flushes tells you nothing about why the job died
Transcripts are source
They quote your repository at length. Apply the same access controls and the same retention policy you apply to the code
Throttle the fan-out
Concurrency groups, staggered schedules and jittered backoff on 429. Provider quota is the real constraint, not runner count
Related Reading
Where the controls referenced above are covered in depth
Prompt Injection Defense
Content boundaries and egress control for agents that read attacker-writable text
Agent Identity
Giving a pipeline agent its own scoped principal instead of borrowing a human account
CI/CD Integration
Wiring development environments and pipelines together without duplicating the toolchain
Spec-Driven Development
Writing the instructions a headless agent will follow when nobody is there to clarify them
Agent Readiness
Assessing whether your repository and pipeline can support automated agents at all
Sources
Primary documentation and research behind the guidance on this site
