Tooling & Integration

Running Coding Agents in CI: Guardrails for Unattended Work

David Guzenburg/ / 8 min read

CI runners hold your deploy keys and registry tokens, because that is what CI is for. An agent running there inherits the most powerful credentials you have, unsupervised.

CIautomationpermissionsworkflow

What changes when nobody is watching

An agent in your terminal has a human in the loop by default. You see each step, and you interrupt when it goes wrong. In CI nobody is watching, and three things that were manageable become structural problems.

The last is the one to design around first. It is not a small increase in exposure — it is the most powerful credential set in the organisation, in an environment with no live supervision.

Tasks that work unattended

The common property is that success is machine-checkable.

TaskVerified by
Dependency upgrade plus fixesTest suite passes
Backfill missing test coverageCoverage threshold met, suite green
Mechanical migration (API rename)Compiles, tests pass
Triage: label and summarise issuesOutput is text; nothing is executed
Draft a fix from a stack traceTest reproducing the bug now passes

Tasks that do not work unattended share the opposite property: success is a judgement. "Improve the error handling" has no green check to converge on, so an unattended agent optimises for the appearance of completion.

The pattern that holds

name: Agent dependency upgrade
on:
  schedule: [{cron: '0 3 * * 1'}]
  workflow_dispatch:

permissions:
  contents: write        # branch only — main is protected
  pull-requests: write
  # deliberately absent: packages, deployments, id-token

jobs:
  upgrade:
    runs-on: agent-runner        # dedicated, not the deploy runner
    timeout-minutes: 30          # hard ceiling
    steps:
      - uses: actions/checkout@v4
      - name: Run agent
        env:
          AGENT_MAX_TOKENS: 400000
        run: ./ci/agent-task.sh "Upgrade deps in package.json to latest
                                 minor. Fix any resulting test failures.
                                 Do not change application behaviour."
      - name: Verify
        run: ./check.sh          # exactly what a human PR must pass
      - name: Open PR
        run: gh pr create --draft --label agent-generated

Five controls in that file, each closing a specific failure:

Never on protected branches

An agent job should hold contents: write for branches and nothing that lets it reach main. Branch protection is what makes "the agent went wrong" a bad pull request instead of a bad deployment.

Prompts for unattended runs

Interactive prompts can be vague, because you correct course as you go. CI prompts cannot.

InteractiveUnattended
"Upgrade the dependencies" "Upgrade to latest minor. Do not change major versions. Do not modify application code except to fix compilation or test failures caused by the upgrade."
"Add some tests" "Add unit tests for uncovered public functions in src/billing/ until coverage reaches 80%. Do not modify existing tests or source."

Two properties make the right column work: an explicit scope boundary, and an explicit completion criterion. Without a boundary an unattended agent expands the task; without a criterion it does not know when to stop.

Failure needs to be legible

An unattended run that fails should say why in a form a human can act on without replaying the session. The default — a red X and a thousand lines of log — means nobody investigates, and the job quietly becomes noise people mute.

- name: Summarise outcome
  if: always()
  run: |
    {
      echo "## Agent run"
      echo "- exit: ${{ job.status }}"
      echo "- tokens: $(cat .agent/tokens 2>/dev/null || echo n/a)"
      echo "- duration: ${SECONDS}s"
      echo "- files changed: $(git diff --name-only | wc -l)"
      echo ""
      echo "### Last check output"
      tail -30 .agent/check.log 2>/dev/null || echo "no output"
    } >> "$GITHUB_STEP_SUMMARY"

Four categories of failure are worth distinguishing in that summary, because each calls for a different response: hit the timeout, hit the token ceiling, finished but the checks failed, or errored outright. Only the third is about the code. The other three are about the job configuration, and treating them all as "the agent failed" sends people looking in the wrong place.

Marking the output honestly

Agent-generated PRs should be identifiable. Not because they are inherently worse, but because reviewers should calibrate differently — the author cannot answer questions, and no human formed an intent behind any particular line.

Cost control that actually binds

An unattended agent has no natural stopping point. Three limits, and they catch different failures:

LimitCatchesMissed by the others
Wall-clock timeoutStuck waiting on somethingA fast, expensive loop
Token ceilingRetry loops, runaway readingA cheap job hung on a network call
Turn limitPing-ponging between two failing statesOne enormous single turn

Set all three. They are cheap to configure and each covers a gap the others leave. A job that hits a limit should fail loudly rather than partially commit — half-finished work merged because the timeout fired mid-task is worse than no work at all, and considerably harder to diagnose.

Start with the boring one

If you are adding agents to CI for the first time, start with weekly dependency upgrades. Well-defined, machine-verifiable, low stakes, genuinely useful, and a task nobody enjoys.

It also exercises the whole path — permissions, runner isolation, timeouts, PR creation, review flow — on something where getting it wrong costs you a closed pull request. Learn the mechanics there before pointing the same machinery at anything that matters.

Takeaway

Only automate tasks with a machine-checkable definition of done. Give agent jobs their own runner and the narrowest possible permissions — no publish, no deploy, no production. Bound them with timeouts and token ceilings, write prompts with explicit scope and completion criteria, and label the output so reviewers calibrate correctly.

Keep reading
Tooling & Integration

Event-Driven Agent Jobs: Fire on Change, Produce Information

Why scheduled agent jobs waste tokens and arrive detached from context, which events are worth wiring, the permissions design that keeps them safe, and how to stop them becoming noise.

Codex vs Claude

Long-Horizon Runs: The Loop Cannot Tell Progress From Motion

A multi-hour agent run does not stop when it stops making progress. Why activity metrics rise fastest during a failing search, the degenerate solution to make the tests pass, checkpointing, budgets with defined exits, and compaction as a source of drift.

Codex vs Claude

Agents in the Code Host: Triggering on Text Anyone Can Write

Wiring an agent into issues and pull requests puts its work where the team already looks. It also turns issue text into an instruction executed with repository credentials. Trigger design, token scoping, fork hazards, and what to allow without a human.

Codex vs Claude

Thirty-Three Hook Events or Three Approval Policies: Matching the Instrument to the Rule

Claude Code hooks fire on thirty-three named lifecycle events; Codex leads with approval policies and sandbox profiles. Which rules need which instrument, and why most teams use two events out of thirty-three.

← Types as the Contract: A Decision Procedure Downstream of a Generator  ·  Build System Integration: Your Toolchain Is the Agent's Feedback Loop →

All tooling & integration articles  ·  Every article