Automated development
Let pair deliver cards for you, Supervised (you trigger one card at a time) or Autonomous (a daemon that watches your backlog), from one manual run to a systemd service, with the engine, model and effort of every stage under your control.
This tutorial shows how to let pair deliver cards without you writing the code or driving every stage. Supervised: you start one card (pair-cli run --card, /pair-workflow-cycle) or one batch and review the result. Autonomous: a daemon (pair-cli run --root --parallel --watch, or /pair-loop) picks cards from your backlog and ships those your gates allow. You will go from a manual dry-run to a systemd-managed daemon on a dedicated box in about 30 minutes.
Before you start
Automated development is one delivery cycle (a state machine that takes one card to a review-approved pull request, optionally to a merge), three ways to drive it, and one set of autonomy gates that decides how far it may go without you. The model is on two pages you should have read once: Delivery workflow (why, roles, handoffs, gates) and The delivery cycle in depth (every stage, every realization). This tutorial only drives it.
| Driver | Use it for |
|---|---|
/pair-workflow-cycle <id> | one card in a Claude Code, Codex or pi session, you nearby |
pair-cli run --card <id> | one card from a shell, cron or CI, on any engine |
/pair-loop, or pair-cli run --root --parallel --watch | a whole backlog, repeatedly |
All three read the same state machine and the same gates. Which assistants support which: Modes by assistant.
Autonomy: until, gates, escalation
Declared once in ## Autonomy of tech/automation.md (or passed as --until, --prepare, --merge); precedence is argument, then adoption, then KB default, and the run prints every effective value with its source. Every key is in Adoption settings.
until:readystops before implementation;pr(default) stops at the review-approved PR;mergedis the only value under which the merge gate is evaluated.preparegate: who refines and breaks down a Draft card.always(default) keeps a human.mergegate:always(default) parks every card for a human;neverandwhen; has: …; lacks: …enter the merge stage.- Escalation: a
whencondition that fires stops the card asescalatedwith one comment on it naming the condition; the PR stays open, the rest of the backlog continues. - Blocking Severities:
## Blocking Severitiessets the floor (Critical,Major,Minor; defaultMinor) at or above which a review finding blocks, plus an optionalmax-dispatchesceiling on a run's handoffs.
In your assistant: edit tech/automation.md, then node .claude/skills/pair-workflow-cycle/scripts/autonomy-policy.mjs resolve. With /pair-assistant (optional, Claude Code): "let green cards go up to merge" shows the ## Autonomy diff, validates it and writes it through record-decision (how it writes).
Per-stage engine, model and effort: workflow profiles
A workflow profile chooses, for each stage of the cycle (implement, verify, prepare, validate, green, contract, merge), which engine runs it, which model, at which effort, and whether the stage starts a fresh agent or resumes the previous one of the same role. It lives in pair.config.json under workflowProfiles and is resolved once per run by one script, workflow-profile.mjs, for both pair-cli run --card and /pair-workflow-cycle. With no profile nothing changes: the run's engine, its default model, default effort, a fresh context on every stage.
A realistic profile: cheap where the work is bounded by sealed tests, frontier where an independent judgment is the gate, and a second vendor for the final review.
default: the profile a run uses when no flag names one.files: a glob (or list) of profile files, each indexed by its ownname.inline: profiles written here, keyed by name (the key is the name). Anything else underworkflowProfilesis refused.- A stage entry overrides only the fields it declares; the rest come from
defaults, then from the KB default.
| Field | Values | Notes |
|---|---|---|
engine | default (the run's engine), pi, opencode, claude, codex | pair-cli spawns that engine for the stage and checks it is installed before the first dispatch; in a session the session is the engine, so a stage engine is only reported |
model | default (the engine's own), an explicit id, a class (cheap, balanced, frontier), by-tier | a class resolves through modelClasses; a near-miss of a keyword (Cheap, by_tier) is refused as a typo |
effort | default, low, medium, high, xhigh, max | in a session: enforced on Codex, a prompt request on Claude; pair-cli sends it to claude (--effort) and codex (-c model_reasoning_effort, with xhigh and max mapped to high); on pi and opencode it is not sent: the run prints one warning and the table marks the stage ignored |
context | fresh, reuse | reuse resumes the previous agent of the same role, and only where the cycle allows it (prepare, green); on validate or verify it is refused at load |
Choosing the profile. Resolved once per run, highest first: --workflow-config <path> (an external profile file, used verbatim) > --profile <name> (looked up in files and inline) > workflowProfiles.default > the KB default. A name no source has halts profile-unresolved, a bad value halts profile-invalid, two files with the same name halt profile-name-collision: never a silent fall back to the KB default, so a typo cannot run the whole cycle on the wrong model.
In your assistant, /pair-workflow-cycle takes the same two as its $profile and $workflowConfig arguments. Both flags also work with --root, --parallel and --watch: the loop forwards them to every card it spawns, and an unknown profile halts profile-unresolved before any card starts.
Printing the resolved table. Every run prints the profile, its source, its hash and one line per stage (engine, model, effort, context, each with the level that decided it) before the first dispatch. To see it without running anything:
The first line names the profile, its source and its hash; then one line per stage:
Variant: let the model follow the card's risk tier. Write "model": "by-tier" on defaults (for the stages that declare no model of their own) or on a stage, and map tiers to classes in ## Model Policy of tech/automation.md:
The tier is the card's risk:* label (from --card-tags, or read from the tracker under --autonomous; the loop passes each child card its labels). An untagged card, a tier the policy omits, a missing policy or a class with no id in modelClasses resolve to the engine's default model, never a halt. The resolved line names the class and the tier it came from: cheap (risk:green) → claude-haiku-5-5.
The profile's name and hash are stamped in every handoff for audit, but they are not an input of the cycle: changing the profile between runs invalidates no evidence. Full schema and refusals: Configuration; the recipe: Autonomy and per-stage engine and model.
Walkthrough: your first unattended run
Prerequisites
- pair-cli installed in the project (
pair-cli installcompleted) - A classification model in place,
classifyalready emittingrisk:*tags on your cards (see Quality Model). Unattended delivery filters on this; without it, there is nothing to filter on. - At least one execution engine installed and authenticated, pi,
opencode, or Claude Code itself run headlessly (
claude -p). See Adding a Harness if you're weighing which one. - Comfort with the idea that this genuinely pushes and merges code without a human clicking anything: that's the feature, not a side effect. Read the Safety section before pointing it at a real repository.
What you'll build
- A
tech/automation.mdpolicy declaring which cards run unattended, whether they can merge on their own, and where the trail is written - One manual run you watch, to prove the config before anything runs unattended
- A daemon, systemd timer (or a scheduled task, or a supervisor loop), that re-invokes
pair-cli runon a schedule, so new eligible cards get picked up without you starting anything by hand - A working understanding of what "eligible" means, who can change it, and what stops the whole thing cold if anything looks wrong
Step 1: Declare the automation policy
Everything here lives in one adoption file: .pair/adoption/tech/automation.md. It is optional: absent, it means "automation is off", never "automation runs on everything". Each section has its own fail-closed default. ## Workflows is what Option D in Step 4 adds; ## Harness and ## Model Policy (supported engines, per-tier model class) are in the guideline.
The filter is a list of tags classify already emits, never a new tag of its own. Nothing is
written onto a card to make it selectable; risk:green here is exactly the risk:green your
review process assigns. If your project renamed the family in tech/risk-matrix.md's Tag
Projection (priority instead of risk, say), write the renamed label here. Matching is plain
string equality, any-of, no tier arithmetic.
The merge gate is the one line that turns on unattended merging. The shipped default is merge: always, and always parks every review-approved card for a human, whatever its tier. To let the loop merge the cards it selected once the PR is review-approved and the gates are green, write both lines:
merge: when; has: cost:red; lacks: risk:green enters the merge stage too, but escalates any card matching the condition instead of merging it. The gate is a switch, not a second risk model: the per-tier gate table lives in the quality model (lint, type, build for green; unit tests from yellow; integration and E2E on red), and the merge stage enacts it.
Using a team-specific tag instead of risk:green. Nothing ties the filter to the risk family. If a maintainer adds automate to specific cards rather than trusting the whole green set, declare filter: automate; the label only has to exist on the card.
Projects written for v0.4 may still carry ## Eligibility (one label) and ## Auto-Advance ((none) or a tier). They are legacy: autonomy-policy.mjs translates them to filter and to the merge gate, and a ## Autonomy section that disagrees with them is refused. Write ## Autonomy only in a new project.
Step 2: Prepare the engine for headless, unattended runs
pair-cli run drives one of three engines, pi,
opencode, or Claude Code (claude -p), chosen with --engine, a
project default in pair.config.json, or (Claude Code) the schema default. Each has its own
headless posture:
| Engine | Confirmations | Project trust | What to set up before an unattended run |
|---|---|---|---|
pi | None at all: no permission prompts exist | Per-directory, ~/.pi/agent/trust.json | Trust the project explicitly (pi's own /trust, or /pair-capability-setup-harness): a headless run silently ignores an untrusted project's local resources rather than asking |
opencode | On by default, opt out with --auto | None: no trust gate | Nothing beyond authentication (opencode auth list) |
claude (claude -p) | On by default, opt out with --permission-mode bypassPermissions | Skipped automatically in -p mode | Nothing beyond claude being on PATH and authenticated |
--autonomous on pair-cli run is the one switch that maps to whichever of these each engine actually
has, pi requires it outright (it has no other posture to run headless with), opencode and Claude
Code accept it as an explicit opt-out of their default confirmations.
defaultProjectTrust: "always" in pi's global config trusts every project pi ever opens on that
machine. That's the right call on a dedicated automation box running nothing else: it's the
wrong call on your laptop. Prefer the per-directory trust decision unless the machine's whole job
is running unattended loops.
Pin the engine so every invocation (yours, a teammate's, the daemon's) agrees without repeating
--engine everywhere:
Step 3: One run you watch, before anything is unattended
Resolve everything and print it: nothing spawns:
This prints which engine won and from where (flag > config > schema default), the work perimeter (scope + cap), and the autonomy/trust posture that would apply. If anything here surprises you, stop: an unattended run should never be the first time you see this output.
Then run it for real, still narrow, still watched:
--autonomous accepts the engine's own confirmations away; it does not turn off the
work-perimeter requirement: a scope and a cap are mandatory on
every run, autonomous or not. --max-iterations here narrows (never widens) whatever
## Stop Predicate in your policy already declares, keep it small for this first run regardless of
what the policy says.
If the loop composes a skill with an internal approval round (one of the assess-*/map-* family) under --autonomous it now passes --approval auto to any of them that declare it, so the round resolves from project state or reports itself unresolved instead of asking a question nobody is there to answer.
This is what closed the gap between pair-cli run shipping and those skills gaining a non-interactive mode in the first place: if you're on an older pair-cli, update it before relying on this.
Step 4: Turn it into a daemon
pair-cli run and the in-harness pair-loop workflow both stop when nothing is eligible: they
don't block waiting for new cards to appear. "Watching the backlog" is therefore an external
scheduler re-invoking the command periodically, not a single long-lived process holding a socket
open. Three ways to get there, in order of what most teams should actually use:
Option A: systemd timer (recommended on a Linux VPS)
Each firing is a fresh, bounded run: if nothing is eligible it exits in a couple of seconds; if something is, it drives it to the stop predicate or the iteration cap, then exits: the next timer firing picks up from wherever the backlog is at that point. A crash mid-run leaves the audit trail intact (every iteration is appended, never overwritten, see Step 5), so the next firing continues cleanly rather than repeating finished cards.
Option B: cron, if you don't have systemd
Same idea, no unit files. You lose systemd's structured logging and the After=network-online.target
ordering guarantee, fine for a low-stakes setup, worth moving to Option A once this matters.
Option C: a long-running supervisor loop
If you want one persistent process instead of periodic re-invocation (useful if you're already
running something like opencode serve as the engine and want to keep its process warm):
Run this under systemd too (Type=simple, Restart=always) rather than a bare nohup: you want
the same crash-recovery guarantee Option A gets for free.
A daemon is a dedicated automation box, not a shared dev machine. It holds engine credentials capable of pushing to your default branch unattended, scope its git credentials to what it actually needs (a machine user or a fine-grained token, not your personal one), and don't run anything else on it you wouldn't want that same credential set exposed to.
Option D: no schedule at all: let a tag start the run
The three options above are pull: something wakes up on a timer and asks what is eligible. The
alternative is push: a run starts when you tag a card, and never otherwise. Add the one
section Step 1 left out (## Workflows, the seventh) to the same policy file:
…then have your code host call the entry point when a label changes, passing the card and the labels it just observed:
The mapping decides which workflow runs; a card carrying no mapped tag runs nothing, and with no ## Workflows section at all the command reports no mapping declared and exits cleanly.
Map a tag only to a workflow from the guideline's catalog (pair-loop, pair-process-plan-tasks): the card is handed over as the argument that workflow declares (--root 217, or --story 217 for pair-process-plan-tasks) and a mapping naming anything else HALTs rather than start a run that would pick its own card.
Refinement is deliberately not in that catalog: pair-process-refine-story ends only on an explicit human approval, so unattended it would either stall until the timeout or approve its own work past the gate, refine by hand, and map tags for what follows. Only a run
start is printed as a DISPATCH-RECORD: line for your trigger to post back on the card, skips and
endings stay in the audit file, so a label edit that routes nothing never comments. The ready-made
GitHub Actions job, fires on issues: [labeled], installs the CLI (a runner does not ship it) and
dispatches, is in collaboration/automation/github-automation.md in the knowledge base; the flags
are documented in pair-cli run.
The dispatch takes a per-card lock so a burst of label edits cannot start two runs on one card,
but that lock is scoped to one working area. On a daemon box (Options A–C) that is the guard. On
ephemeral CI runners each job checks out its own workspace, so there the host's concurrency group
is the guard, and every path that can dispatch a card belongs in it: the shipped job declares
concurrency: pair-dispatch-<issue> for exactly that reason.
Pull and push compose: the timer keeps the eligible backlog moving, the tag starts a specific card now. Both read the same policy file, and both stop at the same human merge gate.
Step 5: Watch it: audit trail and recovery
Every iteration is appended to ## Audit Location's path (default
.pair/working/automation/loop-audit.md), never overwritten, so a killed-and-restarted run's
history stays intact. This is what you tail -f from the daemon box, and what you read after the
fact to see exactly which cards ran, in what order, and why the loop stopped when it did.
A killed run resumes from its own audit file (not a separate checkpoint) on the fan-out path, so restarting the daemon after a crash or a reboot never re-runs a card the audit already recorded as done.
Safety: what this does and does not protect you from
- The loop itself never merges; the merge gate decides. Nothing merges unless the gate allows it
(
merge: always, the default, parks every card for a human;merge: neverandwhenenter the merge stage underuntil: merged). Keep it atalways, or leaveuntilatpr, to get unattended development: every card still stops at a review-approved, gate-green PR for a human to merge. - An untagged card is treated as the highest risk tier. The loop no longer filters by tier
itself (the selection filter does), so an untagged card can be selected, but it is handed over as
risk:redand never auto-advanced. - The filter is an any-of list of labels, each matched exactly. A card carrying any one of the listed labels is selected, but there is no boolean grammar: no AND, no "not red". If you want a different set of cards automated, name the labels explicitly or add a classification tag.
- This is not a security boundary on its own. The engine you point at a live repository can push and (if you enabled it) merge. Everything in Step 4's warning about credential scope is the actual control: the policy file controls what runs, not what the process is capable of if something goes wrong upstream of it.
- Start narrower than you think you need, watch the audit trail for a few cycles, then widen the perimeter or the auto-advance tier once you trust what you're seeing.