Chains
One skill per item is plenty for a red check. For an issue you'd rather see implemented, reviewed by the other agent and fixed before you look, a rule chains more steps after its skill, in the same clone and on the same branch.
No new chain features until real runs ask for them: only fixes and simplifications.
then
Steps run after the rule's skill, one after the other. The whole chain is one unit and publishes once, when it ends: one PR, one push or one report. To split work into PRs of their own, plan it into tasks. A step is a skill name, or a map:
queue:
- skill: implement
where: { type: issue, label: awaywork }
then: review
- skill: plan
where: { section: Ready }
model: { claude: opus }
mcp: [sentry]
then:
- { skill: implement, model: { claude: sonnet }, instructions: notes/impl.md }
- { skill: review, provider: codex, mcp: [] }A step takes provider, model, skills, mcp and instructions, inherited from the rule unless set ([] clears a list), plus its own with and when. Above, implement gets sentry, and review gets no server and keeps opus should it route to Claude. Each step finds the previous one's output in .away/context/previous/: report.md, verdict, the chain's pr.md so far, its commits in diff.patch, and in a loop the until output in check.log. The first step finds the item's latest report there instead, marked as from an earlier run: that's how an approved implement finds its plan, on a retry too, since a failed implement with no report doesn't replace it.
plan shows a chain as one row, every step routed (every loop round up to max) and budgeted live:
Units 1
item rule skill provider deliver title
1 api#12 #1 implement (built-in) claude draft_pr Rate-limit the export endpoint
then loop review ⇄ fix until verdict approve, at most 3 rounds
review (built-in) on codex
fix (built-in) on claude
then docs (user) on claude, when the branch changes src/**The PR body is the pr.md the steps kept, plus an ## Awaywork chain section with each loop's rounds, its failing check if any, and each report step's latest report.
How a chain ends:
- A step that leaves nothing for the next ends it (a committing step with no commits, a report step with no report), and the rest are passed over; the chain counts as finished.
- So does a step that asks:
.away/out/questions.mdplusaskin.away/out/status(implementon a vague item). awaywork posts the questions on your item and publishes any commits, a PR as a draft; your reply reruns the unit, within minutes underrun: soon(Questions). - So does
continue: its commits open as a draft a later run goes on with (Continue). - A failed or stopped step, or the window's end, ends it and publishes what it has. A PR it opened or commits it pushed wait for your review; otherwise it runs again next run. Ctrl-C publishes nothing.
Details:
A skill commits unless its metadata says it reports, as
planandreviewdo. A chain that committed nothing publishes its reports, joined, as one.The rule's
deliverdecides where commits go; steps have nodeliverorbranch. A committing skill that should only report is a skill of your own.model: { claude: [sonnet, opus] }retries a failed step on the second, and the chain goes on.A chain counts once against caps and its rule's
units, once its first step has a provider; over a cap it's skipped whole.On someone else's PR or a fork's, a chain that could commit is skipped with the reason;
deliver: reportmakes it a review chain, andpending_reviewleaves its last report as your pending review.A skill runs at most once outside loops, and once across loops: a loop may repeat the rule's skill, as in
fixthenloop: [review, fix].Steps are named
<unit>-<skill>, in a loop<unit>-<skill>-<round>, each with a numbered folder under the run'sunits/<unit>/, next toout/with what the chain published:units/acme-api-12/ 1-implement/ context/ prompt.md worker.log out/ replay.json 2-review/ 3-fix/ out/ pr.md diff.patchawaywork replayrebuilds only a unit's first step: later ones start from commits that only existed in the clone.
Loops
A loop repeats its steps until a condition holds or max rounds ran. Review and fix until the other agent approves:
queue:
- skill: implement
where: { type: issue, label: awaywork }
then:
- loop:
- { skill: review, provider: codex }
- fix
until: { verdict: [approve, comments] }
max: 3until is checked after every step and ends the loop as soon as it holds; with both keys, both must. verdict is the latest word a loop step wrote to .away/out/verdict (review writes approve, comments or changes): a word, a list, or { not: … }, which doesn't hold until some loop step wrote one. command holds when it exits 0. max is at most 5. Rounds work in the chain's clone and publish with it.
A command, in until or when, runs in the clone, outside the model, like setup on a worker's branch:
- No repo
secrets, at most 15 minutes (past that it fails), never past the window's end, and never on an untrusted author's PR: one not run doesn't hold forwhenand ends the loop either way. setupdoesn't rerun, so a command needing a step's new dependencies installs them (pnpm i --frozen-lockfile && pnpm test). It reruns only once a step committed.- In
untilit fails while the clone has uncommitted changes beyond what setup and earlier checks left, since they wouldn't publish;whensees them, as the step will. - Stopped by the window's end, it ends the chain, which publishes; a
whenstopped that way doesn't pass over its step. After Ctrl-C nothing publishes and the chain is tried again. - A loop that ends on a failing command puts its output's tail in the PR body.
A step that leaves nothing for the next ends only its loop: a fix that disputes every finding commits nothing, and another review would repeat itself. Steps after the loop still run. Loops can follow loops, and a step with a when can come right before one.
when
A step runs only when its when holds; otherwise it's passed over as if absent:
queue:
- skill: implement
where: { type: issue, label: awaywork }
then:
- loop:
- review
- { skill: fix, when: { verdict: changes } }
until: { verdict: [approve, comments], command: pnpm test }
max: 3
- { skill: security-review, when: { changed: "src/auth/**" } } # a skill of yoursverdict: the chain's latest verdict, by any earlier step, matched as inuntil, except{ not: … }also holds when none was written.command: as inuntil, before the step's agent.!runs a step only when a check fails (when: { command: "! pnpm test" }), its output in.away/context/previous/when.log. One that times out ends the chain without passing over the step.changed: a glob or list, holding when the chain's commits change a matching path since where it started (the default branch or the awaywork PR a rule took up, as first cloned, so moving a ref changes nothing). Git globs from the repo root, no leading/:*.mdmatchesREADME.md, notdocs/a.md;src/auth/**everything under it.- Every key must hold.
planshows eachwhen; the report says which failed. - A passed-over step doesn't end a loop, unless its command didn't run (it wouldn't later either). A round with every step passed over ends it.
quota
A rule's quota checks quota, and skips the whole chain when it doesn't hold:
queue:
- skill: implement
where: { type: issue, label: awaywork }
quota: { headroom: 20% } # only on spare quota
then:
- { loop: [review, fix], until: { verdict: approve }, max: 5 }runs_until_reset holds when at most that many window openings are left before the long window resets (1 is the last window, or a manual run with one left); headroom when at least that share is left under this run's ceiling. Both read the quota the run last saw and fail without a reading; plan notes it. They're rule keys only: a step's when takes neither, and a rule takes no when.
push
By default awaywork only pushes new branches. deliver: push adds commits onto your own open PR instead of stacking a draft on it, say to address review threads in place:
queue:
- skill: fix
where: { type: pr, mine: true, unresolved_threads: { gt: 0 } }
deliver: pushThe chain pushes once, at the end, every step's commits included. The agent writes .away/out/pr.md as for a PR (no pr.md, no push); the PR body stays yours, and pr.md with the chain's notes goes into the morning report.
After a push the PR waits for your review: no rule takes it while its head is awaywork's commit, except after a sync's clean merge. Any commit on top, a rebase or an amend releases it. A bot that commits on every push (an autofixer, a snapshot updater) releases it at once, so the rule pushes every run: keep that repo out, e.g. repo: { not: acme/web }.
Details:
- Only onto an open PR you authored, awaywork's included (opened with your login). On someone else's PR, a fork's, or an item without your PR, a chain that could commit is skipped with the reason.
- Fast-forward only, on top of where the clone started; a branch that moved since is refused. New commits including a merge aren't pushed, but for
sync's. - Right before the push awaywork rereads the PR and refuses if it's no longer yours or open, has auto-merge on (it would merge unreviewed commits), or its branch is on a fork, the default branch, protected, or another person's open PR's branch too.
- Commits get the
Awaywork:trailer, which drops signatures. - Only a rule opts in; a skill's frontmatter can't default to
push. - The pushed head lives in the state dir, which a GitHub-hosted runner doesn't keep: there the rule pushes every run its
whereholds.
sync
Your PR is green, but main moved on. sync is a built-in skill where awaywork merges the PR's base into its branch itself and pushes the merge, like a push rule. A model steps in only to resolve conflicts.
queue:
- skill: sync
where: { type: pr, mine: true, behind: { gt: 0 } }
deliver: pushbehind counts base commits the branch lacks (GitHub fields). Leave conflicts out of where: GitHub resets it to unknown after a push to the base, so conflicts: false would miss the PRs that just fell behind; sync reads it itself. Without conflicts: true no model runs, so plan lists no provider, and the run pushes one Merge <base> into <branch> commit with the trailer:
item skill status result
1 api#188 sync done pushed onto https://github.com/acme/api/pull/188
Merge awaywork-sync-base into awaywork-sync-feature: 2 commits, awaywork-sync-base at f25506b.Details:
- awaywork runs
git merge --no-ffof the base as fetched, first thing, before any repo code, with your identity and no signing config. A clean merge runs no worker, spends no quota and gets no gates: no model wrote anything. - A clean merge can still be wrong (two branches adding the same block), so
setupthenverifyrun on the merged tree, the only repo code here; a failure goes into the report and the push still happens, for yourchecks: failingrule to pick up. - Only that merge is pushed. If
setuporverifyamend it, commit on top or move the branch, nothing is, and the report says the head isn't awaywork's merge. - An up-to-date branch pushes nothing (
already up to date with <base>), a finished attempt: the PR is unchanged until it or its base moves, and awaywork remembers the base head it tried, since a base push doesn't touch the PR'supdated. - No review hold after a clean merge, so the next
syncor afixon the CI it started can take the PR at once. - A rule of its own:
deliver: pushrequired; athenorquotaon it, orsyncas another rule's step, is a config error. Everythingpushchecks holds, GitHub only.
Conflicts
When the PR reads conflicts: true at plan time, the unit gets a provider and quota like any other. awaywork still makes the merge before any repo code, leaves the conflict in the clone, markers and all, and lists it in .away/context/merge.md:
awaywork merged `awaywork-resolve-base` (at 95202a5, 2 commits) into `awaywork-resolve-feature`, and these files conflict:
- resolve-probe/a.txt
The commits on `awaywork-resolve-base` that touch them, newest first:
- da5074a resolve probe: theirsThe worker resolves the files, keeping both sides' intent, fixes fallout elsewhere (a function the base renamed), runs its checks and writes .away/out/pr.md. It doesn't commit: awaywork makes the merge commit from its working tree, on exactly your head and the base head it merged, whatever the worker did to .git. Then it's a normal push: verify, the gates, and the summary in the report, the decision card on top when one fired:
item skill status result
1 api#191 sync done pushed onto https://github.com/acme/api/pull/191, gate resolved fired
- resolved: resolve-probe/a.txtDetails:
- Gates read the resolution, not the base: git's automatic merge, markers included, is kept as a local commit never pushed, and gates diff from it.
- A
changedgate fires on paths the model changed on top, and always on the conflicted ones, whatever was kept (keeping your side of a binary still is a call made); a gated path only the base touched doesn't fire.$AWAYWORK_FROMis that commit: diff it toHEADwith two dots, since...would read every base commit. A judge's prompt lists the conflicted paths. - The summary opens with
Resolved:and the paths, thenAlso changed:with whatever else is on top of git's merge (fallout fixes,setup's edits to tracked files), above the worker's lines. - The merge takes the files git's merge holds as the worker left them, deletions included, nothing new: a new file from setup, the worker or checks stays out (
Left out, untracked: generated.txt, scratch.log). A resolution that needs one asks instead. - The push is held for your review, as any
push. - Nothing is pushed, each counting as an attempt, when:
- setup or the worker committed or moved
HEAD; - a conflicted file still has a
<<<<<<<or>>>>>>>line (longer ones too, perconflict-marker-size) neither side had, so fixtures with markers are fine; - the resolution keeps nothing of the base (as
git merge --abortwould), or reverts a file the base changed outside the conflicts, deletions included (the resolution undoes main's change to x.txt, not pushed: a fallout fix gives new content, it doesn't revert); - the worker failed, stopped or asked, the question going on the PR.
- setup or the worker committed or moved
- A conflicted lockfile often breaks
setup, which runs on the conflicted tree first:merge.mdthen notes it, and the worker goes on. conflictsreads unknown for a while after a base push, so a run may plan no provider and hit the conflict: it aborts, pushes nothing, says where (conflicts with awaywork-resolve-base in resolve-probe/a.txt, and no provider to resolve them), and counts no attempt, so a later run plans a provider. Same when providers had no quota.- A conflicted submodule pointer, or a file name that isn't valid UTF-8, aborts with no worker or quota and asks you to merge by hand (
conflicts with main in vendor/lib (a submodule): merge it by hand), a finished attempt likealready up to date. - The instructions are the skill's body, so you can eject
syncto tweak how it resolves; keepmetadata.merge: base.