# Getting started ## Requirements - Node 22 or later, and `git`. - Where your code lives, and awaywork pushes branches and opens draft PRs: - GitHub: [`gh`](https://cli.github.com) logged in. - Bitbucket Cloud: [`twg`](https://developer.atlassian.com/cloud/twg-cli/) logged in and an SSH key, see [Bitbucket and Jira](./config/sources.md#bitbucket-and-jira). - At least one agent CLI, logged in with your subscription: - [Claude Code](https://code.claude.com): logged in, or `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`. - [Codex](https://developers.openai.com/codex): `codex login`. - [Cursor](https://cursor.com/docs/cli/overview): its `agent` CLI and a `CURSOR_API_KEY`, even on your machine ([why](./config/providers.md#providers)). - Any other, opencode, Amp and friends: a command that runs it on a prompt file ([Bring your own CLI](./config/providers.md#bring-your-own-cli)). - Where your tasks live, one [source](./config/sources.md) or more: - GitHub: your issues, your PRs and those awaiting your review. `gh` is enough. - Asana, Linear or Jira Cloud: a personal access token, a personal API key or an Atlassian API token ([which](./config/sources.md#tracker-tokens)). - Anything else, an internal board, Sentry errors, a list of chores: a command, in any language, that prints your items as JSON ([Custom sources](./config/custom-sources.md)). - No tracker at all: [routines](./config/sources.md#routines) make items from the clock. ## Install ```sh npm install -g awaywork awaywork --help ``` ## Configure ```sh awaywork init ``` `init` opens Claude Code on `/away set up awaywork`, and the [`/away` skill](./away.md) does the setup with you in a few rounds of questions. No awaywork plugin in Claude Code yet? It offers to install it first: ```text $ awaywork init ◇ Install the awaywork plugin for Claude Code? It runs claude plugin marketplace add AndreaProvenzale/awaywork, then │ claude plugin install awaywork@awaywork │ Yes ✔ Successfully added marketplace: awaywork (declared in user settings) ✔ Successfully installed plugin: awaywork@awaywork (scope: user) │ ● Opening Claude Code on /away set up awaywork; /exit comes back here ``` Say no and that one session loads the plugin from the npm package instead (`--plugin-dir`), nothing installed. The setup in full, and the pane `/away` opens once there's a config, are in [`/away` in Claude Code](./away.md). Run `init` again with a config there and `/away` tells you where it stands and what you could change. No Claude Code? `init` says what to do instead: - Codex gets the same plugin: see [Codex and friends](./agents.md#codex-and-friends). - Any agent that loads skills can take them from the package. - `awaywork init --yes` asks nothing and writes the detected defaults, for a container or CI. Or write a minimal config by hand: ```yaml providers: [claude] repos: acme/*: sources: github: {} queue: - skill: fix where: { type: pr, mine: true, checks: failing } ``` With no [`queue`](./how-it-works.md#the-queue), nothing runs. GitHub issues need a rule of their own, e.g. `implement` for issues labelled `awaywork` (see [Configuration](./config/index.md)). awaywork reads the issues assigned to you, so assign yourself too: the label alone plans nothing, unless a [`search`](./config/sources.md) reads it. Check it: ```sh awaywork doctor # the config, binaries, credentials, quota, sources ``` ## Preview the next run ```sh awaywork plan ``` ![awaywork plan loading its steps and showing the next run's units, then awaywork report on the night's run: a review report opened and a draft PR's diff](/demo/quickstart.gif) `plan` reads your sources and each provider's quota and shows what a run would do, without running an agent. In a terminal it opens at once and shows each step as it loads: ```text plan-loading ☾ >_ awaywork You're away, your agents work ⠸ Planning the next run ✔ github 5 items ✔ tracker 2 items ✔ linking PRs 2/2 ⠸ claude quota q quit ``` What the screen shows once it has loaded, and `plan --why`, `--skipped` and `--rules`, are in [Plan, run, report](./running.md#preview-the-next-run). ## Run one unit ```sh awaywork run --dry-run # clone, context and prompt, no agent awaywork run --unit api#1 # one real unit awaywork report # browse the last run; piped, its morning report ``` `run`'s options, `--detach` and the run directory are in [Plan, run, report](./running.md#run-one-unit). ## On a schedule ```sh awaywork schedule install # asks where; this machine is launchd, systemd or cron ``` The job keeps `awaywork watch` running, which starts a run when a window opens and stops it when the window ends (01:00 to 07:00 by default). For GitHub Actions instead, pick it when asked or pass `--with github-actions`: see [Scheduling](./scheduling/index.md). # How it works A run collects items from your sources, gives each the skill of the first queue rule it matches, groups them into one unit per feature, and hands each unit to a worker while the quota allows. awaywork then publishes what the worker left and writes the morning report. ```mermaid flowchart TD subgraph before["awaywork, before"] direction LR collect["collect: items from your sources"] match["match: queue rules pick a skill"] dedupe["dedupe: one unit per feature"] budget["budget and route: quota probes and caps"] end subgraph workers["workers: every agent run"] direction TB worker["one per unit: the agent CLI in a fresh clone, no forge token"] end subgraph after["awaywork, after"] direction LR publish["publish: push a branch, open the PR, post on your item"] report["report: the morning report"] end collect --> match --> dedupe --> budget before -- dispatch --> workers workers -- "commits and .away/out/" --> after publish --> report ``` awaywork itself is plain code, and `awaywork plan` shows what it would do before a run. Only workers call a model: each step, and the optional [gate judges](./workflows/gates.md#judges) and [`sync`](./config/chains.md#conflicts) conflict resolution, all run in the unit's clone. The one exception is the quota probe in `plan`, `run` and `doctor`: one Haiku turn for Claude; Codex reads its quota without one. ## Items An item is one open thing of yours that a source returns: a PR, an issue, a task. Rules match on what every item has, like `type`, `title` and `state`, and on the source's own fields, like `checks` on a GitHub PR or `section` on an Asana task: see [Fields](./config/where.md#fields). ## The queue The queue is an ordered list of rules. Each item takes the **first** rule it matches, and one that matches none is skipped. A rule names the `skill` that does the work: ```yaml queue: - skill: fix where: { type: pr, mine: true, checks: failing } - skill: implement where: { type: issue, label: awaywork } ``` GitHub reads only issues assigned to you, so the second rule takes a labelled issue once it's assigned to you too. With no `queue`, nothing runs: `plan` says so and `doctor` warns. `awaywork init` writes the [default queue](./config/queue-rules.md#the-default-queue) for the sources you configured; [Queue rules](./config/queue-rules.md) has the rest. ## Units Items about the same feature become one **unit**, so a task and the PR that fixes it get one worker, not two. Items join when they share a key or [link](./config/repos.md#linking-prs) the same PR. The unit works in that PR's repo, else the item's `repo`, and ranks at the best rule any of its items matched; with no repo it's skipped. Units run one at a time, in queue order, within optional [caps](./config/providers.md#windows-limits-and-budget) per run, repo and rule. Each runs its rule's skill, then any [`then`](./config/chains.md#then) steps, in one clone, and publishes once at the end: one PR, one push or one report. A unit's PR starts from the default branch, or from your PR's branch when it works on your PR, so each can be merged, parked or closed on its own. It stacks only on an awaywork PR a rule took up, or on the PR of the [task](./workflows/tasks.md#the-feature-block) it waits on. ### Skipped units Most skipped units are waiting for you, since awaywork doesn't add to work you haven't read. `awaywork plan` says why: - **Waiting for review:** it links an open awaywork PR. - **Waiting for your answer:** to awaywork's [questions](./workflows/questions.md#questions) on the item. - **Waiting for your approval:** of awaywork's [plan](./workflows/plans.md), or of a report that [waits](./workflows/posts.md#posts-that-wait). - **Unchanged since run ``:** nothing changed since a worker last finished this skill on it. - **Gave up after 3 attempts:** it tries again a week later. - **Skipped by you:** with `awaywork skip`. `run --unit` runs one anyway. The exact rules are in [Why a unit waits](./running.md#why-a-unit-waits). ## Budget Each provider gets a ceiling for the run on its long window: the week for Claude, the week or month for Codex. ```py ceiling = min(used + per_run, 1 - reserve, (1 - reserve) * elapsed / length) ``` - `used + per_run` caps a single run, when `per_run` is set. - `1 - reserve` keeps a share back; the default reserve is 10%. - The last term is pacing: an even spend from 0 at the window's start to `1 - reserve` at its reset. Day 3 of 7 with a 10% reserve gives 0.9 × 3/7 = 38.6%. What a night leaves unused carries over to the next run. A worker stops once its provider is past the ceiling, or past the session limit on the short (5 h) window. That window counts your own sessions too, so the limit depends on whether you're around: - A run inside a window, while you're away: `session_max` (default 90%). - A manual `awaywork run` by day: `present_session_max` (default 40%). Claude reports quota during the run, so it stops mid-unit; Codex is read after each unit, so it stops between units. A provider whose quota can't be read, like Cursor, gets a fixed `fallback` of units and turns per run. `plan` says which limit a run started now gets; the keys are in [Providers and budget](./config/providers.md#windows-limits-and-budget). **Routing:** a unit goes to the first provider under its ceiling, in the rule's `provider` list, else the global `providers` order. When one fills up, the next unit goes to the next. ## The worker Each unit runs in a fresh clone, on a new branch cut from its base. `setup` runs once, before any agent; then every step works in that clone and branch. The worker gets: - the item, prefetched into `.away/context/`: the PR's diff, unresolved threads and checks (a failed one with its log), or the issue or task with its comments; - a prompt: the fixed [harness](https://github.com/AndreaProvenzale/awaywork/blob/main/harness.md), the skill, then your instructions (global, per repo, in-repo `.awaywork/instructions.md` unless someone else opened the PR, per rule); - the skills, where its agent looks: `.agents/skills/` for Codex, a plugin for Claude Code and Cursor. It gets no forge token, and no source's or other provider's secret ([Safety](./safety/index.md#what-the-agent-can-reach)). It stops at a turn cap (200, or `fallback.turns` without a quota reading), at `limits.unit_minutes` when set, and when the [watchdog](./safety/index.md#resources) finds the machine short of memory or disk. ## Delivery A rule's `deliver` says what its unit leaves. The opt-in ones are never a skill's default: only a rule picks them. | `deliver` | Leaves | | --- | --- | | `draft_pr` | A draft PR. The default | | `pr` | A PR ready for review. Opt-in | | `report` | A report in the morning report; nothing pushed | | `pending_review` | Your pending review on the PR it worked on. Opt-in, GitHub only | | `plan` | A plan posted on your item, waiting for your approval. Opt-in | | `push` | Commits fast-forwarded onto your own PR. Opt-in | The worker never pushes or posts. It commits, and writes what it wants published to `.away/out/`: `pr.md` for a PR, `report.md` for a report. awaywork does the rest. A skill commits unless it reports, as `review` and `plan` do, and a chain that committed nothing reports. What each value does, and when a `pr` stays a draft, is in [Each `deliver`](./results.md#each-deliver). # `/away` in Claude Code The awaywork plugin adds the `/away` skill to Claude Code, which sets awaywork up and writes your queue, and a pane beside the chat: what needs you, what ran, your config. ## Install ```text /plugin marketplace add AndreaProvenzale/awaywork /plugin install awaywork@awaywork /away ``` - On Claude Code 2.1.275 or later, `/plugin install awaywork --marketplace AndreaProvenzale/awaywork` does the first two. - Third-party marketplaces don't auto-update: run `/plugin marketplace update awaywork`, or turn auto-update on under `/plugin` > Marketplaces. - From your shell, `awaywork init` does it all: see [Getting started](./getting-started.md#configure). ## Set up With no config, `/away` sets awaywork up with you, and writes the config only on your yes. It puts it where `awaywork config path` says; with [`AWAYWORK_REMOTE`](./scheduling/laptop.md) set that's the server's, so there's nothing to set up. First it looks around, without asking: `awaywork doctor --json` (agent CLIs, `gh` login), your gh accounts and their orgs, the login in each Claude Code folder, whether a job is already scheduled here, and the folders your Claude Code and Codex sessions used in the last 30 days, mapped to repos by git remote (metadata and config only). ### The rounds Then come rounds of up to four multiple-choice questions, its pick first, or type your own answer. The first round, renamed: ```text ← ☐ Work ☐ Claude login ☐ Runs on ✔ Submit → Whose GitHub work should this awaywork config take care of? ❯ 1. octocat (Recommended) Reaches octocat, widgets; ~215 sessions in these repos, mostly api (124) and site (41). 2. octocat-acme Reaches acme; ~125 sessions across acme/app, acme/backend, acme/web and others. [...] ``` One config covers one account's work; another account gets its own setup later. The round also asks which Claude login runs it and where: this machine, GitHub Actions or a box with Docker. The next rounds: | Round | Asks | | --- | --- | | Repos | which, from your sessions; an ssh alias (`git@github-work:widgets/art`) waits for you to name its repo, Bitbucket Cloud is left out | | Tracker | which one, an MCP server for it being a hint; Jira also needs your Cloud site, email and token kind | | Window | when it runs, 01:00–07:00 your time by default | | Providers | which goes first, if several are ready; the next takes over when its quota runs out | | Your PRs | fix red checks or review threads, as draft PRs stacked on yours; keeping them up to date with main is a widening | | Review requests | a report only you read, or your pending GitHub review (a widening) | | Tracker cards | fix QA notes, implement in-progress cards, plan the sprint, and how plans wait for you | | Proof | how a change is proven in each repo, from its own scripts; writes [your proving skill](./workflows/proving.md) on an answer | | Learn | whether to [suggest](./suggest.md) more rules from your sessions | A widening, anything that lets awaywork write more than a draft PR or a report only you read, is always its own option. Without a question tool, each round is one numbered message, answered in your own words. ### Tokens Tokens never go through the chat. You run `awaywork secret LINEAR_API_KEY` (in Claude Code, `! pbpaste | awaywork secret LINEAR_API_KEY` after copying it), which saves it to `~/.config/awaywork/env`. The skill then reads your teams and statuses and asks only which repo each team's code is in. ### The draft Once repos and a window are picked, `awaywork init --yes` writes `config.draft.yml` beside the config, and the skill fills it in as you watch on the [Config tab](#the-config-tab): repos, tracker, the rules you picked with real status names, and the gh account and Claude login where a scheduled job wouldn't find them ([Which login](./config/providers.md#which-login)). It shows what each rule takes now and the closest misses, since taking nothing today doesn't make a rule wrong. On your yes the draft becomes the config and `doctor` runs once: ```text Rules (none takes anything today) 1. fix: your PRs with failing checks. 2. fix: your PRs with unresolved threads. 3. fix: your awaywork PRs with threads you started. 4. review: PRs where your review is requested, trusted authors. 5. implement: Linear NIG issues in In Progress. 6. plan: Linear NIG issues in Todo. Commands for you to run (I never run these): [...] ``` A default rule that doesn't fit, like `QA` on a team with no `QA` status, is left out with the reason. ## The pane With a config, bare `/away` opens the pane, read from `awaywork report --json`: ![The /away pane beside the chat in Claude Code, on its Needs you tab: cards for two draft PRs to review, with the first card's reason and link below](/demo/away-pane.png) It opens on the first tab with something in it. Next run stays empty until you press `p`: `awaywork plan` probes your quota (one Haiku call) and reads your forge. ### Cards Each tab is a strip of cards under their repo or tracker project, with the card's buttons below and its detail under them: title, reason, link. - A Needs you card shows what it asks: `◉` review, `?` answer, `◆` approve, `⚑` stuck, `✓` close a finished card. A repo's cards sort by it. - A Last run card starts with how it ended (`✓`, `✗`, `■` stopped), the skill on the right. - Elsewhere a card says why the item waits (`no rule`, `over cap`, `skipped by you`, ...). ### Keys | Key | Does | | ------- | ---------------------------------------------------------------- | | `1`–`5` | switch tab | | `r` | reread the last run; on Config, what each rule takes | | `p` | plan the next run | | `h` `l` | previous, next card | | `o` | open the PR, issue or tracker item | | `s` | skip the item in the coming runs, or unskip it | | `a` | approve the plan or result it waits on, replying `approve` there | | `g` | run it now: `awaywork run --unit --detach` | | `v` | view its report: `awaywork report --unit ` | | `c` | ask Claude: put the card in the prompt box | | `y` `n` | answer what `s`, `a` or `g` asked | | `b` | back, from a report or the skill list | A card shows only its own buttons. - **`s`, `a` and `g` ask first,** saying what they'll do and how to undo it: `skip #101 in the coming runs? awaywork unskip undoes it`. Cards are what the last run or `p` saw; the next `p` shows the change. - **`g` is the only way the pane starts a run,** on Next run's cards and on items only the schedule holds back (`unchanged`, `skipped by you`, `over cap`, `gave up`). On a `no rule` card it lists [skills](./skills.md#run-one-on-an-item-no-rule-takes) first: `g` again takes the first, a digit another. - **`c` sends nothing:** it types `About awaywork item github:acme/shop#7 "Retry webhooks on 5xx" (waiting for review): ` and leaves the rest to you. The pane has the keyboard when it opens. Click a card and the strip takes every key (arrows, Tab, letters) until Esc hands it back to the prompt; that's how the desktop app gets keys at all. In VS Code, click a card's ref to pick it. ### The Config tab Tab 5 is your config as it reads to a human: the draft while there is one, else the file. With no config yet or a draft waiting, the pane opens here. ![The /away pane on its Config tab: four rules, each with what it takes and what it does, and the selected review rule listing the items it takes now and those no rule takes](/demo/away-config.png) One row per rule: what it takes, how many items now (`· 1`, from `plan --rules`, no quota), and what it does. The selected rule lists those items, the ones an earlier rule takes first, and those no rule takes. `◆` marks a rule that can stop for you: a plan that waits, `wait: true`, or a gate. | Key | Does | | ----------- | ---------------------------------------------- | | ↑ ↓ | move between rules; `j` `k` in the desktop app | | `f` `y` `d` | the flow, the YAML, or the last write's diff | | `c` | ask Claude about the rule | | `o` | open the rule at its line in VS Code | The plugin watches the config's folder, so the chat's writes show at once. A draft that doesn't validate keeps the last good drawing, its problems on top. A config behind `AWAYWORK_REMOTE` isn't on this machine, so it gets no tab. ## The skill `/away` with anything after it goes to the skill (`/awaywork:away` too, when another command has the name). Ask in your own words: `/away fix my PRs when CI goes red`, `/away why didn't acme/api#12 run last night?`. It: - reads your config and real names from `awaywork sources --json`, so a section is spelled as Asana spells it; - drafts rules on a copy and checks them with `awaywork plan --rules --json`: what each takes, what another rule takes first, and near misses; - on your yes writes them, keeping your comments and layout, and runs `awaywork doctor --json`; - starts a tweak from the item (`awaywork plan --why `) and shows before and after; - writes your repo's [proving skill](./workflows/proving.md) with you: `/away show me it works before I review`. It never sees a token, starts a run or installs a schedule (it gives you the commands), or skips, unskips and approves without asking. Nothing reaches your config without your yes, and a widening you didn't ask for (`deliver: push`, `deliver: pr`, `on_done`, dropping `trusted`) gets its own question. Not sure what to hand over? `/away what could you take off my plate?` reads your recent sessions and PRs, with your say-so each time, and proposes rules, routines and `AGENTS.md` lines for work you do by reflex: [Suggested rules](./suggest.md). # With an AI agent Let your coding agent write your `where` clauses, field names and your tracker's spelling of `In Progress` included: the `away` skill asks what you want offloaded, tries its rules on your real PRs, issues and tasks, shows what each would take tonight, and writes only what you say yes to. In Claude Code the plugin adds a pane beside the chat too: see [`/away` in Claude Code](./away.md). ## Codex and friends The same skill ships in the npm package, in `plugin/skills/away/`, in the [Agent Skills](https://agentskills.io) format. Codex reads the Claude Code marketplace too: ```sh codex plugin marketplace add AndreaProvenzale/awaywork codex plugin add awaywork@awaywork ``` It shows up in Codex's skill list as `awaywork:away`; ask Codex to use it by name. Its longer routes, `setup.md`, `queue.md` and `suggest.md`, sit beside its `SKILL.md` and are read only when a route needs one. Any agent that loads skills from a folder can take it from the installed package, e.g. `~/.agents/skills`, which Codex reads as well: ```sh mkdir -p ~/.agents/skills ln -s "$(npm root -g)/awaywork/plugin/skills/away" ~/.agents/skills/away ``` No skills support? Tell the agent to read `away/SKILL.md` and follow it. ## Prompt to paste For a setup from scratch, before there's a config: ```text Set up awaywork (https://andreaprovenzale.github.io/awaywork/llms.txt) for me. Read the docs first. Find which of claude, codex and agent (Cursor) I have installed and logged in, and which GitHub repos I work on (gh). Ask me which repos and which Asana workspace to include and how much quota a run may use, then write the config, validate it and show me the plan. If a source needs a token, tell me to run awaywork secret instead of asking me for it. Don't start a run or install a schedule until I say so. ``` ## For agents Machine-readable docs: | URL | What | | --- | --- | | [`llms.txt`](https://andreaprovenzale.github.io/awaywork/llms.txt) | Index of every page, as Markdown links | | [`llms-full.txt`](https://andreaprovenzale.github.io/awaywork/llms-full.txt) | All pages in one file | | `.md` | Any page as Markdown, e.g. [`guide/configuration.md`](https://andreaprovenzale.github.io/awaywork/guide/configuration.md) | | [`config.schema.json`](https://andreaprovenzale.github.io/awaywork/config.schema.json) | The config's JSON Schema; `awaywork config schema` prints the installed version's | | [`away`](https://github.com/AndreaProvenzale/awaywork/blob/main/plugin/skills/away/SKILL.md) | The `/away` skill: status, why an item did or didn't run, and which of the files below handles the rest | | [`setup.md`](https://github.com/AndreaProvenzale/awaywork/blob/main/plugin/skills/away/setup.md) | A first config: whose work and which logins, where it runs, repos from recent sessions, trackers, a checked first queue | | [`queue.md`](https://github.com/AndreaProvenzale/awaywork/blob/main/plugin/skills/away/queue.md) | Writing and checking rules on real items | | [`suggest.md`](https://github.com/AndreaProvenzale/awaywork/blob/main/plugin/skills/away/suggest.md) | Candidate rules from the user's own sessions and PRs, asked for every run | ### Setting up 1. `npm install -g awaywork`; check `claude --version`, `codex --version`, `agent --version` (Cursor, which also needs `CURSOR_API_KEY` in the env file), `gh auth status`. 2. `awaywork init --yes` writes `~/.config/awaywork/config.yml` without a terminal: the installed providers, `github` when `gh` is logged in (repos `/*`), its default rules for those sources and a 01:00-07:00 window. It won't replace a config without `--force`. - `--provider`, `--source` and `--window` override what it detects ([`init`](../reference/cli.md#awaywork-init)). - Asana is skipped with a note unless `ASANA_TOKEN` is set; then it takes the first workspace and every project with the user's tasks. Linear likewise needs `LINEAR_API_KEY`. Jira is skipped: it needs a site and an email. - Cursor counts as installed only with `CURSOR_API_KEY` set; `--provider cursor` takes it anyway. Where it's the only agent, `--yes` without either stops, naming the missing key. - To go further, edit the file from [Configuration](./config/index.md) and the [reference](../reference/config.md). `awaywork sources asana --json` gives the workspace and project gids and the real section names for `where`; `sources linear`, `sources jira` (once the config has its `site` and `email`) and `sources github` do the same, never showing the token. 3. Secrets go in `~/.config/awaywork/env` (`NAME=value` lines, mode 600), never the config. The user puts them there with `awaywork secret ASANA_TOKEN` (a hidden prompt; in Claude Code, `! pbpaste | awaywork secret ASANA_TOKEN` after copying it). Don't ask for them in the chat, and don't read that file or other tools' credentials. 4. `awaywork doctor` until the config's line isn't `FAIL`. Its `gh login` and ` login` lines name the account each run uses; with several on a machine, the config pins them ([Which login](./config/providers.md#which-login)). Without a config it still lists each agent CLI: `ok` with its version, `warn` when not installed or, for Cursor, `CURSOR_API_KEY` unset. 5. For the queue, follow the [skill](#codex-and-friends). Run `awaywork plan` and show the user. With stdin and stdout both a terminal, `plan` and `report` open an interactive view; piped (`| cat`) or with `CI` set, they print text. ### What each command does | Command | Effect | | --- | --- | | `config path`, `config schema`, `skills list`, `skills list --json`, `report`, `schedule status` | Read-only, local | | `plan`, `doctor` | Read-only on sources and forge; probe each provider's quota (one tiny model call for Claude) | | `plan --rules`, `plan --rules --json` | Read-only on sources and forge; probe no provider | | `sources [id]` | Read-only on sources; prints no token | | `skills eject ` | Copies a skill into the user's skills dir | | `run --dry-run` | Clones repos and writes workspaces; no agent, nothing pushed | | `run`, `run --unit `, `run --unit --detach`, `run --unit --skill ` | Runs agents on the user's quota; pushes branches and opens draft PRs. `--detach` returns once the unit started; `--skill` runs a skill on an item no rule takes. Ask first | | `schedule install` | Installs a scheduled job, or writes a workflow with `--with github-actions`, or a container's files with `--with docker`. Ask first | | `init --yes` | Writes the config from detected defaults; `--force` replaces one | | `secret ` | Writes the env file from a hidden prompt or a pipe; prints no value. The user runs it, not you | | `init` | Interactive; needs a terminal. It opens Claude Code on `/away` | `awaywork --help` and the [CLI reference](../reference/cli.md) list every option. ### `plan --json` `awaywork plan --json` prints one object: `queue`, `allowances` (per provider), `units`, `skipped` and `warnings`. Items leave out their raw source payload. A skipped entry is `{ item, kind, reason }`, plus `url` when it waits on an awaywork PR for review (the PR to open, maybe not the item) or on an answer or approval (the item awaywork asked or posted its plan on). The kinds the user can act on have `needsYou: true`: | `kind` | `needsYou` | What it means | | --- | --- | --- | | `waiting_for_review` | yes | review the awaywork PR | | `waiting_for_answer` | yes | answer awaywork's questions on the item | | `waiting_for_approval` | yes | approve the plan or result awaywork posted | | `invalid_plan` | yes | fix the plan | | `store_failed` | yes | fix the store that couldn't read a post's files back | | `all_tasks_done` | yes | close or move the card whose tasks are all done | | `gave_up` | yes | read the failed logs | | `no_repo` | yes | give the item a repo | | `invalid_branch`, `refused_delivery` | yes | fix the rule | | `draft_moved`, `continue_cap` | yes | finish, mark ready or close awaywork's draft that moved or said `continue` 3 times | | `no_rule`, `over_cap`, `unchanged`, `skipped_by_you` | no | the config doing its job | | `unlisted_repo` | no | the item's repo is not in `repos` | | `approved` | no | a report rule is done with the item: you approved its result, or it went ahead | | `fanned_out` | no | a card whose plan became tasks | | `waiting_for_task` | no | a task waiting for another's PR to merge, or to open, for a check on it to pass, or for your approval of another task's result | | `task_merged`, `task_done` | no | a task whose PR merged, or that reports and whose `done` result is on its card | | `continues_later` | no | a `continue` a soon run leaves to the next window run | | `running` | no | another of the daemon's runs is working on it | Plans and tasks: - A card's `taskPrs` holds its tasks' PRs by task id, with `checks`: each check a task's `after` names, as `{ state, url }`: `state` is `passed`, `pending` or `failing`, `url` the check's link when the forge gives one (untrusted); missing when not reported. - A task's `item.task` has its card as `parent`, its `id` and `skill`, `plan` (the plan's tasks, each with `after` (`opened`, `merged` or a check's name), `repo` and `ships`), `contract` when there is one, and `onto`, the task whose PR it stacks on. Its `item.fields.repo` is its own repo. - A unit stacked on a PR another unit of the run opens has `after`, that unit's id; one waiting for tasks in other repos to open their PR earlier in the run has `waitsFor`, their task ids. - A unit that goes on from an earlier run has `continued`, its `continue` count so far. On awaywork's own draft it also has `resume`: `url`, `number`, `status` (`continue` or `ask`), `head` (where awaywork left it) and `from` (the commit the draft's commits start after). ### The other `--json` outputs - `skills list --json`: `skills`, each `{ name, deliver, pickable }`: the delivery it defaults to, and whether `run --unit --skill` can run it on one item. - `plan --rules --json`: the queue against the items the sources return now, reading no quota, attempts or caps (so `plan --why ` says what a run would really do). `rules` gives each rule's `rule` number, `skill`, `summary`, the items it takes (`taken`, with `plan: true` on a card its `deliver: plan` rule keeps though its `where` no longer matches) and those another rule takes (`takenBy`, by that rule's number); then `unmatched` (each with a `reason` naming the closest rule and the conditions it failed) and `warnings`. Items are references: `source`, `key`, `title`, `url`. On a repo with one review rule: ```json { "rules": [{ "rule": 1, "skill": "review", "summary": "type=pr review_requested=true trusted=true", "taken": [], "takenBy": [] }], "unmatched": [ { "source": "github", "key": "acme/api#134", "title": "Review smoke: fix the typo in README", "url": "https://github.com/acme/api/issues/134", "reason": "matched no rule; closest #1 review: type is issue, wants pr; review_requested is unset, wants true" } ], "warnings": [] } ``` - `sources [id] --json`: one object keyed by source id, of what each one's credentials can see. `github` has `login` and `orgs`; `asana` `workspaces`, each with `gid`, `name`, `projects` (`gid`, `name`) and `sections`; `linear` `teams`, each with `key`, `name` and `states`; `jira` `projects` (`key`, `name`) and `statuses`. A source with nothing to list (`bitbucket`, a custom one) is left out without an id, an error with one. A source that can't tell, say with its token unset, goes to stderr as `: ` (naming the variable and the env file) and makes it exit 1; without an id, the others still print. - `doctor --json`: an array of `{ status, what, detail }`, `status` being `ok`, `warn` or `FAIL`; it exits 1 on any `FAIL`, as without `--json`. - `report --json`: the run's `runId`, `dryRun`, `startedAt`, `finishedAt`, `plan`, `results`, `quotas` and `warnings`, from its `report.json`. - With no id it's the last run that has one (`report --unit` and `--skipped` pick the same), so a run still going, or one that died, doesn't take its place. With none, or an id of a run without one, it exits 1. - Items are references (`source`, `key`, `title`, `url`), plus `type` and, when it's yours, `mine` on a skipped one. Skipped entries have `kind` and `needsYou` as in `plan --json`, except in runs from before skips had kinds. - Each started step has `files`, relative to the run dir: `log`, `report` and `diff` (its own commits) from its folder. A slice's first step, which publishes it, also has `sliceBody` and `sliceDiff` from `out/` (`out-2/` for a stacked slice and so on): the PR body or push summary (the joined report when the slice published reports) and the commits it published, or would have if the publish failed. - A key is there only when its file was; a run [from the runner](./scheduling/actions.md#reports-from-the-runner) has only `report.md`, `report.json` and `units.json`, so check a path exists before opening it. - `summary` is the first paragraph of `sliceBody`, else of `report`; the PR it opened or pushed onto is `outcome.url`. A `fix` that found nothing to commit, trimmed: ```json { "status": "done", "outcome": { "kind": "none", "reason": "nothing" }, "summary": "No code changes: both red checks are unrelated to this diff.", "files": { "log": "units/acme-api-5/1-fix/worker.log", "sliceBody": "units/acme-api-5/out/pr.md" } } ``` # Suggested rules CI goes red, you paste the failure into your agent. A review lands, you ask it to address the comments. Monday, you ask it to go through last week's errors. You do each so often you'd never think to write a rule for it. `/away` notices for you, when you ask. It reads your recent agent sessions and forge history, finds the work you keep doing after the same kind of event or at the same time each week, and proposes queue rules, [routines](./config/sources.md#routines) and fixes for corrections you keep typing, each shown against the items it would take tonight. ## Run it In Claude Code, with the [plugin](./away.md): ```text /away what could you take off my plate? ``` With no config, `/away` sets awaywork up and asks whether your first rules should come from this, with the scope and size it would read; say yes and it doesn't ask again. On a config with no queue, or only `init`'s default rules, it offers once before asking what to offload. It never needs a token: you add those with `awaywork secret ` ([/away in Claude Code](./away.md)). Codex and friends get the same skill from the npm package, in `plugin/skills/away/` ([Codex and friends](./agents.md#codex-and-friends)). ## It asks first Every run starts with three questions and a number, before it opens a transcript: - **Projects:** your config's repos by default. It reads only the folders you name, so work sessions never feed a side project's queue, or the reverse. - **Window:** the last 30 days by default. Under 3 weeks it finds no routine, since a weekly habit needs 3 weeks, and says so. - **Histories:** which agents' sessions and memories. Reading another vendor's sessions sends them to your agent's vendor, and it says so: Codex sessions read by Claude go to Anthropic. - **Size:** how many sessions, MB and memory files that is. Nothing turns it on for good: next time it asks again. ## What it reads | Input | Where | | --- | --- | | Claude Code sessions | `~/.claude/projects//*.jsonl`, and `$CLAUDE_CONFIG_DIR/projects/…` when set: both can hold sessions, counted once each | | Codex sessions | `~/.codex/sessions///
/*.jsonl`, or `$CODEX_HOME/sessions/…` when set | | Agent memories | Claude Code's `~/.claude/projects//memory/*.md` (and under `$CLAUDE_CONFIG_DIR`), your global `CLAUDE.md` and `rules/`; Codex's `~/.codex/AGENTS.md` and, with its memories feature on, probably `~/.codex/memories/` (named in the binary, checked on a real file first). A global file lends only lines that clearly apply to the repo | | Forge history | `gh`, read-only: your PRs (who reviewed them, when), issues, failed CI runs, reviews you gave. Not your replies on others' threads: awaywork has nothing to do there that isn't commenting on someone's behalf | It skips awaywork's workers and other headless sessions, subagent transcripts (they repeat their parent), Claude Code sessions Codex Desktop imported, and earlier suggest runs, or the counts would lie. Times become local before it looks for weekly habits, as a routine's `at` is local. To spare tokens it first digests each session with a `jq` script shipped with the skill: opening prompt, branch, folder, start time, the PR, and language-independent correction signals (an interrupted turn, a rejected tool call, you speaking up right after an edit). It joins sessions to forge events (a session half an hour after CI failed on that branch), clusters similar asks, and reads full sessions only for clusters worth it, with subagents where available. It runs in your session, on your subscription, while you watch. ## What it turns into | It found | It proposes | | --- | --- | | An event on your item, then the same ask | A queue rule: a `where` and a skill | | The same ask at the same time each week | A routine and a rule on `source: routines`, with its cost in units a week | | A correction every agent on the repo needs | A line in the repo's `AGENTS.md` (or `CLAUDE.md`), which helps your own sessions too | | A correction only awaywork's workers need | A line in your awaywork `instructions` | | An ask to show a change works, after an agent's edits | Your repo's own [proving skill](./workflows/proving.md) and its gates, written with you | | A correction already written down and still made | A command instead: a [`verify`](./config/repos.md#repos-and-instructions) like `pnpm lint`, or a command [gate](./workflows/gates.md) | A saved memory can be a correction you already wrote down, which awaywork's Claude Code workers never see: they start with auto-memory off and none of your user-level config (Codex workers do read `~/.codex/AGENTS.md` under `isolation: none`). It reads the memory index, types and descriptions first, and opens only your feedback and notes stating a rule about the repo, not progress logs. One that holds for the repo becomes an `AGENTS.md` or `instructions` line; one about you doesn't. With a rule that implements and no proving skill on it, it proposes one whether or not it saw the pattern, and asks how a change is proven in that repo before writing a line: it names no tool. Anything your config or `AGENTS.md` already has shows as "already rule #1". A pattern with no trigger awaywork can see ("when someone asks in Slack") is dropped. Then it drafts the rules and routines into a copy of your config and runs `awaywork plan --rules --json` and `doctor --json` on it, so each comes with the items it takes now, the near misses, and a routine's next occurrence. ## What you see On one developer's repo, a month of history, a config with the 4 rules `init` wrote: 86 interactive Claude Code sessions (of 163 files, the rest headless), 143 PRs, 76 issues and 20 failed runs, ending with, trimmed: ```text Rules 1 fix my red PRs seen 5× forge (+1 session) where: { type: pr, mine: true, checks: failing } (already rule #1) takes now: nothing 2 review my own green PRs before I merge them seen 12× sessions where: { type: pr, mine: true, ours: false, checks: passing } skill: review deliver: report → rule #5 in the draft. Merging stays with you; awaywork never merges. takes now: nothing. #355 and #356 match, but rule #2 (fix unresolved threads) takes them first. 3 plan my issues before I pick them up seen 8× sessions where: { type: issue, label: { not: awaywork } } skill: plan deliver: report → rule #6 in the draft takes now: #216, #167, #155, #154, #131, #119, #64 (7 units, about 2 runs at the 4-unit fallback; an issue isn't planned again unless it changes) Routines None. No ask came back at the same weekday and hour in 3 or more weeks. Corrections None repeated. Only one session had interrupted turns or rejected tool calls (2 each). Keep which? (e.g. 2, 3) ``` Below it: 2 patterns dropped for having no visible trigger, the draft keeping the 4 rules `init` wrote as #1 to #4, `doctor` passing, neither rule widening delivery. Config, repo and `AGENTS.md` untouched. A month without a routine is an answer too. ## You pick Accept, tweak or decline each by number. Kept rules and routines go through the skill's queue loop: drafted, checked on your items again, written into your config on your yes, comments kept, then `doctor`. An `AGENTS.md` line is a local edit you commit. A rule that lets awaywork write more (`pending_review`, `pr`, `push`, `on_done`) gets its own yes, as when [you write rules with it](./away.md). Nothing else is written. It won't: - quote your sessions or memories: evidence is a count and a paraphrase, since sessions hold pasted secrets; - touch the forge beyond reading; - leave anything behind: digests go in a temp dir it deletes; - run in the background. The CLI never reads transcripts and workers never see your history: only the session you started does. ## Running it again There's no memory between runs, on purpose for now. What you accepted comes back as "already rule #3"; what you declined comes back as a fresh candidate. Nothing nudges you: ask again when habits change or a new repo shows up. # Plan, run, report `awaywork plan` shows what a run would do, `awaywork run` does it, and `awaywork report` reads back what it did. ## Preview the next run `awaywork plan` shows each source and provider as it loads ([Getting started](./getting-started.md#preview-the-next-run)), then becomes a view to browse: ```text plan-view ☾ >_ awaywork You're away, your agents work Providers present run (outside the windows): present_session_max provider window used ceiling set by resets in session / stop at claude seven_day 5% 25.7% pacing 5 runs 2% / 40% other limits: reserve cap 90% ▾ Needs you 1 ▾ tracker T 1 T-4 Export invoices as CSV add repo ▾ Units 3 item rule skill provider deliver title ▾ acme/api 2 1 #1 #1 fix (built-in) claude draft_pr Add rate limits to the public API + api#5: same feature, matched #3 3 #2 #2 review (built-in) claude report Cache user sessions ▾ acme/web 1 2 #3 #1 fix (built-in) claude draft_pr WIP: new billing page ▾ Skipped 2: 1 no_rule, 1 over_cap ▾ tracker T 1 T-7 Onboarding emails no rule closest #3 implement: type is task, wants issue ▸ acme/api 1 ⏎ unfold o open s skip a approve ? help q quit ``` - **Units** name the item (`api#1` is acme/api#1; a tracker key stays as is), the rule it matched, the skill, provider and delivery. - **Needs you** is the skipped items only you can unblock, each ending on what to do: `add repo` for a task with no repo. - **Skipped** is the config doing its job, counted by kind. - `⏎` pages why an item is planned or skipped, `o` opens it, `s` skips it in the coming runs, `?` lists the rest ([key table](#the-morning-report)). Piped or in CI it prints the same as text, the skipped items only counted, and ends on the commands that dig in: ```text plan-hints awaywork plan --skipped [kind] lists them awaywork plan --why says why an item is planned or not ``` `plan --skipped` lists the skipped items with their reason, Needs you first; `plan --skipped no_rule` lists one kind. `plan --why ` explains one item, with the queue when it matched no rule: ```text plan-why $ awaywork plan --why T-7 tracker T-7 Onboarding emails https://tracker.acme.dev/T-7 skipped: no_rule matched no rule; closest #3 implement: type is task, wants issue Queue #1 fix type=pr mine=true #2 review type=pr review_requested=true #3 implement type=issue #4 fix column=QA ``` A planned item shows its unit, rule, skill, provider and branch instead. With no `queue` in your config, `plan` says `Nothing will run: the config has no queue; /away or awaywork init writes one`. `awaywork plan --rules` shows what each rule takes now, without probing quota: ```text plan-rules Queue #1 fix source=github type=pr mine=true checks=failing 1 taken: acme/api#1 #2 fix source=github type=pr mine=true unresolved_threads!=0 0 taken [...] #4 review source=github type=pr review_requested=true trusted=true 1 taken: acme/api#2 1 unmatched: acme/api#3 ``` An item goes to the first rule it matches; a later rule that matches it too shows `1 taken by #1: acme/api#1`. To reorder, move the rules in the config. ## Why a unit waits `awaywork plan` says why a unit makes no work this run: - **Waiting for review:** it links an open awaywork PR. awaywork doesn't add to work you haven't read. - **Waiting for your answer:** the item's newest comment is awaywork's [questions](./workflows/questions.md#questions). - **Waiting for your approval:** of awaywork's [plan](./workflows/plans.md) on it, or of a report rule's result that [waits](./workflows/posts.md#posts-that-wait). - **Unchanged since run ``:** no item changed (`updated`, and for a [`sync`](./config/chains.md#sync) its base's head) since a worker last finished this skill on it, so a review isn't redone every run. A [`continue`](./workflows/questions.md#continue) is exempt. - **Gave up after 3 attempts; next try on ``:** its chain didn't finish 3 runs in a row. It retries a week later, changed or not, and again a week after each failed try, so an error that fires nightly isn't retried nightly. - **Skipped by you:** `awaywork skip ` (e.g. `github:acme/api#5`) holds an item, and any unit it joins, until `awaywork unskip` or the `--until` date. `plan --skipped skipped_by_you` lists it with the `--reason`. Skips live in `~/.local/state/awaywork/skips.json`. `run --unit` runs a unit anyway, unchanged, skipped or given up; a given-up one runs as its one try. Details: - An awaywork PR matches only rules whose `where` names `ours`, like the default `fix` on threads you left on it. That rule takes the unit even over a better-ranked linked item, and its draft stacks on the awaywork PR. - A GitHub issue's draft PR into the default branch says `Closes` it, which links them, so the issue waits with the draft. - awaywork's draft whose footer says it goes on there doesn't wait: the unit [continues](./workflows/questions.md#continue) on it. - A PR awaywork [pushed onto](./config/chains.md#push) waits while its head is awaywork's push, unless that was a [`sync`](./config/chains.md#sync)'s clean merge. - An attempt is one chain, finished when every step that ran was done and no publish left anything behind. A failure, an error, a `unit_minutes` timeout or unpushed commits don't finish it; a plan report then a failed implement is one failure. - A finished attempt clears the count; another skill starts a new one. Skipped, dry-run and aborted units don't count, nor do stops the item didn't or may not have caused (quota, session limit, window end, a watchdog stop for low memory or disk). Attempts live in `~/.local/state/awaywork/attempts.json`. ## Run one unit ```sh awaywork run --unit api#1 # one unit, as plan names it awaywork run --repo 'acme/*' # only units in matching repos awaywork run --rule 4 # only units of queue rule #4 awaywork report --list # recent runs, to pick an id for report awaywork report --unit 2 # one unit in full: its files, PR body or report ``` `--unit` takes the item as `plan` shows it (`api#1`), its key (`acme/api#1`, `github:acme/api#1`) or the unit id (`acme-api-1`), in any case. It holds your terminal until the unit ends, hours sometimes; `--detach` hands it back at once, the output going to the run's `run.log`: ```text run-detach $ awaywork run --unit api#1 --detach Started api#1 as run 20261109-095329; awaywork report 20261109-095329 shows it once it ends ``` To stop a detached unit, kill the pid in `~/.local/state/awaywork/run.lock`: it ends as Ctrl-C would, report included. - A unit you name goes past `limits.units`, `limits.units_per_repo` and its rule's `units`; the rest of the plan stays capped. - A step of a rule's `then` runs its whole chain, from the first step. - It plans first, so a unit that can't start exits 1 saying why: not in the plan, the run lock taken, or no provider. - An item no rule takes needs a skill: [`--skill`](./skills.md#run-one-on-an-item-no-rule-takes), detached too. - Outside your windows it's a [present run](./how-it-works.md#budget) with a lower session limit, and a provider whose quota awaywork can't read (a command provider without a `probe`) sits it out: run inside a window or add a probe. `--repo ` and `--rule ` (counting from 1, as `plan` shows) work on `plan` and `run`, and together both must hold. Caps and ceilings stay the full run's, but only matching units count: with `limits.units: 3`, `--repo acme/api` plans up to three acme/api units. The run directory, `~/.local/state/awaywork/runs//`, keeps the plan, the worker's log, its context files and the report. The id is the run's start time in UTC; old ones go after [`keep_runs`](./config/index.md#old-runs). ## The morning report `awaywork report` opens the last run from its `report.json`: each provider's long window before and after against the ceiling, a row per unit with its result and a line of what it concluded, then Needs you and the skipped items. Here `web#3` is selected and `d` just said why it did nothing: ```text report-view ☾ >_ awaywork You're away, your agents work Run 20261019-220012: 1 draft PR, 1 report, 3 units 2026-10-19 22:00:12 to 2026-10-19 23:47:40 away run (inside a window): session_max claude: seven_day 5% to 17%, ceiling 42.9%; session 2% to 31%, stop at 90% ▾ Needs you 1 ▾ tracker T 1 T-4 Export invoices as CSV add repo ▾ Units 3 item skill status result ▾ acme/api 2 1 #1 fix done, 14 turns https://github.com/acme/api/pull/14 Rate limits the public API with a token bucket per client key; bursts get a 429 instead of a… 3 #2 review done, 6 turns report, verdict approve Approve: the session cache keys on the user id and expires with the token. ▾ acme/web 1 2 #3 fix done, 9 turns nothing No code changes: the red check is the visual diff job, which fails on main too since #212. ▾ Skipped 2: 1 no_rule, 1 over_cap ▸ tracker T 1 ▸ acme/api 1 ⏎ read o open l log d diff s skip a approve ? help q quit diff: this unit left no diff ``` `plan` is the same view without `l` and `d`, since a plan has no logs or diffs. `report` opens at once too, a line per loading step, such as the run fetched from GitHub. - Items sit under their repo, or tracker and project key, so a row shows just `#3`. - A skipped item ends on what it asks of you (`review PR`, `answer question`, `approve`, `add repo`, ...) or why it waits (`no rule`, `over cap`, ...). Needs you sorts a repo's items by that, so PRs to review sit together. - The line under a skipped item is the rest of its reason, without the closest rule in Needs you; `⏎` has all of it. Rows run to the terminal's width, keeping that word on screen. - It opens on the first Needs you item, else the first unit, else the first skip, and leaves out empty sections; with nothing at all, it says why. - The footer dims keys with nothing behind them: `d` on a unit that committed nothing, `l` on a run [from the runner](./scheduling/actions.md#reports-from-the-runner), which keeps no logs. | Key | Does | | --- | --- | | `↑` `↓`, `j` `k` | move | | `g` `G`, Home End | first, last row | | PgDn PgUp, Ctrl-D Ctrl-U | a screen down, up | | `→` `←` Space | unfold, fold, as `⏎` on a heading; `←` on a row goes to its section | | `⏎` | page the unit in full (`report --unit`), or why an item is planned or skipped (`plan --why`), in `$PAGER`, else (or if it's `cat`) `less -R -c -+F -+X`, which repaints from the top and stays open on a short text | | `o` | open the unit's PR, else its item; a skip waiting for review opens the PR it waits on | | `l` | page the `worker.log` of the chain's last step | | `d` | page the diff: the commits the unit published, else the step's own | | `s` | `awaywork skip` the item, after a `y` | | `a` | `awaywork approve` the item, after a `y`; there's no undo | | `?` | the keys | | `q`, Esc, Ctrl-C | quit, also while it loads | Piped, in CI (`CI` set), or with `--json`, `--list`, `--unit` or `--skipped`, it prints text, so `awaywork report | cat` prints the view, then the quota and how to dig in: ```text report-piped $ awaywork report | cat Run 20261019-220012: 1 draft PR, 1 report, 3 units [...] Quota away run (inside a window): session_max claude: seven_day 5% to 17%, ceiling 42.9%; session 2% to 31%, stop at 90% awaywork report 20261019-220012 --unit shows one awaywork report 20261019-220012 --skipped [kind] lists the skipped items ``` `report --unit ` prints one unit in full, as `⏎` pages it: links, each step with its provider and what it left when there are several, the files still there (none on a run [from the runner](./scheduling/actions.md#reports-from-the-runner)), then its PR body or report. It takes the row number, the unit id (a step's gives its chain) or an item: ```text report-unit $ awaywork report --unit 2 2 web#3 WIP: new billing page skill fix status done, 9 turns result nothing item https://github.com/acme/web/pull/3 files ~/.local/state/awaywork/runs/20261019-220012/units/acme-web-3/1-fix/worker.log ~/.local/state/awaywork/runs/20261019-220012/units/acme-web-3/out/pr.md Fix the billing page build [...] ``` - An all-digit item key, like an Asana gid, wins over a row number. - A name that fits two units (`api#1` under two owners) is refused with each one's `source:key`, here and in `run --unit` and `plan --why`. - A long reason, like a failed setup's output, gets its first line in the row, all of it under `--unit`. - A unit with `then` steps is one row, its chain in the skill column. Also: - `report --skipped [kind]` lists the skipped items as `plan --skipped` does; `report --json` prints the run as [JSON](./agents.md). - `report --list` lists recent runs with their units, PRs and reports, and `report ` prints one; inside a control repo they include the runs GitHub Actions [uploaded](./scheduling/actions.md#reports-from-the-runner). - During a run there's no report yet: `report` lists the steps done, the one working and its `worker.log` path, to `tail -f`. - The run dir also keeps `report.md`, the run as markdown: units with every step under its chain and a summary line each, Needs you, the skips folded by kind, each report folded under its unit. GitHub Actions puts it in the job summary and [`notify`](./results.md#notify) gets it. A run from before `report.json` prints its `report.md`. ## Stats `awaywork stats` adds up every run that kept a `units.json`, per rule (by its place in that run's queue), skill and provider: units, how many were done, failed or stopped, the share of the long quota window their workers used, and PRs opened. `--json` prints the same. - The quota share is an estimate: it starts at the reading before the unit (the plan's probe, then that provider's previous unit), includes other use of the account meanwhile, and is `-` when the window reset mid-unit or the worker read no quota. - A chain's steps count one by one, and only the first carries the chain's PR, so later skills' rows show no PRs. - `units.json` is written when the run ends, so a killed run, or an older one, has none and is left out. # Where results go The model writes the content, the config picks the destination, and awaywork or your own [notify](#notify) hook does the write, never the model. The integrity rules in [Safety](./safety/publishes.md) never move. How far a result reaches is your call per rule, quiet by default. ## Reach and ownership Who sees the result, and whose thing it lands on: | Reach | Your PR, issue or task | Someone else's PR | | --- | --- | --- | | Only you | awaywork: `report` into the morning report, `pending_review` on your own PR (submittable only as a comment). Your hook: a note to yourself, like [ntfy](../examples/ntfy-report.md) | awaywork: `report`, `pending_review`. Your hook: the same note | | Visible, nobody asked to look | awaywork: `draft_pr` (default), `push` onto your PR's branch. Your hook: PR labels, a Notion page, a Slack message | awaywork: nothing, it stays private. Your hook: a Notion page, a Slack message | | Pings someone | awaywork: `pr`, a ready PR, which asks the code owners. Your hook: reviewers | awaywork: nothing; you submit the pending review. Your hook: a Slack @mention | The `deliver` values are in [Delivery](./how-it-works.md#delivery), set per rule in [Queue rules](./config/queue-rules.md). ## Each `deliver` - **`draft_pr`:** the worker commits and writes `.away/out/pr.md` (first line the title), which later steps keep current. awaywork adds an `Awaywork: ` trailer, pushes the new branch and opens one draft PR into that base. No `pr.md`, no publish. Never into someone else's PR or a fork's: there a chain that could commit is skipped with the reason; a report still works. - **`pr`:** a `draft_pr` that opens ready for review, pinging whoever a ready PR pings, code owners included. It stays a draft, saying why, when a step failed, stopped or never ran, it [asked questions](./workflows/questions.md#questions) or said [`continue`](./workflows/questions.md#continue), [`verify`](./config/repos.md#repos-and-instructions) failed, a [loop](./config/chains.md#loops) ended before its `until` held, or a [gate](./workflows/gates.md) fired. - **`report`:** no step commits; each writes `.away/out/report.md`, and the chain's reports, joined, go into the morning report. - **`pending_review`:** a `report` that also lands on the PR, anyone's or a fork's: the last report and the line comments in `.away/out/review.json` become your pending review at the commit the clone read, seen only by you until you submit it. If GitHub refuses a line comment (one off the diff, say), they all go in the body. When a step failed, stopped or never ran, or GitHub refuses the review (you have one pending there already, say), the report goes alone with the reason. - **`plan`:** a `report` whose step also writes `.away/out/plan.yml`: [Plans](./workflows/plans.md). - **`push`:** the commits and `pr.md`, pushed with the trailer onto your own open PR's branch as a fast-forward. The PR body isn't touched; `pr.md` goes into the morning report. No `pr.md`, no push. Under [`sync`](./config/chains.md#sync) the one commit is awaywork's merge of the PR's base; a worker runs only to resolve a [conflict](./config/chains.md#conflicts), without committing. It pushes only while ([`push`](./config/chains.md#push)): - the PR is yours, open, in the repo, without auto-merge; - its head is still where the clone started; - its branch isn't on a fork, the default branch, protected, or someone else's open PR's. A PR's body gets its items' [`link`](./config/repos.md#linking-prs) lines and a footer naming awaywork, the run and the unit's items, which is how the next plan links it back. A step's one-word `status` and `verdict` files are in [Skills](./skills.md#write-your-own). ## Built in - **Ready PRs:** `deliver: pr`, a draft still when the chain went wrong, a step asked or said `continue`, or a [gate](./workflows/gates.md) fired. A draft a later run [finishes](./workflows/questions.md#continue) turns ready. - **Decision cards:** a fired gate tops the PR body with a "Needs a decision" card, or a plan or report rule's post for [its own gates](./workflows/gates.md#gates-of-a-rule-s-own); with none fired, a `Gates: none fired (…)` line. On a `push` the body isn't awaywork's, so the card tops the report's summary and the push still happens. - **Review comments:** `pending_review` leaves the report and line comments as your pending review, unseen until you submit it. - **[Questions](./workflows/questions.md#questions):** posted as a comment on your own item, your reply the answer; commits made first open as a draft that says so. On an item not yours they stay in the report. - **[Plans](./workflows/plans.md):** `deliver: plan` posts `plan.yml`'s tasks under the report and contract on your card and waits: a reply starting `approve` approves, anything else replans; `wait: false` goes ahead unless a gate fires. - **[Posts that wait](./workflows/posts.md#posts-that-wait):** a `report` rule with `wait: true` posts on your card and waits like a plan, holding its `on_done` move; your `approve` hands the card to the rule whose `where` says `approved: `. - **[Lean posts](./workflows/store.md#files-that-come-back):** with a [store](./workflows/store.md), a plan or report posts `summary.md` and a link per file; the next unit gets the files back on any machine, and an edited file counts as feedback. - **[Task PRs](./workflows/tasks.md):** each approved task opens its own PR, per its `planned` rule's `deliver`, in the card's repo or [another](./workflows/tasks.md#across-repos) the config names, with `Part of ` instead of `Closes`. - **Task posts:** a task has no item, so it writes to the card: questions headed `Questions from task api`, a reporting task's report headed `Result of task qa`, and a `planned` rule's `on_done` move. - **[`on_done`](./config/queue-rules.md#on-done):** `move` sends your own items to a status, state, section or label once the chain published, never someone else's PR; `comment: result` first posts the result (a report, or a link to the PR) with a `Status: done` line. A report that said `continue` posts `Status: continue (1 of 3)` and runs again. ## Notify `notify.command` runs as you, with your environment, on two events in `AWAYWORK_EVENT`: - **`run`**, when a run ends, dry runs included: `AWAYWORK_REPORT` is the morning report's path, with `report.json` beside it, and `AWAYWORK_RUN_ID` the run. - **`unit`**, for every step of a unit once it published, before the next unit. None on a dry run; Ctrl-C stops the running one and fires no more. ```yaml notify: # a line per step command: >- [ "$AWAYWORK_EVENT" = unit ] || exit 0; echo "$AWAYWORK_UNIT $AWAYWORK_STATUS $AWAYWORK_PR_URL" >> ~/awaywork.log ``` The command gets both events, so filter as above. `secrets` names the env vars it reads: workers never see them, and Actions passes them from repository secrets. Both events have `AWAYWORK_RUN_ID`; a `unit` event also has: | Variable | Holds | | --- | --- | | `AWAYWORK_UNIT`, `AWAYWORK_SKILL`, `AWAYWORK_STATUS` | The step (`acme-api-12-review-1`), its skill, how it ended (`done`, `failed`, `skipped`...) | | `AWAYWORK_PR_URL` | On every step of a unit that opened a PR; never on a [`push`](./config/chains.md#push), so a hook can't touch your own PR | | `AWAYWORK_REPORT` | What the step wrote: its report, or the PR body | | `AWAYWORK_ITEM_URL`, `AWAYWORK_ITEM_KEY`, `AWAYWORK_SOURCE` | One line per item, in order | | `AWAYWORK_REPO` | `owner/name` | | `AWAYWORK_VERDICT`, `AWAYWORK_OUT_DIR` | The step's verdict, and its kept `.away/out/`, which also holds earlier steps' files: check `$AWAYWORK_SKILL` first | There are no built-in integrations. A failure or a run over a minute is logged (`notify failed for acme-api-1: Command failed with exit code 3: ...`) and the run still succeeds; anything left in the background is stopped. Recipes, ntfy, Slack and Notion among them, are in [Where results go](#examples). ### PR labels, reviewers and ready for review Ready for review is [`deliver: pr`](./config/queue-rules.md). Otherwise a PR gets the agent's title and body, no labels or reviewers, into the base awaywork picked. `gh` in the hook adds the rest: ```yaml notify: command: | set -e [ "$AWAYWORK_EVENT" = unit ] && [ -n "$AWAYWORK_PR_URL" ] || exit 0 gh pr edit "$AWAYWORK_PR_URL" --add-label awaywork # every step of the PR fires: ask octocat once, and never on a draft skip=$(gh pr view "$AWAYWORK_PR_URL" --json isDraft,reviewRequests -q '.isDraft or any(.reviewRequests[]; .login == "octocat")') [ "$skip" = false ] || exit 0 gh pr edit "$AWAYWORK_PR_URL" --add-reviewer octocat ``` - `set -e` stops at a failing `gh`; the next step tries again. - `--base develop` moves the base too. Leave it out on a plan's [stacked tasks](./workflows/tasks.md#the-feature-block) and on a rule that takes up an awaywork PR: their PRs sit on another PR's branch, and moving the base breaks the stack. - It's your token, not a worker's. A reviewer, and the CODEOWNERS a ready PR asks, get notified: treat that like a comment you'd post. ## Examples Each is a `notify` block. A config has one `notify.command`, so put several in a script and point `command` at its full path. The `unit` event fires for every step, so each recipe first picks its steps. `AWAYWORK_ITEM_URL`, `AWAYWORK_ITEM_KEY` and `AWAYWORK_SOURCE` hold one line per item; most units have one. - [The morning report on your phone](../examples/ntfy-report.md) - [A Notion page per plan](../examples/notion-plans.md) - [A Slack message when a review is ready](../examples/slack-reviews.md) - [Acting on a skill's own output](../examples/skill-output.md) ## What awaywork won't do - **Post to Notion or Slack itself:** every service has its own API and token, and your hook covers it in a few lines, with your token. - **Submit a review, approve, or comment on someone else's PR:** its author wrote what the model read, so can steer what it says. A pending review waits for you to read it. - **Let a skill pick where its result goes:** a skill writes into `.away/out/` and holds no write token. The rule's `deliver` and your hook decide. # Skills A skill tells the worker what to do with a unit. Every skill is an [Agent Skills](https://agentskills.io) folder with a `SKILL.md`, so one folder works for Claude Code, Codex and Cursor. ## Built-in skills | Skill | For | Delivers | | --- | --- | --- | | `fix` | Problems listed on an item: your PR's failing checks or unresolved threads, threads you started on an awaywork PR, a task back from QA, an error from a [custom source](../examples/sentry.md), or this run's review in a [loop](./config/chains.md#loops) | draft PR | | `implement` | An issue or task with no PR, or in progress with yours: the unambiguous next steps, or [questions](./workflows/questions.md#questions) when it's too vague | draft PR | | `review` | A PR awaiting your review, or this run's work as a `then` step. Posts nothing unless `deliver: pending_review` leaves it as your pending review. Verdict `approve`, `comments` or `changes`; line comments to `review.json` under `pending_review` | report | | `plan` | A task with no PR: a plan, no code, or [questions](./workflows/questions.md#questions) when no reading is likely enough; under `deliver: plan`, tasks in `plan.yml` ([Plans](./workflows/plans.md)) | report | | `judge` | A [gate's judge](./workflows/gates.md#judges) of commits or a plan's or report's files: verdict `fires` when a human must decide, one line per decision | report | | `sync` | Your PR behind its base: awaywork merges and pushes, a model only on `conflicts: true`; needs `deliver: push` ([`sync`](./config/chains.md#sync)) | push | ```sh awaywork skills list # every skill a rule can name, and where it comes from ``` The skill decides the work; the [harness](https://github.com/AndreaProvenzale/awaywork/blob/main/harness.md) around it decides the limits (no forge access, targeted tests only, no background commands, when to stop), which no skill lifts. ## Run one on an item no rule takes No rule matches an item, and you want `fix` on it this once. `--skill` runs a skill on that item, foreground or `--detach`; the `/away` pane's `g` on a `no rule` card does the same: ```text skills-run-detach $ awaywork run --unit CH-1 --skill review --detach Started CH-1 as run 20261109-112811; awaywork report 20261109-112811 shows it once it ends ``` It's a one-off rule, last in the queue, for that run alone: `plan` doesn't show it and other items read as without it. A real rule always wins, so `--skill` refuses an item one takes (`--skill is only for an item no rule takes: #1 plan takes CH-3`), and the usual refusals hold (`deliver draft_pr: feature-7 is someone else's PR`). - Any listed skill but `judge` (a gate) and `sync` (needs `deliver: push`). - No `on_done`, `deliver`, `wait`, thread or gates of its own (top-level gates still apply): a draft PR on a new `away/` branch, or a report. - Nothing on the item: no comment, move or thread. What a rule would post, questions included, stays in the report; a draft's `.away/out/` files still go to your [`store`](./workflows/store.md). - Nothing goes on from it: after `continue` or `ask` you finish the draft or report by hand, and the attempt counts for no rule. ## Where skills come from A name, a rule's or in `skills`, is looked up in order: 1. `skills//` next to your config, yours (`~/.config/awaywork/skills/` by default); 2. `.awaywork/skills//` in the unit's repo, the team's; 3. the built-ins. Extra skills also fall back to the [repo's own skill folders](#bring-your-repo-s-skills). Claude Code gets the unit's skills as a plugin outside the clone (`--plugin-dir`), named `awaywork:`, on every unit, someone else's PR included; Cursor the same plugin through its `--plugin-dir`; Codex a copy in the clone's `.agents/skills/`, where a repo skill of the same name wins except on someone else's PR. Details: - A config outside `~/.config/awaywork/` (`--config`, `AWAYWORK_CONFIG`) uses its own `skills/`; `doctor` warns while the default folder holds skills it doesn't use. - On someone else's PR, `.awaywork/skills/` is the author's and skipped. - A skill from `~/.claude/skills/`: link it in, `ln -s ~/.claude/skills/ ~/.config/awaywork/skills/`. - On a runner or in a container, yours are the copy `schedule install` puts in `.awaywork/skills/`. - `doctor` sees user and built-in skills only, so it warns about repo skills, which resolve at run time; a skill found nowhere fails the unit with `unknown skill`. ## Tweak a built-in ```sh awaywork skills eject fix $EDITOR ~/.config/awaywork/skills/fix/SKILL.md ``` The ejected copy shadows the built-in from then on. ## Bring your repo's skills Your repo has skills for Claude Code, Codex or Cursor? Name them in a rule scoped to that repo, no copying: ```yaml queue: - skill: implement skills: [tdd-implementation, writing-migrations] where: { type: issue, repo: acme/api } ``` An extra skill that isn't yours, in `.awaywork/skills/` or built in, is looked up in the clone's `.claude/skills/`, `.agents/skills/`, then `.cursor/skills/`. They mostly matter for Codex, which gets a copy: Claude Code loads `.claude/skills/` itself, and Cursor all three. Skills the model reaches for itself port fine: a TDD loop, a migration checklist, a tricky domain's rules. Workflow skills don't: one that waits for your approval, uses `gh`, or is `disable-model-invocation` has nobody to approve, no forge login, or can't be called. Keep the built-in and port the standard instead, say an instruction file saying "review against `.claude/skills/pr-review/references/review-standard.md`". Details: - Not in top-level `skills`: that joins every unit, other repos and others' PRs too, where it fails with `unknown skill`. - They only fill in names: a rule's main skill never comes from them, so `.claude/skills/implement` doesn't replace the built-in. Move one to `.awaywork/skills/` to make it a main skill. - Like `.awaywork/skills/`, skipped on someone else's PR, where naming one fails with `unknown skill`. - They resolve at run time, so `doctor` warns. ## Try an edit on a past unit ```sh awaywork replay 20261006-020000 acme-api-1 ``` Replay rebuilds a past unit's first step, a clone at its start commit with its saved context, and runs it again, so you see what an edited skill or instruction does. The files are read fresh: each `SKILL.md`, instruction file and repo and provider config. The unit is as recorded: its skills and its rule's `model`, `with` and `instructions` list, so config edits to those change nothing. It runs the provider and model once, outside the quota ceilings, under your [isolation](./safety/isolation.md), and never pushes or opens a PR. The clone stays on the unit's branch under `replays/` in the run's state dir (its own `/tmp/awaywork-*` under `user`), with the worker's `out/` beside it. Fetch the branch into your checkout to look (`git fetch away/acme-api-1`), which runs nothing of it. Details: - The context is the post-run copy, so files the worker edited under `context/` replay as edited. Setup gets no repo `secrets`; MCP servers don't start. - Only a unit's first step, on its rule's first model. A later [`then`](./config/chains.md#then) step starts from commits only the chain's clone had, and records no `replay.json`, nor does a [retry](./config/queue-rules.md#retry-on-the-next-model), a unit that never started, or one from before replay existed. - The clone is still the worker's, `.git` included: read its files, but run no git in it, not even `git status`, `git diff` or `git log`, nor its scripts. A worker's submodule config runs its `core.fsmonitor` on your `git status`, as would the clone's `core.pager` if the replay couldn't restore its config; an `.envrc`, a `package.json` script or editor tasks run as you too, what `user` and `container` prevent. - awaywork restores `.git/config` and removes `.git/hooks`, nothing more, unless the clone is lost, and prints where it stays, even on failure. ## Write your own ``` ~/.config/awaywork/skills/triage-flaky/ SKILL.md ``` ```markdown --- name: triage-flaky description: Find out why a test is flaky and make it deterministic. Use when a PR's only failing check is a known flaky test. metadata: deliver: draft_pr --- # Triage a flaky test The unit is a pull request whose failing check is a test that passes on retry. ... In the PR body, name the test, the cause you found, and the command you ran 20 times to show it no longer fails. ``` Then name it in a rule: ```yaml queue: - skill: triage-flaky where: { type: pr, mine: true, checks: failing, label: flaky } ``` Say what the output must state, as the built-ins do: what was checked, how, and what couldn't be. A skill that proves a change, with the gates that check what it left, is worked through in [Proving a change](./workflows/proving.md). The context is in `.away/context//`: `item.md` always, `diff.patch`, `threads.md` and `checks.md` for a PR, and for a plan's task the contract in `contract/` and what other repos' tasks ship in `shipped//` ([Across repos](./workflows/tasks.md#across-repos)). Frontmatter and rule keys: - `metadata.deliver: report` makes it report; otherwise it commits, where the rule's [`deliver`](./config/chains.md#then) says. - `metadata.type`, a conventional-commit type (`feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `style`, `revert`), fills `{type}` in a [branch name](./config/repos.md#branch-names): `{type}/{key}-{title}` gives `feat/ENG-12-login-with-sso` for `implement`. The chain's first committing skill decides; otherwise `chore`. - It's read at plan time, before the clone, so a repo's `.awaywork/skills/` can't set it: a repo skill replacing a built-in gets the built-in's type, others `chore`. - `metadata.merge: base` has awaywork merge the PR's base itself before setup and push it, as [`sync`](./config/chains.md#sync) does; the body matters only on a [conflict](./config/chains.md#conflicts), for the worker resolving it, which can edit or delete the merge's files but not add one. The rule must be one step with `deliver: push`, else it's skipped. - `with: { key: value }` on a rule or step lists values in the prompt's Parameters section (multi-line values JSON-quoted on one line). Document them under `metadata`, e.g. `metadata: { depth: "quick or thorough" }`; awaywork doesn't check them. - `skills: [name]` on a rule, or top-level, adds skills the agent may call. What a skill writes in `.away/out/`: - **`status`:** one word when a step stops, as the harness asks: `done`, `ask` or `continue`. A skill writing none works as before ([Questions](./workflows/questions.md#questions), [Continue](./workflows/questions.md#continue)). - **`verdict`:** one lowercase word, like `pass`, shown in the report and matched by a [loop](./config/chains.md#loops)'s `until`. - **`review.json`:** line comments, `[{ "path": "src/limit.ts", "line": 42, "body": "Burst should come from config." }]`, `line` on the new side, at most 100 comments and 100 KB. Under [`pending_review`](./how-it-works.md#delivery) they land on lines the diff adds or shows, `report.md` the body; if GitHub refuses one, all go in the body, and an invalid file is dropped, the result saying why. Otherwise unread. - **`questions.md`**, with `ask` in `status`: plain text (Jira and Asana show no Markdown), at most 30 KB. awaywork posts it on your item and waits ([Questions](./workflows/questions.md#questions)); the next `item.md` shows it under `awaywork's questions`, replies after. Without `ask`, a committing step went on and nobody reads it. - **`plan.yml`:** asked for under `deliver: plan`, in the shape the prompt spells out (the `planned` rules' skills, the repos a task may name, task ids a closed PR retired), optionally with a contract file; any skill can write it. awaywork checks it and posts it with `report.md` ([Plans](./workflows/plans.md)); the next `item.md` shows it under `awaywork's plan`. - **Anything else** but a plan's contract is kept with the run and reaches [notify](./results.md#notify) as `$AWAYWORK_OUT_DIR`, unread by awaywork. Treat it as untrusted: the model wrote it after reading others' text. It also holds earlier [chain](./config/chains.md#then) steps' files, so check `$AWAYWORK_SKILL` first. # Configuration One YAML file, `~/.config/awaywork/config.yml` by default; `--config ` or `AWAYWORK_CONFIG` point elsewhere. This page goes by example. Every key, with its type and default, is in the [reference](../../reference/config.md). Where to find what: - [Editing safely](#editing-safely): validate, preview. - [Stability](#stability): what stays valid across releases. - [A full example](../../examples/full-config.md): most keys in one file. - [A base config](#a-base-config): share settings with `extends`. - [Windows, limits and budget](./providers.md#windows-limits-and-budget): when runs start, how much one does. - [Providers](./providers.md#providers): agents, their settings and logins, your own CLI. - [Sources](./sources.md): where items come from, Bitbucket with Jira, routines. - [Branch names](./repos.md#branch-names): what a unit pushes to. - [Linking PRs](./repos.md#linking-prs): how a PR is tied to an item. - [Repos and instructions](./repos.md#repos-and-instructions): per-repo commands, and everything that runs in a clone. - [Queue rules](./queue-rules.md): which skill runs on which item. - [Matching items](./where.md): `where`, and each source's fields. - [Notify](../results.md#notify): your command after each step and each run. - [MCP servers](./mcp.md): servers a rule gives its units. - [Isolation](../safety/isolation.md): a worker user or a container. - [Old runs](#old-runs) and [Secrets](#secrets). Gates, plans, threads and the store are in [Workflows](../workflows/index.md); multi-step rules in [Chains](./chains.md). ## Editing safely ```sh awaywork doctor # validate, including skill names awaywork plan # see what the change does to the next run ``` Or let your coding agent edit the queue, checked on your real items first: [With an AI agent](../agents.md). For autocompletion in editors with the YAML language server, start the file with: ```yaml # yaml-language-server: $schema=https://andreaprovenzale.github.io/awaywork/config.schema.json ``` `awaywork config schema` prints the same JSON Schema for the installed version. ## Stability From 0.1.0-rc.1, a config valid on one minor release stays valid on the next. Keys marked experimental may change in a minor release, with a note in the release notes: so far only `forge.github.host`. ## A base config Several configs, one per role, tend to repeat `windows`, `budget`, `sources` and `repos`. Put those in a base and have each role file `extends` it: ```yaml # team/base.yml windows: [{ start: "01:00", end: "07:00" }] limits: { unit_minutes: 45 } providers: [claude] repos: { acme/api: } sources: { github: {} } instructions: [notes/team.md] ``` ```yaml # roles/developer.yml extends: ../team/base.yml limits: { units: 6, units_per_repo: 3 } queue: - skill: fix source: github where: { type: pr, mine: true, checks: failing } ``` Maps merge key by key, the role winning: `limits` above gets all three keys. A list replaces the base's outright, `queue` and `windows` included, since rule order matters. An error names the file it comes from: ```text config-doctor-extends $ awaywork doctor --config roles/developer.yml ✖ team/base.yml: windows[0].end: expected a time like "01:00" ``` Details: - `extends` is relative to the role file. One level only, local only: a base with its own `extends`, or a URL, is an error. - There's no unsetting what the base sets: `null` is just an invalid value. - Relative paths resolve against the file they're written in (`notes/team.md` is `team/notes/team.md`). So does the folder a custom source's `command`, a `context` command or a custom provider's `probe` runs in, and `claude: { settings }`. A custom source's `write` and `setup` run in the folder of the file that sets its `command`. - Skills stay next to the file you pass to `--config`. - An error inside a list the role sets names the role; a key only the base sets names the base. ## Old runs Run dirs keep plans, logs, context and reports, and a `replay`'s clone, easily 100 MB. `keep_runs` (a span, `30d` by default) says how long: ```yaml keep_runs: 14d ``` Each `awaywork run` prunes once it holds the run lock (not on `--dry-run`), going by run id; [`watch`](../scheduling/machine.md) does at start and daily, logging it. Runs in progress and those holding an item's latest report stay. A removal prints `Removed 3 runs older than 30d, 124.8 MB freed`; a failure is a `warning:`. `report --list`, `stats` and `replay` see only kept runs. ## Secrets The config holds env var **names** only. Values come from the environment or `~/.config/awaywork/env`, the environment winning. `awaywork secret ASANA_TOKEN` saves one there off your shell history, at a hidden prompt or from a pipe (`pbpaste | awaywork secret ASANA_TOKEN`), replacing an existing line. | Variable | Used by | | --- | --- | | `CLAUDE_CODE_OAUTH_TOKEN` | Claude without the local login (`claude setup-token`); wins over `config_dir` | | `ASANA_TOKEN`, `LINEAR_API_KEY`, `JIRA_API_TOKEN` (or your `token_env`) | Asana, Linear, Jira | | `GH_TOKEN` | GitHub in Actions; locally `gh`'s login or `forge.github.user`'s | | A custom source's `secrets` | Its `command` and `write` | | `context.secrets`, `notify.secrets`, `repos..secrets` | That `context` command, the notify command, that repo's `setup` | | `mcp..secrets` | That server, for units whose rule names it | # Queue rules A rule maps items to a skill; the first match wins, top first. Only `skill` is required: ```yaml queue: - skill: fix where: { type: pr, mine: true, checks: failing } - skill: implement source: jira where: { status: Ready } deliver: pr ``` | Key | Does | | --- | --- | | `skill`, `source`, `where` | What runs, on which items: [Matching items](./where.md) | | `provider`, `model` | Overrides routing for this rule; two models [retry](#retry-on-the-next-model) | | `skills`, `mcp`, `instructions`, `with` | Extra skills, [MCP servers](./mcp.md), instruction files, [parameters](../skills.md#write-your-own) | | `deliver` | `draft_pr` (default), `pr`, [`push`](./chains.md#push), `report`, `pending_review` or [`plan`](../workflows/plans.md); see [Delivery](../how-it-works.md#delivery) | | `branch`, `order`, `units` | [Branch template](./repos.md#branch-names), [item order](#order), units per run | | `then`, `quota` | [More steps](./chains.md#then), [quota checks](./chains.md#quota) | | `plan_tasks` | On a `deliver: plan` rule, the most tasks its [plan](../workflows/plans.md) holds; 8 by default | | `on_done` | [Comment and move](#on-done) your items | | `gates` | The rule's [own gates](../workflows/gates.md#gates-of-a-rule-s-own), on top of the top-level and repo ones | | `wait`, `post`, `thread` | Plan and report posts: [waiting](../workflows/posts.md#posts-that-wait), [lean or full](../workflows/store.md#files-that-come-back), [where they go](../workflows/posts.md#threads) | | `run` | `window` (default) or [`soon`](#run-soon) | Types, defaults and full meanings are in the [reference](../../reference/config.md#queue). An item goes down the list and stops at the first rule whose `source` and `where` it matches. One that matches none is skipped as `no_rule`, and `plan --why ` names the closest rule and the conditions it failed: ```mermaid flowchart TD item["An item from a source"] --> r1{"Rule 1: source and where match?"} r1 -- yes --> s1["Rule 1's skill and deliver"] r1 -- no --> r2{"Rule 2 matches?"} r2 -- yes --> s2["Rule 2's skill and deliver"] r2 -- no --> rn{"... the last rule matches?"} rn -- yes --> sn["That rule's skill and deliver"] rn -- no --> skip["Skipped as no_rule:
plan --why names the closest rule"] ``` Four kinds of item go their own way: awaywork's own PRs reach only [`ours` rules](#your-reviewed-awaywork-prs), a plan's tasks only the first [`planned` rule](#a-plan-s-tasks) for their skill, a card carrying awaywork's plan [stays on its plan rule](../workflows/tasks.md#the-plan-sticks-to-the-card), and an item whose report you approved [goes on to the next rule](../workflows/posts.md#posts-that-wait). ## The default queue With no `queue`, or an empty one, nothing runs: `plan` says so and `doctor` warns. `awaywork init` writes these rules, minus those for sources you didn't configure: | Skill | GitHub | Trackers\* | | --- | --- | --- | | `fix` | Your PR, checks failing | `QA` | | `fix` | Your PR with unresolved threads; your awaywork PR with threads you started | | | `review` | A trusted author's PR requesting your review | | | `implement` | | `In progress` | | `plan` | | `Current sprint` (Asana), `Todo` (Linear), `To Do` (Jira) | \* An Asana section, a Linear or Jira status, matched ignoring case. Read it row by row, left to right: that's the rule order, so broken things get fixed before new work starts. On [Bitbucket](./sources.md#bitbucket-and-jira) only the tracker rules apply. The section and status names are guesses; the [`/away` setup](../away.md#set-up) writes the queue out with your tracker's real ones. ## Your reviewed awaywork PRs awaywork's own PRs match only rules whose top-level `where` names `ours` (in `any` it's an error), so they wait for your review. After it: ```yaml - skill: fix where: { type: pr, mine: true, ours: true, my_unresolved_threads: { not: 0 } } ``` - The new PR stacks on awaywork's, on a branch named after it (`away/acme-api-9` for #9). - Merge it, and the next pickup takes `-2`. While it's open or closed unmerged, #9 is skipped as `awaywork tried this in ; delete away/acme-api-9 to run it again`. - Resolve the threads it answered, or the rule takes #9 up again for them. - The rule runs on threads you started, but the agent reads every unresolved one. - `ours` is the GitHub source's: a custom source's `ours` field holds nothing back. ## A plan's tasks A [task](../workflows/tasks.md) matches only rules whose top-level `where` names `planned: true`, the first whose `skill` is the task's (`implement` by default), and those rules take only tasks. Tasks carry their card's fields: ```yaml - skill: implement where: { planned: true } deliver: pr - skill: fix where: { planned: true, labels: bug } # a plan's `skill: fix` tasks, on bug cards only ``` - A plan naming a skill no `planned` rule takes isn't posted. - A `planned` rule delivers `draft_pr`, `pr` or `report`. A task is done when its PR merges, or, under a rule that reports, once its result is on the card: a [report task](../workflows/tasks.md#report-tasks), which nothing can wait on. - Config errors: `planned: false`, `planned` inside `any`, and a `deliver: plan` rule with no `planned` rule. ## Finished items Rules see open items until one names `state`: `open`, `merged` or `closed` (closed unmerged, done, canceled). `closed` is when it finished, a date field: ```yaml - skill: review where: { type: pr, mine: true, state: merged, closed: { newer_than: 14d } } deliver: report ``` ```text config-plan-rules-merged $ awaywork plan --rules Queue #1 review type=pr mine=true state=merged closed:newer_than=14d deliver=report 85 taken: acme/api#329, acme/api#328, acme/api#327, … ``` Details: - A `where`, or an `any` entry under one, that never names `state` matches open items only: `any: [{ state: merged, label: release }, { label: hotfix }]` takes merged `release` PRs and open `hotfix` ones. - `closed` without a `state` that can be `merged` or `closed` is an error; so is `closed: { older_than }` without a `newer_than`. - Only when a rule can match finished items (in `any` and `not` too) does its source read them: back to the widest `closed: { newer_than }`, or 7 days. - GitHub reads your merged or closed PRs and closed assigned issues, plus your `search` ANDed with `is:closed`; search stops at 1,000 results, and `plan` warns near it. Jira reads resolved issues, Linear completed or canceled ones (archived too), Asana completed tasks; [custom sources](./custom-sources.md#finished-items) print their own. - A finished item links no PR: its unit branches from the default branch. So `push` and `pending_review` are errors on a rule matching only finished items, and a rule matching both skips them. - Attempts work as for open items; a PR merged since its last unit counts as changed. ## `order` Units run in the source's order, newest first for GitHub, so with little quota the oldest can wait. `order: oldest`, `newest` or `updated` sorts them; items without the time go last. A card's [tasks](../workflows/tasks.md#the-feature-block) run back to back where the first ranks, in dependency order. ## Retry on the next model ```yaml - skill: fix where: { type: pr, mine: true, checks: failing } model: { claude: [sonnet, opus] } ``` A step that fails on `sonnet` (a failed turn or the turn cap) runs once more, right away, on `opus`: in a one-step rule and in each step of a [chain](./chains.md#then), where a step can bring its own pair. Details: - The retry starts from the clone as the failed step found it, its commits, edits and `.away/out/` changes dropped (gitignored files stay), on the same provider, without rechecking [`when`](./chains.md#when) or [`quota`](./chains.md#quota). - Only a failure retries: not a quota stop, the time limit or Ctrl-C. No budget left, and the failure stands. - Both attempts show in the report and cost budget. For [giving up](../how-it-works.md#units) on an item the retry counts, unless a quota, session or window stop cut it short. `notify` hears only the retry. ## `on_done` Moving the card tells the next person it's their turn; a comment tells them what happened: ```yaml - skill: implement source: jira where: { status: Ready } on_done: { comment: result, move: In Review } ``` `comment: result` posts, before the move, the result headed with the rule's skill: a report rule's report, else what it published (`Opened `, `Opened as a draft`, `Pushed 2 commits onto `, `Left your pending review on `), then a `Status:` line and `— awaywork`. `move` is in the tracker's words, ignoring case: a Jira status (via its transition), a Linear state of the team, an Asana section (in the first of the task's projects that `projects` names and has it), a GitHub label (created if new), or a custom source's [`write`](./custom-sources.md#writing-back). Both show under the unit in the report: ```text config-report-on-done 1 ENG-12 implement done, 7 turns https://github.com/acme/api/pull/41 result posted on https://acme.atlassian.net/browse/ENG-12 on_done moved ENG-12 to "In Review" ``` When it runs: - Once the chain published: a PR opened, a push landed, a report written, a pending review left. Not after a failed or stopped step, a run that ended first, a loop out of rounds, a step that left nothing or asked [questions](../workflows/questions.md#questions). A passed-over step, fired gates or a failed `verify` don't hold it back. - `deliver: plan`: once the [plan](../workflows/plans.md) is posted. It takes no `comment`, as the plan is the result. - A report whose post [waits](../workflows/posts.md#posts-that-wait): the result posts, and the move waits for your `approve`, made while the card is still where the post saw it. - A [report task](../workflows/tasks.md#report-tasks) posts its result on the card regardless; a PR task only when asked. A `planned` rule moves the task's card, which [stays with its plan rule](../workflows/tasks.md). What it writes to: - The unit's items that are yours and from the rule's tracker (its `source`, else the matched item's). A Jira rule leaves the PR in the unit alone. - Yours: your PR, an assigned issue, a Jira, Linear, Asana or custom source item. Never a PR you're reviewing or a `search`-only item (`not moved, not yours`, `got no result, not yours`). - Ownership is reread right before the move: `couldn't move ENG-12 to In Review: ENG-12 is no longer yours: not assigned to you`. A custom `write` checks it itself. - A missing status names the ones there are: `ENG-12 has no transition to "In Review", only to "QA", "Done"`. - Bitbucket and a custom source without `write` can't move; `doctor` warns. The token needs write scope (`write:jira-work`, a Linear key with write); Asana's and gh's have it. - A result over 30 KB isn't posted. Neither write fails the unit; errors are masked in the report. - Each write changes the item's updated time. awaywork reads it before and after; if it's still the plan's (or the result's), it records the new one so its own writes don't look like a change, while someone else's still counts. A custom `write` prints both times for this; otherwise make the rule stop matching where the item moved to. ## `run: soon` Some work shouldn't wait for the night: a PR gone red, an answer to awaywork's [questions](../workflows/questions.md#questions). [`awaywork watch`](../scheduling/machine.md#soon-runs) starts a soon rule's units within minutes of them coming due, a new item or one changed since its last attempt: ```yaml watch: { every: 5m } # the default sources: jira: { site: acme.atlassian.net, email: me@acme.dev, every: 15m } # a slow tracker queue: - skill: fix where: { type: pr, mine: true, checks: failing } run: soon ``` Details: - The daemon polls the soon rules' sources every `watch.every`, a source with its own longer `every` less often (both 1m or more). A poll plans only the soon rules and reads no quota. - Window runs, manual `run` and `plan` take every rule; `plan` marks soon units `#1 soon`. - GitHub Actions has no daemon: soon rules run in the windows. Costs and backoff are in [Scheduling](../scheduling/machine.md#soon-runs). # Matching items A rule runs on the items its `where` matches, by the fields their source gives. ## `where` Every key must hold; `any` lists alternatives: ```yaml where: type: pr # equals checks: [failing, pending] # any of review_requested: { not: true } # not unresolved_threads: { gte: 2 } # on a number field: gt, gte, lt, lte; combine for a range updated: { older_than: 3d } # on a date field: older_than, newer_than; m, h, d or w title: { matches: '^\[bug\]' } # on title, description or a text field: case-insensitive regex label: awaywork # on a list field: contains; GitHub issues must also be assigned to you behind: { gt: 0 } # GitHub: your PR's base has commits its branch lacks; see sync conflicts: false # GitHub: it merges cleanly into its base "fields.Priority": High # an Asana custom field blocked: false # Linear, Jira, Asana: no open blocker any: # at least one entry holds; each is a where, ANDed with the keys above - { label: urgent } - { checks: failing } ``` Strings compare ignoring case (`section: In progress` matches `In Progress`); numbers and booleans exactly; fixed values like `type` are lowercase. The keys are the item's [fields](#fields). Details: - A missing field equals nothing and is neither older nor newer than anything. Spans count back from when the plan runs. - Config errors: an unknown key (the error lists each source's keys), an invalid regex, an empty `any` entry. - `plan` names the match an item failed, for `any` each entry's. - GitHub reads only issues assigned to you, so a labelled issue nobody assigned to you plans nothing, unless a `search` reads it; then `mine: true` keeps a rule to yours. ## Fields Every item has: - `type` (`pr`, `issue` or `task`), `title` and `description`; - `approved`: the skill of awaywork's newest result on it that you approved or that [went ahead](../workflows/posts.md#posts-that-wait); `approved: true` is an error that says so; - `state`: `open`, or `merged` or `closed` for the [finished items](./queue-rules.md#finished-items) a rule asks for; `closed` is when it finished; - `planned`: it's a [task](../workflows/tasks.md) of a plan you approved. The trackers' fields, shared ones first: | Field | Sources | Value | | --- | --- | --- | | `repo` | All | `owner/name`; on a tracker, from `repo_map` | | `created`, `updated` | All | ISO times; a routine's are the occurrence | | `project` | Asana, Linear, Jira | Its name; Jira's key | | `status` | Linear, Jira | The workflow state | | `labels` | Linear, Jira | Asana calls them `tags`, GitHub `label` | | `priority` | Linear, Jira | `High` | | `blocked` | Asana, Linear, Jira | An open blocker: an incomplete dependency, a blocking issue, an "is blocked by" link | | `fields.` | Asana, Jira | The custom field's value | | `section` | Asana | Its section in the project | | `due` | Asana | Date, or date and time | | `team` | Linear | Its key | | `issuetype` | Jira | `Bug` | | `name` | Routines | The routine's name | GitHub's own: | Field | On | Value | | --- | --- | --- | | `label` | PRs, issues | Its labels | | `author` | PRs, issues | The author's login | | `trusted` | PRs, issues | The author is an owner, member or collaborator | | `mine` | PRs, issues | A PR you authored, an issue assigned to you | | `ours` | PRs | awaywork opened it | | `draft` | PRs | It's a draft | | `review_requested` | PRs | Your review is requested | | `checks` | PRs | `failing`, `pending` or `passing` | | `unresolved_threads` | PRs | Those [shown to the agent](../safety/index.md#public-repos) | | `my_unresolved_threads` | PRs | Those you started | | `files` | PRs | The first 500 paths it touches; test with `matches` | | `changed_lines` | PRs | Additions plus deletions | | `conflicts` | PRs | It doesn't merge cleanly into its base; unset while GitHub works it out | | `behind` | Your open PRs | Commits on its base its branch lacks | A custom source's are the `fields` its config lists ([Custom sources](./custom-sources.md)). # 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](../workflows/plans.md). A step is a skill name, or a map: ```yaml 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`](#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: ```text chains-plan 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.md` plus `ask` in `.away/out/status` (`implement` on 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 under [`run: soon`](./queue-rules.md#run-soon) ([Questions](../workflows/questions.md#questions)). - So does `continue`: its commits open as a draft a later run goes on with ([Continue](../workflows/questions.md#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](#push) 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 `plan` and `review` do. A chain that committed nothing publishes its reports, joined, as one. - The rule's `deliver` decides where commits go; steps have no `deliver` or `branch`. A committing skill that should only report is a [skill of your own](../skills.md#write-your-own). - `model: { claude: [sonnet, opus] }` [retries a failed step](./queue-rules.md#retry-on-the-next-model) on the second, and the chain goes on. - A chain counts once against [caps](./providers.md#windows-limits-and-budget) 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: report` makes it a review chain, and `pending_review` leaves 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 `fix` then `loop: [review, fix]`. - Steps are named `-`, in a loop `--`, each with a numbered folder under the run's `units//`, next to `out/` 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.patch ``` - [`awaywork replay`](../../reference/cli.md#awaywork-replay) rebuilds 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: ```yaml queue: - skill: implement where: { type: issue, label: awaywork } then: - loop: - { skill: review, provider: codex } - fix until: { verdict: [approve, comments] } max: 3 ``` `until` 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`](#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 for `when` and ends the loop either way. - `setup` doesn'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 `until` it fails while the clone has uncommitted changes beyond what setup and earlier checks left, since they wouldn't publish; `when` sees them, as the step will. - Stopped by the window's end, it ends the chain, which publishes; a `when` stopped 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: ```yaml 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 yours ``` - `verdict`: the chain's latest verdict, by any earlier step, matched as in `until`, except `{ not: … }` also holds when none was written. - `command`: as in `until`, 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 `/`: `*.md` matches `README.md`, not `docs/a.md`; `src/auth/**` everything under it. - Every key must hold. `plan` shows each `when`; 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: ```yaml 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: ```yaml queue: - skill: fix where: { type: pr, mine: true, unresolved_threads: { gt: 0 } } deliver: push ``` The 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`](#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](../scheduling/actions.md#on-the-runner) doesn't keep: there the rule pushes every run its `where` holds. ## `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](#conflicts). ```yaml queue: - skill: sync where: { type: pr, mine: true, behind: { gt: 0 } } deliver: push ``` `behind` counts base commits the branch lacks ([GitHub fields](./where.md#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 into ` commit with the trailer: ```text chains-sync-clean 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-ff` of 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 `setup` then [`verify`](./repos.md#repos-and-instructions) run on the merged tree, the only repo code here; a failure goes into the report and the push still happens, for your `checks: failing` rule to pick up. - Only that merge is pushed. If `setup` or `verify` amend 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 `), 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's `updated`. - No review hold after a clean merge, so the next `sync` or a `fix` on the CI it started can take the PR at once. - A rule of its own: `deliver: push` required; a `then` or `quota` on it, or `sync` as another rule's step, is a config error. Everything `push` checks 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`: ```markdown 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: theirs ``` The 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](../workflows/gates.md), and the summary in the report, the decision card on top when one fired: ```text chains-sync-conflicts 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.txt ``` Details: - 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 `changed` gate 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_FROM` is that commit: diff it to `HEAD` with two dots, since `...` would read every base commit. A judge's prompt lists the conflicted paths. - The summary opens with `Resolved:` and the paths, then `Also 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, per `conflict-marker-size`) neither side had, so fixtures with markers are fine; - the resolution keeps nothing of the base (as `git merge --abort` would), 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. - A conflicted lockfile often breaks `setup`, which runs on the conflicted tree first: `merge.md` then notes it, and the worker goes on. - `conflicts` reads 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 like `already up to date`. - The instructions are the skill's body, so you can [eject](../skills.md#tweak-a-built-in) `sync` to tweak how it resolves; keep `metadata.merge: base`. # Providers and budget ## Windows, limits and budget A run is one window opening, or one `awaywork run` by hand: ```yaml windows: - { start: "01:00", end: "07:00" } # the default - { days: [sat, sun], start: "10:00", end: "18:00" } ``` A scheduled run starts at `start`; no unit starts after `end`, and a running one stops there. `days` (`sun` to `sat`) are the days it opens on. Pacing follows the clock, not the number of windows. A run may spend up to where an even spend over the long window stands at the next window's opening, or at the reset if that comes first, so more windows split the week finer rather than spend more. How the [budget](../how-it-works.md#budget) shares combine is in How it works; start with `per_run: 5%` for a week to see what a unit costs. Limits cap what you'd have to review, per run and for every provider. None is set by default: ```yaml limits: { units: 8, units_per_repo: 3, unit_minutes: 90 } ``` Units past a cap are skipped in queue order, the cap named in `awaywork plan` and the report. The keys are in the [reference](../../reference/config.md#limits). Details: - Times are the machine's local time. In GitHub Actions they're the timezone you ran `schedule install` in, a run may start [up to an hour late in summer time](../scheduling/actions.md), and a hosted runner stops it at about 6 hours. - [`awaywork watch`](../scheduling/machine.md) rereads windows every minute: no reinstall, unless you move the [wake](../scheduling/machine.md#waking-up-for-it). - `windows: []` means none: the daemon idles, and the Actions workflow runs only when dispatched. Windows shouldn't overlap. - A run started outside every window stops workers at `present_session_max` (40%) instead of `session_max`, and skips providers whose quota it can't read. - `per_run` caps each run, and `runs_until_reset` counts window openings. - `budget` applies to every provider; a different budget takes a second config. - A [`then`](./chains.md#then) chain is one unit. Each of a plan's tasks is one, in its own [repo](../workflows/tasks.md#across-repos), so a cap can cut a card's [block](../workflows/tasks.md#the-feature-block); the next run carries on. - Only units whose first step got a provider count. Caps apply per `awaywork run`, like `fallback.units`, which every step draws on. A unit named with `--unit` goes past them. - A rule's own `units` caps that rule; a `deliver: plan` rule's `plan_tasks` (8) caps its [plan](../workflows/plans.md)'s tasks. ## Providers A list, in routing order. A bare name uses the defaults; a map sets `model` (passed as is: `claude --model`, `codex exec -m`, `agent --model`) and its own settings: ```yaml providers: - claude: { model: opus, settings: claude-worker.json } - codex: { config: { model_reasoning_effort: high } } - cursor ``` Workers skip your user-level agent config. What you do want them to have, on every unit including someone else's PR, goes here: - `claude: { settings }`: a settings file, relative to the config, passed as `--settings`. Auto-memory stays off, and workers run in `dontAsk` mode: a tool not pre-approved, by awaywork or your `permissions.allow`, is denied. - `codex: { config }`: each key passed as `codex exec -c key=value`. Cursor takes only `model` (its `auto` when unset). Details: - It needs `CURSOR_API_KEY` in the env file, even on your machine: each run gets a fresh `HOME` in the clone, `.away/agent-home`, so none of your `~/.cursor` comes along, `agent login` included. Make a key in Cursor's dashboard. - awaywork writes that `HOME`'s permissions: any shell command, read and write, plus the `tools` of the rule's [MCP servers](./mcp.md). Never `--force`. - Its commits can carry `Co-authored-by: Cursor `, as Claude Code's can. - A Cursor subagent is one turn toward the cap and one `task:` line in `worker.log`, whatever it does inside; `limits.unit_minutes` bounds it. - Its usage shows only in its dashboard, so it runs on the [fallback](../how-it-works.md#budget) caps and sits out runs started outside a window. - On a GitHub-hosted runner the action installs it: [Running Cursor](../scheduling/actions.md#running-cursor). - An older command provider of yours named `cursor` must be renamed: `providers[0].cursor: built in, so it takes no command; give your own provider another name`. ### Which login Work and personal subscriptions on one machine? Pin each config's logins: ```yaml providers: - claude: { config_dir: ~/.claude-work } - codex: { config_dir: ~/.codex-work } forge: { github: { user: octocat-acme } } ``` `config_dir` is passed as `CLAUDE_CONFIG_DIR` or `CODEX_HOME`; log in there once (`CLAUDE_CONFIG_DIR=~/.claude-work claude /login`, `CODEX_HOME=~/.codex-work codex login`). `forge.github.user` picks a `gh auth status` account: its `gh auth token --user` becomes `GH_TOKEN` for everything awaywork does on GitHub, no `gh auth switch`. `doctor` names each login and fails with the fix: ```text config-doctor-logins ✔ gh login: octocat-acme, pinned by forge.github.user ✔ claude login: , max, in ~/.claude-work, pinned by providers[0].claude.config_dir ✖ codex login: ~/.codex-work doesn't exist; log in there with CODEX_HOME=~/.codex-work codex login ``` Details: - `~/.claude` means Claude Code's default login, found only with `CLAUDE_CONFIG_DIR` unset, so that's what awaywork does. - Unpinned, a run uses the environment: for a [scheduled job](../scheduling/machine.md), the env file and each binary's default, not a shell function that switches logins. - The environment wins: `CLAUDE_CODE_OAUTH_TOKEN` over `config_dir`, `GH_TOKEN` or `GITHUB_TOKEN` over `user` (`GH_ENTERPRISE_TOKEN` with a `host`). So one config also runs in Actions or a container. `doctor` warns when that token is someone else's: `gh login: octocat from GH_TOKEN, not forge.github.user octocat-acme: a token in the environment wins; make it octocat-acme's, or unset it`. - Cursor's login is `CURSOR_API_KEY`, everywhere. - Under `isolation: user` or `container`, Claude's `config_dir` is refused (`set CLAUDE_CODE_OAUTH_TOKEN instead`). Codex's works: the `auth.json` it lends comes from there. - A pin that can't be made stops `run`, `plan`, `approve`, `replay`, `sources` and `schedule install` before they touch anything (`forge.github.user: octocat-acme isn't logged in to gh; log in with gh auth login, as octocat-acme`), and `watch` starts nothing new until it can; working pins still apply to runs already going. ### Bring your own CLI opencode, Amp and friends need no code in awaywork. Name a provider and give it a command; your CLI, your login: ```yaml providers: - claude - opencode: command: opencode run --auto "$(cat "$AWAYWORK_PROMPT")" probe: ./opencode-quota.sh # optional; without it, windows only limits: unit_minutes: 45 ``` It runs in the clone like any worker, the prompt in the file `$AWAYWORK_PROMPT`, output to `worker.log`; exit 0 is a finished unit. (`--auto` approves whatever your opencode config doesn't deny; check your version's `--help`.) There's no turn cap, so `limits.unit_minutes` is required. The keys are in the [reference](../../reference/config.md#providers-key). A `probe` prints the quota as JSON. awaywork paces against the `long` window; `short` only feeds the session stop: ```json { "windows": [ { "name": "5h", "span": "short", "used": 0.2, "resetsAt": 1793021152, "minutes": 300 }, { "name": "7d", "span": "long", "used": 0.3, "resetsAt": 1793539552, "minutes": 10080 } ] } ``` Details: - `secrets` are withheld from other workers and listed in Actions; `skills_dir` (default `.agents/skills`) is where the unit's skills go. - No `model` key (put it in the command) and no `mcp`: a rule it may run can't name MCP servers, so route those elsewhere with `provider`. - Nothing installs it on a GitHub-hosted runner, so there `plan` routes around it and `schedule install` leaves it out. - `fallback.turns` doesn't apply; its 60 is for Claude, Codex and Cursor without a quota reading. - Without a `probe` (or one with no `long` window) it runs on the `fallback` caps and sits out every run outside the windows, `run --unit` included. With no live rate-limit events, it can't stop mid-unit at the ceiling. - The probe runs in the config's folder once per `plan` or `run`, after each unit and in `doctor`, with 60 s and your whole environment. `used` is 0 to 1, `resetsAt` unix seconds, `minutes` optional (a `long` window without it is 7 days). - Whether the vendor allows unattended use is your call; `doctor` flags it `unattended: grey`. # Sources Each source reads your own items from one place: | Source | Reads | You set | | --- | --- | --- | | `github` | Your PRs, PRs awaiting your review, issues assigned to you, in the [`repos`](./repos.md#repos-and-instructions) you list, plus any `search` (`label:awaywork`, `mentions:@me`) | `{}` is enough; optional `search` | | `asana` | Your incomplete tasks in the workspace, optionally only some projects | `workspace` gid; optional `projects`, `repo_map` | | `linear` | Your issues neither completed nor canceled | Optional `repo_map` | | `jira` | Your unresolved issues | `site`, `email`; optional `cloud_id` (scoped token), `jql` (no `ORDER BY`), `repo_map` | | `routines` | Each routine's latest occurrence | [Routines](#routines) | | any other name | Your command's JSON | [Custom sources](./custom-sources.md) | Each also reads its [finished items](./queue-rules.md#finished-items) when a rule asks. The rest, defaults and `token_env` names included, is in the [reference](../../reference/config.md#sources). Every source takes an `every` for [`run: soon`](./queue-rules.md#run-soon); all but `github` and `routines` a [`link`](./repos.md#linking-prs). `awaywork sources ` lists, read-only, the ids and names your keys and `where` take (`--json` for scripts): ```text config-sources-linear $ awaywork sources linear linear: teams: - key: ENG name: Engineering states: - Todo - In Progress [...] ``` `github` lists your login and orgs; `asana` the workspaces, then the configured one's projects and sections; `jira` the projects and statuses, once `site` and `email` are set. The forge, where the repos live and PRs open, is its own top-level key, GitHub unless you say otherwise: ```yaml forge: github # the default; leave it out # forge: bitbucket # Bitbucket Cloud, see below # forge: { github: { host: github.acme.com, user: octocat } } # GitHub Enterprise, or a pinned gh account ``` `host` is a GitHub Enterprise host `gh` is logged in to; `user` is in [Which login](./providers.md#which-login). A config has one forge. GitHub items come only with `sources.github`: without it, GitHub is still where your trackers' work opens its PRs. ::: warning Experimental `forge.github.host` has only run in tests so far, never against a real GitHub Enterprise. `init`, `doctor` and artifact downloads still use `gh`'s default host. Tried it? [Open an issue](https://github.com/AndreaProvenzale/awaywork/issues/new) with how it went. ::: ## Tracker tokens awaywork only reads with a tracker token, except for [`on_done`](./queue-rules.md#on-done), [questions](../workflows/questions.md#questions) and [plans](../workflows/plans.md), and never hands it to the agent unless you list it in an [MCP server](./mcp.md)'s `secrets`. - **Asana** has no read-only token: make one just for awaywork, so you can revoke it alone. - **Linear:** a personal API key (Settings > Account > Security & access), read-only when offered, or with write for those three. - **Jira Cloud:** an Atlassian API token (id.atlassian.com > Security > API tokens). Prefer one with scopes, app Jira, `read:jira-work` plus `write:jira-work` for those three: `/away` then finds the `cloud_id`, and awaywork calls `api.atlassian.com`, as scoped tokens require. An unscoped token can do anything you can. Tokens last at most a year. - Jira custom fields sharing a name are keyed by name and id: `fields.Team (customfield_10040)`. ## Context commands A built-in source's `context` command fetches what an item points at, a Sentry issue or a runbook, with secrets the agent never gets: ```yaml sources: github: context: command: ./fetch-sentry.sh # reads $AWAYWORK_ITEM_URL, writes $AWAYWORK_CONTEXT_DIR/sentry.md secrets: [SENTRY_TOKEN] # kept out of the agent's env ``` It runs per item before the worker, in the config's folder, never the clone, with `AWAYWORK_ITEM_KEY`, `AWAYWORK_ITEM_URL` and `AWAYWORK_ITEM_FILE` (the item as JSON). What it writes to `$AWAYWORK_CONTEXT_DIR` lands in `.away/context//extra/` as is, so never write a secret there (`curl -v` into a file, say). Its limits are in [What runs in the clone](./repos.md#what-runs-in-the-clone). A custom source does this in its own `command`. ## Bitbucket and Jira Code on Bitbucket Cloud, tickets in Jira? Set `forge: bitbucket` and list at least one repo, and draft PRs open on Bitbucket: ```yaml forge: bitbucket repos: acme/*: # the workspace is literal, the name globs sources: jira: site: acme.atlassian.net email: me@acme.com repo_map: { ENG: acme/api } # project key -> workspace/name on Bitbucket ``` It goes through Atlassian's `twg` CLI, and git over SSH with your keys. You need `twg` logged in (`twg setup bitbucket`, or a token in `TWG_BBC_TOKEN`) and an SSH key git can use without a passphrase prompt, since launchd and cron have no terminal; a key in an agent your scheduler sees works too. `doctor` checks both (details at the end of this section). A run ends as on GitHub, with a draft PR whose footer links back to the item. `away/{key}` puts the Jira key in the branch, which Jira's development panel links on. [Plans](../workflows/plans.md) work too: twg finds task PRs, reads files a task [ships](../workflows/tasks.md#across-repos), and the build statuses a task [waits on](../workflows/tasks.md#waiting-on-a-check). Not yet: - **Bitbucket's own items:** there is no Bitbucket source, so no fix or review rules on its PRs. Your open PRs still [link](./repos.md#linking-prs) to items. - **`deliver: push`:** refused, since twg reads neither branch restrictions nor auto-merge. - **`deliver: pending_review`:** refused; GitHub only. - **Going on with its own draft:** a draft that says `continue`, or asked, is yours to finish ([Continue](../workflows/questions.md#continue)). - **GitHub Actions or a container:** `schedule install` refuses both; use launchd, systemd or cron. - **Fork PRs:** no pull refs to fetch, so the unit fails. - **The `github` source:** refused with `forge: bitbucket`; one forge per config. What `doctor` prints: ```text config-doctor-bitbucket ✔ twg: 1.3.8 ✔ twg acme ✔ git over ssh: acme/api ``` `twg acme` is `twg bb workspace get` per workspace; `git over ssh` a `git ls-remote` with `-o BatchMode=yes` added to git's ssh command (`GIT_SSH_COMMAND`, else `core.sshCommand`, else `ssh`; a `GIT_SSH` wrapper runs as is), so a prompting key fails here, not in a scheduled run. It warns when twg keeps its login anywhere but a file ([Safety](../safety/index.md#what-the-agent-can-reach)). ## Routines Work with no ticket, only a day: every Monday you triage Sentry. A routine is an item that comes due on the clock, so rules, `plan` and attempts treat it like any other: ```yaml sources: routines: sentry-triage: days: [mon] # sun to sat; every day when unset at: "09:00" # local time; or a list: ["09:00", "14:00"] repo: acme/api # required: where the work happens title: Triage last week's Sentry issues description: Group new issues, link duplicates, draft fixes for the top three. queue: - skill: triage source: routines where: { name: sentry-triage } deliver: report run: soon # within minutes of 09:00, not at the next window ``` Each is one `task`, its latest occurrence, keyed `--` in local time: branch `away/sentry-triage-20261026-0900`. `plan` lists when each comes due next, and `--why` which occurrence an item is: ```text config-plan-why-routine $ awaywork plan --why sentry-triage-20261026-0900 routines sentry-triage-20261026-0900 Triage last week's Sentry issues https://github.com/acme/api routine sentry-triage, due mon 2026-10-26 09:00; next mon 2026-11-02 09:00 [...] ``` Details: - A machine asleep all week wakes to one item, not a backlog. Once a unit on it finishes, it's [unchanged](../how-it-works.md#units) until the next occurrence, a new item. - It's yours (`mine`), its `url` the repo's page. Rules match `name`, `repo`, `created` and `updated` (both the occurrence). - Any name but `context` and `every`. - On GitHub Actions, times are the workflow's `TZ`. Without `run: soon` it waits for a window: Monday's 09:00 runs Tuesday at 01:00 with the default one. - No tracker to write to: `on_done` and `deliver: plan` are refused, and results or questions stay in the report; [`notify`](../results.md#notify) can send them on. - It takes a [`context`](#context-commands) command. `init` writes no rules for routines: write yours. # Custom sources Your tracker isn't built in, an internal board say? Write a command, in any language, that prints your open items as JSON, and awaywork treats it like any other source. ## Config ```yaml repos: acme/*: # an item in any other repo is skipped sources: github: {} board: # any name; rules use it as `source: board` command: python3 sources/board.py # runs in the config file's folder fields: [column, board] # what `where` may match on; `any` is reserved secrets: [BOARD_TOKEN] # env vars the command reads setup: pip install -r sources/requirements.txt # GitHub Actions and awaywork's image only link: "Refs {key}" # optional: the line that links a PR, see below queue: - skill: fix source: board where: { column: QA } - skill: implement source: board where: { column: In progress } ``` `init` writes no rules for custom sources: write yours. ## Output The command prints a JSON array to stdout; its stderr shows when it fails. ```json [ { "id": "8812", "key": "AI-42", "title": "Export invoices as CSV", "url": "https://board.example/cards/AI-42", "description": "Users want a CSV export.", "comments": [{ "author": "qa", "body": "Header row missing", "at": "2026-01-02T10:00:00Z" }], "fields": { "column": "QA", "repo": "acme/api", "updated": "2026-01-02T10:00:00Z" } } ] ``` | Key | | | --- | --- | | `id`, `key`, `title`, `url` | Required. `key` is the tracker's own reference; branch names use it, and your PRs are matched by it | | `type` | `task` (default) or `issue` | | `description`, `status`, `assignee`, `reviewers`, `labels`, `comments` | Optional. A comment may say `"mine": true` when your own account wrote it | | `state`, `closed` | Optional: `open` (default) or `closed`, and the ISO time it closed; see [Finished items](#finished-items) | | `fields` | Strings, numbers, booleans or lists of them. Every key must be listed in `fields` in the config, except `repo`, `created` and `updated` | | `context` | Optional Markdown the worker reads instead of `description`: attachments, linked specs, whatever helps | - **`fields.repo`** (`owner/name`) is where the code lives. Without it, a task is picked up only with a linked PR of yours, an issue never. - **`link`**, in the config, is the line that [links a PR](./repos.md#linking-prs) in your tracker's words, from `{key}`, `{id}` and `{url}`, no default: awaywork writes it into its drafts and links your PRs whose body holds it, besides the key in branch or title. - **`created` and `updated`** are ISO timestamps, which `order` sorts by. - The command is killed after 10 minutes. ## Finished items When a rule names [`state`](./queue-rules.md#finished-items), the command gets `AWAYWORK_CLOSED_SINCE`, the lookback's ISO start (`2026-10-11T09:30:00.000Z`), and may print items closed since, with `"state": "closed"` and `"closed"` set to when. Without it, print open items only, or all and the rules ignore the closed ones. ```js // board.mjs: open cards, and those done since the lookback when a rule asks const since = process.env.AWAYWORK_CLOSED_SINCE const cards = await fetchCards() // your tracker's API const items = cards .filter((c) => !c.doneAt || (since && c.doneAt >= since)) .map((c) => ({ id: c.id, key: c.ref, title: c.title, url: c.url, ...(c.doneAt && { state: 'closed', closed: c.doneAt }), })) console.log(JSON.stringify(items)) ``` `state` and `closed` are every item's own keys, so `fields` can't list them: print a tracker field called `state` under another name. A top-level `state` other than `open` or `closed`, or a non-ISO `closed`, is ignored with one warning naming the first such item, so an old command printing GitLab's `"state": "opened"` keeps working. ## House rules As for [built-in sources](../../extend/sources.md#rules-for-a-source): - `command` only reads; `write` writes, only when awaywork pipes it a write. - Print only your items, assigned to you or awaiting your review: awaywork takes every printed item as yours, so `write` may move any of them. - `write` refuses an item no longer yours since `command` printed it, which awaywork can't tell: exit non-zero and say so on stderr. - Read tokens from env vars listed in `secrets`. ## Writing back [`on_done`](./queue-rules.md#on-done) results and moves go through a `write` command of the source's; without one, its items stay put, with no result. ```yaml sources: board: command: python3 sources/board.py write: node sources/board-write.mjs # runs like `command`: config folder, `secrets` in its env fields: [column] secrets: [BOARD_TOKEN] # a token that may write, for both queue: - skill: implement source: board where: { column: Ready } on_done: { move: Review } ``` It runs once per write, the write as JSON on stdin: `op`, the item's `key` as `item`, its `id`, and `to` for a move or `body` for a comment. ```json { "op": "move", "item": "AI-42", "id": "8812", "to": "Review" } { "op": "comment", "item": "AI-42", "id": "8812", "body": "Which header row?\n\nReply here to answer: awaywork's next run takes this up again with your reply.\n\n— awaywork" } ``` - **A comment** is a step's [questions](../workflows/questions.md#questions), a rule's [plan](../workflows/plans.md) or your `awaywork approve` reply, ending in `— awaywork`: post it as is. - **List it back** in `comments`, oldest first, `mine` set as below, and the item waits for an answer or approval until a later comment replies. - **A non-zero exit** is a failed write: stderr goes to the run's output and report; the unit stays done. - **Print the updated time** (ISO) before and after the write, `{"before": "…", "after": "…"}`. When `before` is what `command` printed, awaywork records `after`, so its own move doesn't look like a change next run and someone else's still does. ```js // board-write.mjs: moves a card or comments on it, as you import { text } from 'node:stream/consumers' const write = JSON.parse(await text(process.stdin)) const card = `https://board.example/api/cards/${write.id}` const headers = { Authorization: `Bearer ${process.env.BOARD_TOKEN}`, 'Content-Type': 'application/json' } const before = (await (await fetch(card, { headers })).json()).card.updated_at const res = await fetch(`${card}/${write.op === 'move' ? 'move' : 'comments'}`, { method: 'POST', headers, body: JSON.stringify(write.op === 'move' ? { column: write.to } : { text: write.body }), }) if (!res.ok) throw new Error(`${write.op} ${write.item}: ${res.status} ${await res.text()}`) console.log(JSON.stringify({ before, after: (await res.json()).card.updated_at })) ``` Set `"mine": true` on a printed comment your account wrote and, if the tracker says, nobody else edited last: awaywork counts a comment as its own only with its last line and `mine`, so a teammate pasting the line doesn't pass for awaywork. ## Thread hosts Your tracker can't take awaywork's posts and `thread: github` doesn't fit (repos on Bitbucket, or plans kept out of the issue list)? A custom source with `thread: true` can host the [threads](../workflows/posts.md#threads), named in a rule's `thread`. ```yaml sources: jira: { site: acme.atlassian.net, email: me@acme.dev } # a read-only token notes: command: node threads.mjs ~/vault/awaywork write: node threads.mjs ~/vault/awaywork write thread: true # its write opens threads queue: - skill: story source: jira deliver: report wait: true thread: notes ``` The rule's first post for a card opens its thread, one more op for `write`: ```json { "op": "open", "item": "ENG-2101", "title": "ENG-2101: Export VAT report", "body": "awaywork posts here for [ENG-2101](https://acme.atlassian.net/browse/ENG-2101), Export VAT report: its plans, results and questions. Reply here to answer them; the card still decides whether it's yours.\n\n" } ``` It prints the thread's key and link, `{"key": "ENG-2101", "url": "file:///Users/me/vault/awaywork/ENG-2101.md"}`, plus an `id` when the host's own id isn't the key (a Notion page's). Each later post is a `comment` with that key as `item` and the `id` (else the key). Once the card's source says it's done comes a `close` with a last line, `The card is done, so awaywork closes its thread.`; a host may ignore it and get it again every run that still reads the card as done, a week by default. - **Listed back as items:** `command` prints each thread like an item, the `body` it got as `description`, marker line included. The marker is how awaywork finds a card's thread every run, with nothing in the state dir. It's also why a thread is never an item of its own: a thread host's item carrying the marker is left out. - **Its `comments` are the replies**, oldest first, `mine` on yours: only your reply answers a post, a teammate's is context in `item.md`. The rest works as on a GitHub thread: approving, feedback, held moves, `awaywork approve jira:ENG-2101` or the thread's key `notes:ENG-2101`. - **One post, one comment:** part of a post is the model's text, so a host that splits it at anything shaped like a reply heading lets that text pass for your reply, `approve` included. - **Nobody gets pinged:** unlike a GitHub issue, a file or page notifies nobody, so pair the host with a [notify](../results.md#notify) hook that tells you when something waits. Example: [a folder of Markdown files](../../examples/markdown-threads.md) as a thread host. ## Examples - [Chores](../../examples/chores.md): work with no tracker item, like tests for an untested module. - [Sentry errors](../../examples/sentry.md): unresolved errors with their stack trace, for `fix`. ## On GitHub Actions The runner has only the repo holding your config: - **Commit the command next to `config.yml`.** A `~/` path works only on your laptop; `doctor` warns. - **Build it in `setup`,** run in the config's folder before awaywork's action, without the source's secrets (`cargo build --release`, `go build -o board .`). A Mac binary won't run on the Linux runner; `ubuntu-latest` has Node, Python, Go and Rust. - **Add the `secrets` as repository secrets:** `schedule install --with github-actions` names them in the workflow and offers to set those your environment has. ## Try it ```sh awaywork doctor # runs the command and checks its output awaywork plan # shows which rule each item hits ``` # Repos and branches ## Repos and instructions `repos` lists the repos awaywork works in, and holds each one's commands and instructions: ```yaml repos: acme/web: # a bare key: listed, no settings acme/*: { setup: pnpm install --frozen-lockfile } # a glob: every repo it names acme/api: secrets: [NPM_TOKEN] # only setup sees these verify: pnpm lint && pnpm test test: pnpm vitest run {files} instructions: notes/acme-api.md ``` - **It's the allowlist.** The `github` source reads these repos, and an item from any source, a custom one too, that names a repo no key matches, as its `repo` or its linked PR's, is skipped; `awaywork plan --skipped unlisted_repo` shows them. A repo the config names itself, a routine's `repo` or a `repo_map` value, has to be listed, or the config is refused. - **A key** is `owner/name` or a `*` glob, which stays within the owner or the name (`acme/*`, `acme/api-*`). - **Settings merge.** A repo takes the settings of every key that names it: globs first, in the order written, then its own key, a later one winning per setting. `secrets` add up and `gates` merge by name. Above, `acme/api` gets the glob's `setup` and its own `verify`. - **`setup`** runs once per unit in the fresh clone, before any agent, outside the model; the agent is told it ran. - **`verify`** runs once the chain ends, before publishing. A failure still publishes, its tail in the PR body or push summary, and a `deliver: pr` PR opens as a draft; the tail also lands in `verify.log` in the unit's `out/` in the run dir. - **`test`** is a line in the agent's prompt. - **`image`** is what the repo's units run in under [`isolation: container`](../safety/isolation.md#in-a-container). - **`instructions`** (top level, per repo, per rule) are file paths relative to the config they're in. A repo can also carry `.awaywork/instructions.md`, read unless the unit is on someone else's PR. Top-level `skills` adds [skills](../skills.md#where-skills-come-from) to every unit. PR titles, bodies and commit messages have no config: the agent follows the repo's template, its commit style and your instructions. Details on `setup` and its `secrets`: - Later chain steps share the clone and don't rerun it, so a `when` or `until` command needing a step's new dependencies installs them itself (`pnpm i --frozen-lockfile && pnpm test`). Whatever setup leaves running is stopped. - Its `secrets` reach setup only, masked in its output, passed from repository secrets in Actions. Read them from the repo's config (`${NPM_TOKEN}` in `.npmrc`), not your home, which the agent shares. - It gets `GH_TOKEN` and `GH_ENTERPRISE_TOKEN` as `none`, so `gh` and git over https through gh's helper fail with a 401, even on a public repo. List `GH_TOKEN` in `secrets` to give it yours. - On someone else's PR it runs the author's install scripts, so it gets no secrets, and runs only for an author with write access or org membership. No secrets either on an awaywork PR's branch a rule took up, which a worker wrote. - A plan's task may name any repo `repos` lists, and runs with that repo's settings ([Across repos](../workflows/tasks.md#across-repos)). ### What runs in the clone Every config command that touches a clone. All run in a shell outside the model, in the clone, except `context`, which runs in the config's folder. None gets a forge token. | Command | When | Secrets | A failure | | --- | --- | --- | --- | | `repos..setup` | Once per unit, before the first agent; not for later steps, nor on an untrusted author's PR | Its repo's `secrets`; none on someone else's PR or an awaywork branch a rule took up | Non-zero, over 15 minutes or the window's end skips the unit, the tail in the report | | `sources..context.command` | Per item, before setup; not on a dry run | Its own `secrets` | Logged; the unit runs without `extra/`. Over two minutes, the window's end, over 1 MB, or a symlink or non-file in its output count as failures | | `repos..test` | Never by awaywork: the agent runs it if it wants | The agent's: its provider's and its rule's MCP servers' | Up to the agent | | `repos..verify` | Once per chain that ends with commits, before publishing; not for `report` | None | Its tail in the PR body or push summary; a `pr` PR opens as a draft; still published | | `when.command` | Before a step's agent; not on an untrusted author's PR | None | The step is passed over; over 15 minutes or the window's end ends the chain, which publishes | | `until.command` | After every loop step; not on an untrusted author's PR, where the loop ends | None | The loop goes on; uncommitted changes fail it too; the window's end ends the chain; a loop ending on a failure puts the tail in the PR body | | `gates..command` | Once per chain with commits, after `verify`; not for `report` or `pending_review`, nor a dry run. A plan or report rule's own gate after its step. Each with `$AWAYWORK_OUT_DIR` | None | The gate fires: stdout in the decision card, a `pr` PR opens as a draft, a plan or report waits. So does a command that can't run, times out, hits the window's end or isn't run on an untrusted PR ([Gates](../workflows/gates.md)) | `verify`, `when` and `until` share results: one command on one commit runs once; a gate always runs afresh. All four get the chain's start commit as `$AWAYWORK_FROM`, and their git ignores replace refs; a gate also gets `$AWAYWORK_OUT_DIR`, a read-only copy of the steps' `.away/out/`. Only a gate's git reads attributes from the start commit; the others keep the worktree's, so an eol or LFS rule the chain adds doesn't trip your `verify`. More in [Loops](./chains.md#loops). ## Branch names `branch` is a template, set globally, per repo or per rule; the most specific wins: ```yaml queue: - { skill: fix, where: { type: pr, mine: true, checks: failing }, branch: "fix/{title}" } ``` It needs `{key}` or `{title}` (a slug of at most 40 characters), else every unit would share it. Optional `{type}` is the conventional-commit type of the chain's first committing skill ([`metadata.type`](../skills.md#write-your-own)). | Item | `{key}` | | ---------------------------------- | ------------- | | GitHub PR or issue `acme/api#12` | `acme-api-12` | | Asana task with custom ID `ENG-12` | `ENG-12` | | Linear or Jira issue `ENG-12` | `ENG-12` | | Task `api` of a plan on `ENG-12` | `ENG-12-api` | The name is cosmetic, since awaywork finds its PRs by their footer, except for Jira, which links a PR only by the key in its branch or title: keep `{key}` in one of them. awaywork pushes only branches that don't exist yet, but for a [`push`](./chains.md#push) rule and its own draft it [continues](../workflows/questions.md#continue). When the name is taken: - The unit takes the first free `-2` to `-10`, for every step of its chain. - Unless awaywork tried there: a PR on that branch, open or closed unmerged, whose footer names one of the unit's items skips it as `awaywork tried this in ; delete to run it again` (that record is on GitHub, so it holds on a runner without [attempts](../how-it-works.md#units)), unless it's the draft the unit [goes on](../workflows/questions.md#continue) with. A merged one starts a new round with a suffix. - A branch with no PR whose tip has the `Awaywork:` trailer is a push whose PR failed: skipped until you delete it. Without the trailer it's your work, passed over for a suffix. - A [plan's task](../workflows/tasks.md) takes no suffix, since its PR is found by its branch: skipped until you delete it. So a `planned` rule's template renders from `{key}` alone (`{title}` or `{type}` is a config error, as a re-plan may retitle), and every `planned` rule of a source sets the same `branch`, or none does. ## Linking PRs Trackers don't say which PR is a task's, so awaywork looks through your open PRs: - Its own PR is linked by the items its footer names, never by names, so its PR for ENG-13 mentioning ENG-12 isn't ENG-12's. A footer naming no items counts as none. - Yours is linked when its branch or title holds the key as a whole word, any case, or its body holds the source's `link` line. A bare key in the body doesn't count. `link` is a line in the tracker's words, from the item's `{key}`, `{id}` and `{url}`, which awaywork also writes into its drafts: | Source | Default `link` | Why | | --- | --- | --- | | `linear` | `Part of {key}` | Non-closing: merging leaves the issue to your workflow | | `asana` | `{url}` | Asana links a PR whose description holds the task URL | | `jira` | none | Jira links on the branch or title only | | custom | none | Whatever your tracker reads, e.g. `Refs {key}` | ```yaml sources: linear: { link: "Closes {key}" } # merging closes the issue ``` # MCP servers Declare servers under `mcp`; rules and steps name the ones they get: ```yaml mcp: sentry: # HTTP url: https://mcp.sentry.dev/mcp headers: { Authorization: "Bearer ${SENTRY_TOKEN}" } secrets: [SENTRY_TOKEN] db: # stdio command: npx args: [-y, some-postgres-mcp] env: { READ_ONLY: "1" } secrets: [DATABASE_URL] # in the server's env as DATABASE_URL tools: [query] # default: all the server's tools queue: - skill: implement where: { type: issue, label: bug } mcp: [sentry] then: - { skill: review, mcp: [db] } ``` The shape is a subset of Claude Code's `.mcp.json`, translated per agent. A `then` step inherits its rule's servers unless it names its own; `mcp: []` gives none. Details: - A stdio server gets its `secrets` in its env; an HTTP one in headers, as `${NAME}` or `Bearer ${NAME}`. Never in `command`, `args` or `url` (the process list shows them) or `env` (Codex takes literals only). - `GH_`, `GITHUB_` and `TWG_` names are reserved: workers never get forge logins, and Actions refuses `GITHUB_` secrets. - `tools` limits what the agent may call. Only units naming a server get its secrets; `schedule` maps them into Actions, and `doctor` shows whether each is set. - Servers start for your own PRs, issues and tasks, never on someone else's PR or an issue it closes, nor with a secret unset. - Only token-based servers work unattended; workers never get your claude.ai connectors. Its secret is one the agent can read, its tools ones injected text can call: [Safety](../safety/index.md#mcp-servers). # Workflows Where a run stops for a person, and how the work goes on from your answer: - [Questions](./questions.md#questions): a step needs your answer, so it asks on your item. - [Continue](./questions.md#continue): work too big for one run goes on in the next, on the same draft. - [Gates](./gates.md): a change that needs a human (a migration, a new route, a bumped dependency) gets a decision card on the PR. - [Plans](./plans.md): a feature across a few PRs or repos is split into tasks you approve first. - [Posts that wait](./posts.md#posts-that-wait): a report you read before the next stage starts. - [Threads](./posts.md#threads): the card can't take awaywork's posts, so they go to a GitHub issue. - [Store](./store.md): big plans, contracts and screenshots live outside the post and [come back](./store.md#files-that-come-back) to the next step. - [Proving a change](./proving.md): a skill of your own shows the change does what the card asks, and gates check what it left. awaywork keeps none of this in its state dir: every wait is read off the item, its PR or its thread, so a fresh runner still waits and goes on. A feature card under a `deliver: plan` rule, with an `implement` rule and a review-and-fix [loop](../config/chains.md#loops) taking its tasks, stops for you at the plan, at any questions, and at the PR. Your reply is what moves it on: ```mermaid flowchart TD card["Your feature card"] --> plan["plan step writes the tasks"] plan --> posted["Plan posted on your card"] posted --> waits{"Waits? By default,
and whenever a gate fired"} waits -- "no: wait: false, gates clear" --> tasks["Tasks fan out, each its own unit"] waits -- yes --> you1(["You reply"]) you1 -- approve --> tasks you1 -- "anything else: feedback" --> plan tasks --> impl["implement"] impl -- asks --> q(["You answer its questions"]) q --> impl impl --> loop["review ⇄ fix until review approves or max rounds"] loop --> pr["Draft PR, a decision card on top if a gate fired"] pr --> you2(["You review and merge"]) ``` # Questions and continue ## Questions A step that can't go on without an answer, like `implement` on a vague issue, asks on your item: 1. It writes its questions to `.away/out/questions.md` and `ask` to `.away/out/status`, and the chain ends. 2. awaywork posts them as one comment, marked `— awaywork`, on the unit's item from the rule's own tracker (the one [`on_done`](../config/queue-rules.md#on-done) would move), when that item is yours: your PR, an issue assigned to you, a tracker item. 3. The unit is skipped as **waiting for your answer**, under Needs you as `answer question`. 4. A reply ends the wait, and the next run takes the unit with the same rule: within minutes on a [`run: soon`](../config/queue-rules.md#run-soon) rule. The model writes the questions, awaywork the last two paragraphs. On GitHub it shows up as you: ```text workflows-questions octocat commented Before I write code for "Add rate limits": 1. Per user or per API key? The issue says "per client", and src/auth.ts has both. 2. Should a burst over the limit get a 429, or wait for the bucket to refill? Reply here to answer: awaywork's next run takes this up again with your reply. — awaywork ``` The result reads `asked questions`, the report lists them under Questions, and `awaywork run` prints `questions posted on `, the comment's URL on GitHub, the item's elsewhere. The next run's `item.md` has the comment headed `awaywork's questions`, replies after it; a step that still can't go on asks again. Details: - awaywork's own comment bumping `updated` isn't a change. - Any later comment the agent sees ends it, yours or a teammate's: on GitHub an owner's, member's or collaborator's; on a tracker, any comment on an item assigned to you. - Bots don't (GitHub's bots, Jira's and Linear's apps, Linear's integrations): they show in `item.md` but the unit keeps waiting. Asana and custom sources don't mark bots, so there an integration's comment ends it. - A comment is awaywork's only when its `— awaywork` line comes from your account, so a pasted one doesn't hold your item ([Safety](../safety/publishes.md)). - On an item that isn't yours, a source that can't write (a custom source without `write`) on a rule without a [thread](./posts.md#threads), or a failed post, the questions stay in the morning report with the reason; the unit is still done. - A step that committed and asks opens a draft (`draft: asked questions`) headed `Status: ask, questions on `, `status=ask` in the footer, as for [`continue`](#continue); under `push` the commits go onto your PR. Once answered, the next run goes on with that draft, `run: soon` included. - With no status file awaywork guesses as before: a step that committed went on, so `questions.md` stays in `out/`, unposted. A word other than `done`, `ask` or `continue`, or `ask` without `questions.md`, reads as no file, with a line in the run's log. - A chain that asked doesn't run its rule's `on_done`. ## Continue You get one draft PR that grows over several window runs, `Status: continue (1 of 3)` on top. Under `deliver: pr` it turns ready for review once done; mark it ready yourself to take it over: a ready PR, or a draft with no status in its footer, waits for review like any awaywork PR. A step commits what's solid and writes `continue` to `.away/out/status`. The chain stops there, so no review or fix runs on half-done work, and publishes what it has. Under `draft_pr` or `pr` that's a draft (`draft: the agent will continue`), the count in its footer: ```text workflows-continue-draft Export screen Status: continue (1 of 3): awaywork's next window run goes on here [...] --- Opened by awaywork (run `20261026-232730`): a coding agent worked on this unattended. Review it like any other contribution. ``` The next run that would take the item anyway (window, scheduled or manual, never `run: soon`) goes on with the draft. Its commits fast-forward onto it, no new PR or suffixed branch, and the new `pr.md` replaces title and body: ```text workflows-continue-plan Units 1 item rule skill provider deliver title 1 acme/api#1/app #2 soon implement (built-in) claude pr Export screen in acme/web goes on with its draft https://github.com/acme/web/pull/13, which said continue (1 of 3) ``` Once the agent says `done` (or writes no status file) and `verify`, the gates and the loops pass on every commit of the draft, a `deliver: pr` draft turns ready for review; a `draft_pr` stays a draft, its status line gone. The agent's word can only hold a PR back. Details: - awaywork reads the footer back: status, count, the head it pushed, and the commit the draft's commits start after. The visible line is for you; the agent could write a `Status:` line itself, but not after the footer. - The run that goes on clones the draft's branch, its prompt saying so, with the last report (or the draft's body, if no run of it is kept on the machine) in `.away/context/previous/report.md`. - A chain that committed nothing has no draft, so its questions are posted. - The footer still holds the draft's [dependents](./tasks.md) back. - A `run: soon` run skips a `continue` as `goes on from in the next window run`. - **A report** that says `continue` posts its result where results go (a [report task](./tasks.md#report-tasks)'s, `on_done: { comment: result }`'s) with `Status: continue (1 of 3)`, and runs again from it as `previous/report.md`, up to the same cap. Clear a capped count with `awaywork skip`, or by deleting awaywork's `continue` results on the item. Otherwise it only reports. - A `continue` with nowhere to keep its count (no commits on a new branch, or a `push`, `pending_review` or `plan` rule) reads as no status file, with a log line. - A chain that says `continue` skips its rule's `on_done` move: the unit isn't done. ### When it won't go on awaywork goes on only with its own GitHub draft: head where it left it, opened by you, body last edited by you, under 3 continues. Otherwise the report says `: finish by hand`, the draft opens without the status line, its questions stay in the report, and the next plan skips the unit: ```text workflows-finish-by-hand Needs you 1 acme/web #7 Export screen finish by hand awaywork's draft says continue, but it moved from 1f8a231,… ``` The whys: `awaywork goes on with its own draft on GitHub only`, `someone else opened or edited it last`, `it moved from , where awaywork left it`, `it continued 3 times`. # Gates A gate checks what a unit leaves, its commits and the files its steps wrote, and fires when a human should look first. Where it fires decides what happens: | The unit leaves | A fired gate | | --- | --- | | A PR (`draft_pr`, `pr`) | Puts a [decision card](#the-decision-card) on top of the PR; a `deliver: pr` PR stays a draft | | A `push` onto your PR | Still pushes, the card atop the push's summary in the report | | A plan or report | Makes the [post wait](./posts.md#posts-that-wait) for your approve, the card on top | Gates go at three levels: top-level and per repo, which run on every chain that publishes commits, and on [a rule](#gates-of-a-rule-s-own). Each gate has a name and one of: - `changed`: git globs from the repo root, firing when the chain's commits touch a match; - `command`: a script, firing when it exits non-zero, its stdout the gate's line; - `skill`: a [judge](#judges). ```yaml gates: schema: { changed: ["**/migrations/**", "**/*.prisma"] } routes: { command: scripts/route-diff.sh } # non-zero exit = fires, stdout = the line deps: { changed: [package.json, pnpm-lock.yaml] } risk: { skill: judge } # a model reads the chain's commits and may fire it repos: acme/backend: gates: schema: { changed: ["src/db/schema/**"] } # joins the top-level schema globs queues: { changed: ["src/jobs/**"] } # only this repo has it ``` Top-level gates apply to every repo; a repo's `gates` only add. A gate both define fires on either's globs, and if both have a `command` or `skill`, both run and either fires it. A repo can't turn off or loosen a top-level gate. A command diffs from `$AWAYWORK_FROM`: the chain's start commit, or git's automatic merge on a [`sync`](../config/chains.md#conflicts) a model resolved. Never from a branch like `origin/main`: the clone's refs are the agent's to move. It also gets `$AWAYWORK_OUT_DIR`, a read-only copy of what the steps left in `.away/out/`: the PR body, a screenshot a step saved, any other file. ```sh #!/bin/sh # scripts/route-diff.sh: fires when the chain adds a route routes=$(git diff --text "$AWAYWORK_FROM" HEAD -- src/routes | grep -E '^\+ *router\.(get|post)') [ -z "$routes" ] || { echo "$routes"; exit 1; } ``` ## The decision card Gates run on every chain that publishes commits, after its steps and `verify`, judges last, on the chain's own commits. A fired gate puts the card first in the PR body, under the title, above the agent's text and awaywork's chain notes: ```markdown workflows-decision-card ## Needs a decision ### Gates (from scripts) - schema: migrations/20261023_add_vat_mode.ts - routes: + GET /exports/vat + POST /exports/vat/retry ### Judge - risk: VAT rounding moved from the invoice total to each line → totals change on open invoices --- ``` With none fired, that spot holds `Gates: none fired (schema, routes, deps)` above the same rule. So with gates, awaywork's section comes first and the agent's text sits below the rule, a made-up card included; that's all it guarantees. No gates, no section. A `changed` line lists the first three matching paths and how many more; a `command` line is its stdout on one line, cut at 300 characters, or why it failed if it printed nothing. By `deliver`: - `pr` opens as a draft, the report saying `draft: gate schema fired` with the card's lines under the unit. - `draft_pr` was a draft anyway: only the card is new. - `push` still pushes. Your PR's body is yours, so the card tops the push's summary in the morning report: `1 commit pushed onto feat/vat-export, gate schema fired, report below`. - `report`, `plan` and `pending_review` commit nothing, so these gates don't run; a plan or report rule takes [gates of its own](#gates-of-a-rule-s-own). ## Gates fail closed A command that can't run, times out, is stopped or isn't run on an untrusted author's PR fires its gate with the reason: ``- routes: `scripts/route-diff.sh` not checked: timed out after 15 min``. The agent could rewrite the script that judges it, so right before a command runs, awaywork compares each file it names with that path at the clone's start commit, before `setup` or any agent (on an awaywork PR a rule took up, where that PR left the default branch). A file that differs, is new or is gone fires the gate unrun (``- routes: `scripts/route-diff.sh` changed, not run``), committed, uncommitted, hidden from `git status` or changed by `verify` alike. A `changed` gate is the one to trust: awaywork computes it from the commits it pushes ([Safety](../safety/publishes.md)). Details: - The command must name its script by repo-root path, as a word of its own: `bash ./scripts/route-diff.sh` or `(scripts/route-diff.sh) && echo ok` count, `cd scripts && sh route-diff.sh` doesn't. - A named file missing at the chain's start always fires it, so don't name one the command writes. - Only named files are compared, and the command runs repo code the agent may have changed: a sourced helper, code it calls, a module loaded from beside it. `python3 scripts/gate.py` imports an agent-committed `scripts/json.py` before the real `json`, so run Python with `-I`; Node looks for `node_modules` up from the script the same way. - A gate can't change `.away/out/`: once the gates ran, awaywork puts back what the steps left, as plain files, so a link or an executable bit in it is dropped. - What can't blank the diff: replace refs (the command's git ignores them); attributes like `* -diff`, which make it `Binary files … differ` (awaywork deletes `.git/info/attributes` first, and git reads `.gitattributes` files, committed or not, from the start commit, not the worktree; older git ignores that, hence `--text`); the steps' git config, like `diff.external` or a textconv driver (the command's git reads a fresh global config awaywork writes, with your identity and git-lfs's filter, no attributes file). ## Judges A `skill` gate is a judge: a model that reads the chain's commits and fires when it finds something a human must decide. After the scripts, awaywork runs that skill in the chain's clone as one more model run, routed on the rule's providers and models like a step, told which commit to diff from, where the read-only copy of the steps' `.away/out/` is, and to change nothing. Writing `fires` to `.away/out/verdict` fires the gate, the first ten lines of its `report.md` going into the card's Judge part. The built-in `judge` looks for schema and data changes, public routes and API contracts, money and rounding, auth, infra, performance-sensitive paths, dependency changes, anything irreversible. Name your own skill to judge by your rules: yours or a built-in, never the repo's. A gate's `instructions`, files relative to the config, reach its judge's prompt (`risk: { skill: judge, instructions: [judges/money.md] }`); the rule's and repo's don't, being written for the agent it judges. Details: - Any other verdict, or none, adds nothing. Scripts set the minimum; a judge only adds, and can't clear a script's gate, even one with its name. - It reads what the agent wrote, `// reviewed: safe` comments included, so it can be fooled, and then it just doesn't fire. - It can't change what's published: a commit, moved HEAD, uncommitted change, changed `.away/out/` file or agent config it leaves (`.claude/settings.local.json` too, though git ignores it) is put back, and the gate fires with `the judge changed the clone; awaywork dropped its changes`. A worker process that outlived the watchdog could still change a file after that. - It fails closed: a judge that errors, times out, is stopped or can't run fires with `judge didn't run: `, so a run out of quota drafts its `deliver: pr` PR. - Each judge is one more model run per published chain, on the steps' quota, logged as `acme-backend-412: gate risk, judge on claude`, its own log beside what the chain published (`out/judge-risk-judge.log`). ## Gates of a rule's own A rule takes `gates` of its own, for a check only its units need. On a rule that opens a PR or pushes, a `planned` one included, they run with the top-level and repo gates, on top of them, and one that fires does what theirs does: ```yaml queue: - skill: implement where: { labels: ui } deliver: pr gates: proof: { command: scripts/has-screenshot.sh } # reads $AWAYWORK_OUT_DIR/screenshot.png copy: { changed: ["src/locales/**"] } ``` Like the others, they run only when the chain has commits: one that left none reports, ungated. The exception is a chain that left no commits and has `on_done` to write, say a `review` that approves, so the `fix` after it is passed over: the rule's own gates run first, on `$AWAYWORK_OUT_DIR` alone (no `$AWAYWORK_FROM`, and `changed` matches nothing), and one that fires holds `on_done`'s comment and move back, its line in the unit's report. The unit is still handled: nothing retries it and `awaywork approve` has nothing to approve, so the comment and the move are yours to make, or change the item to run it again. Top-level and repo gates don't run there. A rule whose skills all report, as `review` does, could never run them, so its items are skipped with `set deliver: report (review commits nothing, so its gates would never run)`, and `awaywork doctor` warns about the rule. A `deliver: plan` or `report` rule's own gates are the only ones it has (top-level and repo ones never run there: no diff). They run on what the step left, and one that fires makes the [post wait](./posts.md#posts-that-wait) for your approve, the card on top: ```yaml queue: - skill: plan deliver: plan wait: false # goes ahead unless a gate fires gates: product: { skill: judge, instructions: [judges/product.md] } # your rules, from the config only size: { command: scripts/plan-size.sh } # reads $AWAYWORK_OUT_DIR/plan.yml ``` - A command gets `$AWAYWORK_OUT_DIR`, a read-only copy of the step's `.away/out/`: `report.md`, the post's text, a plan's `plan.yml`, any other file it wrote. No `$AWAYWORK_FROM`: nothing was committed. - A judge gets the same copy, told to judge those files against the item, not a diff. The built-in `judge` looks for product calls: acceptance criteria that can go two ways, UX or copy, scope the card doesn't settle, data kept or shown to someone new, a contract another team owns. - The post is what the step left: awaywork restores `.away/out/` before posting, and a judge that changed it fires. - None fired: `Gates: none fired (product, size)` tops the post, which under `wait: false` goes ahead, its mark line naming the gates that ran clear. - On a `planned` rule that reports, one that fires makes the [task's result wait](./tasks.md#a-gate-on-a-report-task), and holds the card's other tasks until you answer. - A rule gate takes no name a top-level or repo gate has, and on a plan or report rule no `changed`: there's no diff. A `pending_review` rule takes no gates. # Plans A feature spanning a few PRs doesn't fit one unit. Give its card a `deliver: plan` rule and its step splits the work into tasks you approve first: ```yaml queue: - skill: plan where: { label: awaywork-feature } deliver: plan on_done: { move: Plan review } - skill: implement where: { planned: true } deliver: pr ``` The second rule takes the [tasks](./tasks.md) once you approve. The plan rule's steps all report, and the prompt asks for `.away/out/plan.yml` too: the built-in `plan` skill writes it, any skill can. ## `plan.yml` `tasks:` holds up to the plan rule's `plan_tasks` tasks (8 by default) when the rule writes it; a posted plan isn't counted again. Each task has: - `id`: lowercase letters, digits and dashes; - `title`, and optionally a `description`; - `skill`: one a `planned` rule takes, `implement` when unset; - `repo`: the card's when unset, or another the config names ([Across repos](./tasks.md#across-repos)); - `ships`: files its PR will hold that tasks in other repos need; - `after`: task ids mapped to `merged`, `opened` ([The feature block](./tasks.md#the-feature-block)) or a check's name ([Waiting on a check](./tasks.md#waiting-on-a-check)). An optional `contract:` names one file the step wrote under `.away/out/`, like an OpenAPI diff or a shared schema, posted with the plan. The prompt lists the skills `planned` rules take, marking `(reports)` those whose rule opens no PR (a reporting skill like `review`, or an all-report chain): they make [report tasks](./tasks.md#report-tasks). awaywork checks the file in plain code: - ids unique and not [retired](./tasks.md#task-details), under the cap; - every skill taken by a `planned` rule, no `after` naming a report task; - every repo one the config names, `ships` paths inside the repo; - every `after` naming another task, no cycles, a task's `opened` and check tasks in its repo on one chain; - a `contract` under `.away/out/`, no unknown key. ## The plan comment awaywork posts report, contract and file as one comment on your item, where [questions](./questions.md#questions) would go. The model writes report and file; awaywork adds the fences, a [contract](./tasks.md#across-repos)'s heading, and the last two paragraphs: ````text workflows-plan-comment VAT export lands in two PRs: the endpoint first, then the button that calls it. - `api`: `GET /exports/vat` in `src/routes/exports.ts`, paged like `/exports/sales`, with a test next to `sales.test.ts`. - `web`: an Export button on the reports page, behind the `vat_export` flag; it waits for the endpoint to merge. Open question: should the export include voided invoices? I left them out. ```yaml tasks: - id: api title: VAT export endpoint description: Add GET /exports/vat to src/routes/exports.ts, paged like /exports/sales. - id: web title: Export button description: Add an Export button to the reports page behind the vat_export flag. after: { api: merged } ``` To approve, reply with approve as the first line; the lines below it reach the tasks as notes. Any other reply is feedback: awaywork plans again with it. — awaywork ```` The run says where it went, and [`on_done`](../config/queue-rules.md#on-done) fires once it's posted: ```text workflows-plan-posted 1 ENG-2101 plan done report VAT export lands in two PRs: the endpoint first, then the button that calls it. plan posted on https://acme.atlassian.net/browse/ENG-2101, tasks api, web; it waits for your approve on_done moved ENG-2101 to "Plan review" ``` Until you answer, the card is skipped as **waiting for your approval**, under Needs you as `approve`. ## Your answer - **Approve:** reply from your own account with `approve` as the first line, any case; lines below are notes for the tasks. `awaywork approve [notes]`, or `a` in the report, posts it for you, marked like any awaywork comment, through the same ownership check, and refuses a plan you already answered. The next run [fans it out](./tasks.md): ```text workflows-approve $ awaywork approve tracker:ENG-2101 Leave voided invoices out. plan approved: replied approve, with your notes, on https://acme.atlassian.net/browse/ENG-2101 ``` - **Anything else of yours is feedback,** `approve if web waits` included. The next run plans again with the whole thread in `item.md` (the plan headed `awaywork's plan`) and posts a new plan, which waits for a fresh approval whatever `wait` says. A re-plan that isn't posted is skipped as unchanged until the card changes. - **Only you answer,** with your first reply after the plan. A teammate's, code owner's or bot's comment is context in `item.md`, and your later comments change nothing. Edit your reply to change your mind before the next run reads it; one someone else edited isn't yours anymore. - **`wait: false`** posts the plan and goes on, its mark line saying `— awaywork · went ahead` and that a reply changes nothing. It counts as its own answer; the next run fans it out with no notes. Its [gates](./gates.md#gates-of-a-rule-s-own) still run, and one that fires makes it wait, the card on top. ## Details - The plan is awaywork's newest plan comment, read off the card every run. GitHub reads the whole thread, past its last 50 comments. - Edit the plan comment to fix a task, and the next run checks your edit; one that breaks it skips the card as **plan refused** with the reason, under Needs you. A plan comment someone else edited isn't awaywork's anymore ([Safety](../safety/publishes.md)), so the card gets a new plan. - A plan isn't posted when it fails its checks (in the file, or read back from the comment), `plan.yml` or the contract is missing, or the comment with the contract passes 30 KB (a [lean](./store.md#files-that-come-back) plan pins them, and isn't posted when the [store](./store.md) fails to put either or skips it: `plan.yml isn't in the store`). The reason stays in the morning report (`plan not posted: plan.yml: task id "api" is used twice`) and `on_done` doesn't fire. Nor on an item that isn't yours, a source that can't write on a rule without a [thread](./posts.md#threads), or a failed post. A step that asks [questions](./questions.md#questions) posts those instead. - A `deliver: plan` rule that matches only merged or closed items, names a `source` that can't write without [`thread: github`](./posts.md#threads), or has no `planned` rule for its tasks is a config error. # Tasks Approve, and each task becomes a child item keyed `ENG-2101/api`, down the queue like any item, on its own branch `away/ENG-2101-api`, opening its own PR. The card makes no unit of its own anymore. - A task matches only rules whose top-level `where` names `planned`, the first whose `skill` is the task's; such a rule takes nothing else. Caps, attempts, `skip`, gates and chains work on tasks as on anything. - Tasks without `after` run in parallel; `after` orders them, and a card's tasks run back to back, as one [block](#the-feature-block). - A task carries the card's source, URL, owner, fields and whole thread, and its repo unless the plan names another, so `where: { planned: true, labels: backend }` works. Its `item.md` is awaywork's own, the task first: ```markdown workflows-task-item # ENG-2101/api: VAT export endpoint https://acme.atlassian.net/browse/ENG-2101 Add GET /exports/vat to src/routes/exports.ts, paged like /exports/sales. ## The plan This is task `api` of the plan you approved on ENG-2101. [...] Your notes on approving it: Leave voided invoices out. ## The card: ENG-2101, Export VAT report [...] ``` Every run reads the plan off the card and each task's PR off the forge (the PR on the task's branch whose footer names the task, open, merged or closed). `awaywork plan --skipped` shows the card, run by run: | What happened | The card | Its tasks | | --- | --- | --- | | You approved | `fanned out: api no PR yet, web waits for api` | `api` runs and opens its PR; `web` is skipped as `waiting for task api's PR to merge` | | `api`'s PR is open | `fanned out: api PR open, web waits for api` | `api` waits for your review, under Needs you, like any awaywork PR | | `api` said `continue` | `fanned out: api draft says continue, web waits for api` | the next window run [goes on](./questions.md#continue) with `api`'s draft | | `api` merged | `fanned out: api merged, web no PR yet` | `web` runs, cut from the default branch, which has `api` in it | | `web`'s PR closed unmerged | plans again | none run until you approve the new plan | | Every task done | `all tasks done: close or move the card`, under Needs you | none: each merged, or a [report task](#report-tasks) posted its `done` result | A task's PR says `Part of `, never closes the card, and its footer names only the task, so the card never links its tasks' PRs or folds into one. ## The plan sticks to the card Once awaywork's plan comment is on an open card of yours, the card goes to the `deliver: plan` rule it matches, else the first for its source whatever its `where` says now, and no other rule takes it whole. `plan --rules` lists it there, with `(plan)` after its key when the `where` no longer holds. So a `planned` rule's `on_done: { move: In Progress }` can move the card off a plan rule on `status: To Do` and its tasks still run, where a catch-all `implement` lower down would otherwise take the card as one unit. - It leaves the plan flow when closed or reassigned (read every run); `awaywork skip` on the card holds it and all its tasks. - Someone else's edit to the card's only plan comment makes it not awaywork's: back to ordinary matching. - With no `deliver: plan` rule left for its source, a catch-all may take the card whole, next to its open task PRs. ## Task details - **Questions from a task** go on the card, headed `Questions from task api` (`awaywork's questions from task api` in `item.md`), through the usual ownership check. The run stops the card's [block](#the-feature-block) there: no task of the card runs after it that run, skipped as `api's questions wait for your answer`, so one post of a card waits for you at a time. While they wait, later runs skip every task of the card as `waiting for your answer to task api's questions`. The first comment after them that's neither a bot's nor awaywork's answers them. - **A task's PR is its result:** it posts nothing else on the card. A [report task](#report-tasks) posts its report there instead. - **Writes go to the card:** a `planned` rule's `on_done` moves the card; a task writes nowhere else. Reassign the card and nothing fans out or writes (`plan approved, but the card isn't yours: its tasks don't run`). - **A task PR closed unmerged** after the plan was posted re-plans the card, though the card didn't change. Its id is retired: the prompt names it and its PR, with the tasks whose PR is open or merged, and a plan reusing it isn't posted. The new plan needs a fresh approval, even under `wait: false`; a task that keeps its id keeps its PR. - **A task's branch is its name for good.** When something else holds `away/ENG-2101-api`, the task is skipped with `delete it to run this task`, never moved to `-2`, which its PR lookup wouldn't find. - A [`branch`](../config/repos.md#branch-names) template renders from the task's key alone, so a re-plan that retitles a task keeps its PR: `{title}` or `{type}` in a `planned` rule's template, own or inherited, is a config error. A re-plan may hand a task another skill, and so another `planned` rule, so all `planned` rules for a source set the same `branch`, or none does. - A task branch whose PR failed to open still reads `no PR yet` (the PR is the task's state), and each morning report says `away/ENG-2101-api exists on acme/api without a PR; delete it to run again`. - A task takes no approval of its own, the plan's covers it: no `approved`, `awaywork approve tracker:ENG-2101/api` is refused, and a `planned` rule takes no `wait`. The one exception is a report task's result that a [gate of its rule](#a-gate-on-a-report-task) fired on: it waits for your reply. - On [Bitbucket](../config/sources.md#bitbucket-and-jira) it works the same, through twg. A declined or superseded PR counts as closed unmerged, when last updated; a stacked task's PR goes into the branch of the one below (no stacks there); a task whose draft says `continue`, or asks, holds its dependents back, but it's yours to [finish](./questions.md#continue). ## Report tasks For [proof of the change](./proving.md), a write-up or a prototype link, give the skill a `planned` rule that reports, and the plan can hand it a task: ```yaml queue: - skill: plan where: { label: awaywork-feature } deliver: plan - skill: implement where: { planned: true } deliver: pr - skill: prove where: { planned: true } deliver: report ``` ```yaml tasks: - { id: app, title: Export screen } - { id: qa, title: Proof, skill: prove, after: { app: opened } } ``` Its result is its report, posted on the card when its chain finishes, as [`on_done: { comment: result }`](../config/queue-rules.md#on-done) would, unasked: `Result of task qa`, the report, `Status: done` (`awaywork's result of task qa` in `item.md`). Like any awaywork comment, it answers nobody's questions, a plan's included, and isn't a change to the card. - It's **done** once a `done` result for it, newer than your approval, is on the card, and no [gate holds it](#a-gate-on-a-report-task): `plan` skips it as `done: its result is on `, and the card reads `qa done`. Until then it runs, failed attempts counting as usual. A re-plan's approval leaves older results behind, so its tasks report again. - A `continue` result isn't done: the task [runs again](./questions.md#continue). - **Nothing can wait on it:** no PR to stack on, check or merge, so a plan whose `after` names one isn't posted. It can wait on others, as `qa` waits for `app`. - A `planned` rule delivers `draft_pr`, `pr` or `report`; anything else is a config error. ### A gate on a report task Give the rule [gates of its own](./gates.md#gates-of-a-rule-s-own) to check what the task left, say that every acceptance criterion has a file: ```yaml queue: - skill: prove where: { planned: true } deliver: report gates: proof: { command: sh scripts/evidence-gate.sh } # reads $AWAYWORK_OUT_DIR ``` One that fires posts the result as a [post that waits](./posts.md#posts-that-wait), the gate's line on top: ```text workflows-task-gate Result of task qa ## Needs a decision ### Gates (from scripts) - proof: no file for criterion 3 --- Two of three criteria recorded against the preview. Status: done To approve, reply with approve as the first line. Any other reply is feedback: awaywork runs this step again with it. — awaywork · waits ``` - **It holds the feature**, as a task's [questions](#task-details) do. The task isn't done: `plan` skips it as **waiting for your approval** (`approve`, `task qa's result`), under Needs you, and every other task of the card as `waiting for your approval of task qa's result on `. The card reads `fanned out: api waits for qa, qa waits for your approval`. - **One post of a card waits at a time.** The run that posts it stops the card's [block](#the-feature-block): no task of the card runs after it that run, skipped as `qa's result waits for your approval`. Results the tasks before it posted stay as they are. - **Your first reply after it decides**, from your own account; a teammate's comment is context. First line `approve`: you accept the gap, and the task is done. `awaywork approve tracker:ENG-2101/qa`, the card's key or `a` posts it. Anything else is feedback, and ends the hold too: the card reads `qa runs again on your feedback`, the task runs again with the thread in `item.md`, ahead of the tasks after it, and the other tasks go on. Its gates run on the new result: if one fires again that run, the tasks after it stop behind it. A [pinned file](./store.md#files-that-come-back) of the post edited in the store is feedback too, whatever you replied, though the card didn't change. - **None fired:** `Gates: none fired (proof)` tops the result, marked `— awaywork · went ahead · gates=proof`, and the task is done as without gates. - A newer plan leaves the result behind: `approve tracker:ENG-2101/qa` is refused as `older than the plan`, and while that plan waits your reply answers the plan. - A `planned` rule's `on_done: { move }` is held with the post, as on [any that waits](./posts.md#posts-that-wait). - All of it is read off the card or its thread every run. ## The feature block `after` says what a task waits for: - **`merged`**: run after run, until that task's PR merges; then it's cut from the default branch, which has it. Each PR lands and is reviewed before the next starts. - **`opened`**: only until that task's PR opens, in the same run. Its clone starts from that task's branch and its PR stacks on that task's, so the whole feature is up for review in the morning, as a stack. A task in [another repo](#across-repos) only waits. - **a check's name**, like `Vercel Preview`: `opened` plus that check passing, which takes a later run ([Waiting on a check](#waiting-on-a-check)). The run that sees a card runs its tasks back to back, as one block, in the order they wait on each other, before the next unit, wherever the first ranks. A task joins once every `merged` task it names merged and every `opened` one has an open or merged PR, or runs earlier in the block. So a block stops only where it needs you: at `merged`, to merge something, or at a task's post that waits, its [questions](#task-details) or a result [a gate fired on](#a-gate-on-a-report-task), to answer it. ```yaml tasks: - id: api title: VAT export endpoint - id: web title: Export button after: { api: opened } ``` The run after `approve`: ```text workflows-stacked Units 2 item rule skill provider deliver title 1 ENG-2101/api #2 implement (built-in) fake pr VAT export endpoint 2 ENG-2101/web #2 implement (built-in) fake pr Export button after api, stacks on its PR Skipped 1: 1 fanned_out ``` `api` opens `away/ENG-2101-api`; `web` is cloned from it and its PR goes into it, the body ending `Stacked on : merge that first.` Once `api`'s PR is open, a later run stacks the same way, and `plan --why ENG-2101/web` shows `after api opened (PR #41 open)` and `stack after api, stacks on #41`. When the task before opens no PR (it failed, asked or committed nothing), those stacked on it are skipped as `api opened no PR`. When it never ran (`setup` failed, Ctrl-C, the window's end, or `run --unit` named only the task after it), they're skipped as `api didn't run`, and those on them as `web didn't run`. | What happened | The card | Its tasks | | --- | --- | --- | | You approved | `fanned out: api no PR yet, web no PR yet` | `api` opens its PR, then `web` runs on `api`'s branch and opens its PR, stacked on `api`'s | | `api` failed | `fanned out: api no PR yet, web no PR yet` | `web` is skipped as `api opened no PR`; both run again next run | | `api` said `continue` | `fanned out: api draft says continue, web no PR yet` | `web` is skipped as `api left a draft that says continue`; the window run that finishes `api` runs `web` right after it, stacked on its PR. A check on `api`'s PR waits as `its draft says continue` | | A cap cut the block after `api` | `fanned out: api PR open, web no PR yet` | the next run stacks `web` on `api`'s open PR: `after api, stacks on #41` | | `api` merged before `web` ran | `fanned out: api merged, web no PR yet` | `web` is cut from the default branch | | Both PRs are open | `fanned out: api PR open, web PR open` | both wait for your review | ### Stacking details - **One chain.** A task's `opened` tasks, check ones too, lie on one chain, each after the one before, and it stacks on the last. With `api` after `{ schema: opened }`, `web` after `{ api: opened, schema: opened }` stacks on `api`, which holds `schema`. Off one chain there's nowhere to stack, so the plan isn't posted: `task web: its opened tasks api, schema aren't on one chain: make each after the one before, e.g. schema after api: opened`. - **A merged PR in the stack:** a task stacks on the first unmerged PR down the stack. With `api`'s PR merged into `schema`'s branch, `web` after `{ api: opened }` stacks on `schema` (`after api, stacks on schema's #10`), cut from the default branch only once the whole stack merged. - **`merged` and `opened` mix:** `merged` says when it can start, `opened` where. `web` after `{ schema: merged, api: opened }` waits as `waiting for task schema's PR to merge`, then stacks on `api`. `merged` means on the default branch: if `api` stacks on `schema` and merges into its branch first, `web` after `{ api: merged }` waits as `waiting for task schema's PR to merge, api's merged into it`. - A task waiting on an `opened` task that doesn't run (waiting for an answer, unchanged, gave up, cut by a cap) is skipped as `waiting for task api's PR to open`. Caps and the window's end can cut a block; the next run carries on from the task with no PR. - **No secrets in a stacked setup.** A stacked clone starts on a branch a worker wrote, so `setup` gets none of the repo's `secrets`, as on any awaywork PR a rule takes up again ([Safety](../safety/index.md#what-the-agent-can-reach)). If `setup` needs one (a registry token for `pnpm install`, say), use `merged`, not `opened`, or every stacked task fails setup. - **Merge the stack bottom-up.** Once `api`'s PR merges and its branch is deleted, GitHub retargets `web`'s to the default branch; awaywork does nothing. After a squash merge `web` still carries `api`'s commits: merging the default branch into it usually resolves cleanly. ## Across repos A task's `repo` can be any repo [`repos`](../config/repos.md#repos-and-instructions) lists, by name or by glob; unset, it's the card's. A card with no repo needs one on every task. The plan step can also write a contract, the interface the tasks agree on, and a task lists in `ships` the files its PR will hold that other repos' tasks need, like the spec a client is generated from: ```yaml contract: .away/out/openapi.yaml tasks: - id: api title: VAT export endpoint ships: [openapi.yaml] - id: web title: Export button repo: acme/web after: { api: opened } ``` The contract goes in the plan comment before `plan.yml`, under a heading naming it, in a fence longer than any backtick run in it (so a Markdown contract with fences survives): ````text workflows-contract-plan VAT export spans two repos: the endpoint in acme/backend, then the button in acme/web. ### Contract: openapi.yaml ```yaml openapi: 3.1.0 [...] ``` ```yaml contract: .away/out/openapi.yaml [...] ``` ```` You approve it with the plan. Every task finds it in `.away/context//contract/openapi.yaml`, beside `item.md`, its prompt saying it was approved. Each task is a unit in its own repo: clone, `setup`, `secrets`, `test`, branch template, gates and `units_per_repo` are that repo's, and its PR opens there. Questions and `on_done` still go to the card. `opened` across repos only waits: with no branch to stack on, `web` is cut from `acme/web`'s default branch, which no agent wrote, so its `setup` gets the repo's `secrets`. It joins the block once `api` opened its PR earlier in the run, else it's skipped as `api opened no PR`: ```text workflows-across-repos Units 2 item rule skill provider deliver title 1 ENG-2101/api #2 implement (built-in) fake pr VAT export endpoint 2 ENG-2101/web #2 implement (built-in) fake pr Export button in acme/web waits for api's PR in acme/backend ``` `web` gets what `api` ships in `.away/context//shipped/api/openapi.yaml`, named in its prompt, read as `web` is about to run from `api`'s PR head, open or merged. A missing file skips `web` as `api's PR has no openapi.yaml`, every run until it's there, counting no attempt. Details: - Any other repo isn't posted: `task x: repo evil/x isn't the card's (acme/backend) or one the config names`. Case counts, since a repo's settings and caps go by its exact name: `Acme/Web isn't acme/* as configured`. - The contract counts toward the 30 KB unless the plan is [lean](./store.md#files-that-come-back), must read back as the file it was, and your edits to it are checked on every read. One the file names but the step didn't write isn't posted; an edit that drops it from the comment refuses the plan. - The one-chain rule counts only `opened` tasks in the task's own repo: with `ui` in `acme/web` too, `web` after `{ api: opened, ui: opened }` stacks on `ui` and waits for `api`. - A task in the same repo gets no `shipped/`: a stacked one has the files in its branch, a `merged` one is cut from a default branch holding them. - On Bitbucket a shipped file is read through twg (`twg bb repo file`), at the PR's branch or, once merged, its head commit. ## Waiting on a check The button wants the endpoint deployed to try against. Vercel, Netlify and Expo report previews as checks on the PR, so name the check in `after`, exactly as the PR shows it: ```yaml tasks: - id: api title: VAT export endpoint ships: [openapi.yaml] - id: web title: Export button repo: acme/web after: { api: Vercel Preview } ``` Anything in `after` but `merged` and `opened` is a check's name, matched letter for letter: `opened`, plus that check's latest run or status on the PR's head commit succeeded (on GitHub a neutral or skipped check run passes, as for a required check). A merged PR counts as passed. Until then the task waits: ```text workflows-check-wait $ awaywork plan --skipped Needs you 1 ENG-2101/api: waiting for review: awaywork PR https://github.com/acme/backend/pull/41 is open Skipped 2: 1 fanned_out, 1 waiting_for_task ENG-2101: fanned out: api PR open, web waits for api's check Vercel Preview ENG-2101/web: waiting for api's check Vercel Preview (pending) ``` The check reads `pending`, `failing`, `not reported` (no check by that name: check the spelling) or `no PR yet`. A failing one keeps waiting: a [`fix`](../config/queue-rules.md) rule may fix it, or you re-plan. It never passes in the run that opened the PR, so `web` runs the first run after the preview is up, cut from `acme/web`'s default branch with `api`'s `openapi.yaml`, shown as `after api's check Vercel Preview (passed)`. While the PR is open, its `item.md` plan has the check's link too, `after api's check Vercel Preview (passed, https://…)`, which is whatever reported the check: a skill that opens it names the host it must be on. Details: - In the same repo a check's name stacks like `opened` and counts toward the [one chain](#stacking-details); in another repo it only waits and gets what the task ships. - awaywork reads a PR's checks only when a task names one, so other plans cost no extra calls; a failed read skips the card. `plan --json` has them under `taskPrs.api.checks`, each with its `state` and `url`. - On Bitbucket, checks are the build statuses on the PR's head commit, by name, via `twg bb prs get --statuses`: `SUCCESSFUL` passes, `INPROGRESS` is pending, `FAILED` and `STOPPED` are failing. A name carrying a build number never matches. # Posts that wait and threads ## Posts that wait A `deliver: report` rule with `wait: true` posts its result on the item and waits for your answer there, like a plan; your `approve` hands the item to the next stage. Plans wait by default (`wait: false` lets one go ahead); reports don't. ```yaml queue: - skill: story where: { section: Inbox } deliver: report wait: true # your approve hands it to the next rule on_done: { move: Ready for design } # held until you approve - skill: prototype where: { approved: story } # works on a tracker that can't move cards too deliver: report gates: taste: { skill: judge, instructions: [judges/design.md] } # one that fires holds the post ``` The post is the result as `on_done: { comment: result }` writes it, asked for or not, with how to answer and its state on the mark line: ```text workflows-post-waits Result of story Export VAT as CSV from the reports page. Status: done To approve, reply with approve as the first line. Any other reply is feedback: awaywork runs this step again with it. — awaywork · waits · move=Ready%20for%20design · saw=Inbox ``` - **It holds its rule.** Later runs skip the card as **waiting for your approval** (`approve`, `story's result`), whatever changes, and no rule below takes it. Linked items rank on their own, so a red-CI fix on the linked PR still runs. - **You answer** as on a [plan](./plans.md#your-answer), `awaywork approve ` and `a` included. Feedback reruns the step with the thread in `item.md`, its new post waiting whatever `wait` says. - **Approved, it hands the item on.** `approved` becomes the post's skill, read off the thread, so `where: { approved: story }` works even where awaywork can't move cards; a newer waiting post leaves it on the one before. The posting rule is done with the item; with no rule after it, the item is skipped as `#1 story doesn't run again: you approved its result; to run it again, delete that post on ` (then `awaywork run --unit ` if the card hasn't changed). - **`on_done` waits too.** The post holds its `move` and records the card's status. The first run that sees your `approve` (window, `soon` or manual) makes that move, even if `on_done` changed since, before planning, with no model run: `on_done: moved ENG-2101 to Ready for design, its post approved`. - **Gone ahead.** A report rule with [gates of its own](./gates.md#gates-of-a-rule-s-own) and no `wait` goes ahead when they run clear (`Gates: none fired (taste)` on top, `— awaywork · went ahead · gates=taste` as the mark), moves the card at once and counts as approved. A gate that fires makes it wait, card on top. Details: - A held move happens only while the card is in the status the post saw (on GitHub, the label not on yet) and no newer awaywork post is on the item. A dry run only says it would; the report lists the move, or why it failed, under Held moves. Moving or reassigning the card yourself releases the post. - A report rule that waits, or has gates of its own, needs a source that can write, or a [thread](#threads): naming one that can't is a config error, and `doctor` warns about any it may match. - `wait` on a `pr`, `draft_pr`, `push` or `pending_review` rule is a config error: the PR is the decision. So is `wait` on a `planned` rule: a report task's result waits only when a [gate of its rule fires](./tasks.md#a-gate-on-a-report-task), and it sets no `approved`. The simplest two-stage queue plans the sprint's tasks as reports and implements those you approve: ```yaml queue: - skill: plan source: asana where: { section: Current sprint } deliver: report wait: true # your approve hands it on - skill: implement where: { section: Current sprint, approved: plan } ``` `implement` finds the plan as its earlier report. While its draft is open the task waits for review; once merged, move the task out of the section and neither rule takes it again. ## Threads Your Jira token is `read:jira-work`, or your custom source has no `write`: the card can't take awaywork's posts. `thread: github` on a rule moves the conversation to a GitHub issue awaywork opens in the card's repo (`repo_map`, or a custom item's `repo`), assigned to you, so GitHub pings you: ```yaml queue: - skill: story source: jira # read-only deliver: report wait: true thread: github # posts and replies on an issue in the card's repo - skill: prototype source: jira where: { approved: story } deliver: report thread: github on_done: { comment: result } ``` The rule's first post opens it: ```text workflows-thread CARD-1: Export VAT report awaywork posts here for [CARD-1](https://tracker.example/CARD-1), Export VAT report: its plans, results and questions. Reply here to answer them; the card still decides whether it's yours. ``` - **It's the card's thread now.** Its comments follow the card's in `item.md` and wherever awaywork reads posts, so plans, posts that wait, held moves, [pins](./store.md#files-that-come-back) and questions work there. Every post for the card goes there, other rules' and tasks' too, so keep a card's rules on one thread. A reply counts as a change to the card (awaywork's posts don't). - **Your reply decides:** `approve` on the issue, or `awaywork approve jira:ENG-2101` (or the thread's key, `github:acme/api#57`). A teammate's `approve` is context. Skips point at the thread: `CARD-1: waiting for your approval of story's result on https://github.com/acme/api/issues/212`. - **The card still decides** whether it's yours, every run: reassign it and its thread neither fans out nor takes answers. `on_done` moves stay on the card: one that can't move holds no move, and a plain post's move reports `couldn't move ENG-2101 to "Ready": jira can't write to its items`. - **Its end:** once the card's source says it's done, closed or merged, read back a week, the next run comments and closes the issue (`closed https://github.com/acme/api/issues/212, CARD-1's thread: the card is closed`; a dry run says `would close`). Details: - Each run lists the open issues you opened in the card's repo and takes the one whose marker names the card. The GitHub source leaves issues with the marker out of its items, or a plan rule would take the thread itself. - All tasks merged isn't the end: the card says `all tasks done: close or move the card` until you do, its thread open with it. Close the thread yourself and the next post opens a new one. A thread whose card leaves your items without reading as done (reassigned, moved to another `repo`, closed while no run read back that week) stays open: close it yourself. - `thread: ` names a custom source with `thread: true` instead, a Notion database or a folder of Markdown files, which opens and lists threads itself ([Thread hosts](../config/custom-sources.md#thread-hosts)); that's the one for a Bitbucket config, where `thread: github` is a config error. - A thread goes on the plan's rule, never a `planned` one: tasks post where their plan did. Thread writes use the `gh` login awaywork opens PRs with, never the worker's. - A host that notifies nobody (a Markdown folder) wants a [notify](../results.md#notify) hook, or a plan there waits unseen. # Store A card's thread suits a decision, not a 400-line plan, a contract or three screenshots, and a fresh runner loses whatever stayed in the run dir. `store` is one command awaywork hands each file a post pins: it puts the file anywhere (Notion, a bucket, a folder) and prints a link. ```yaml store: command: node store.mjs # runs in the config file's folder, like a custom source's command secrets: [STORE_TOKEN] # withheld from workers, listed in Actions read: { max_mb: 10, files: ["*.md", "*.yml", "*.png"] } # what comes back; default 10 MB of everything ``` The post gets a short summary and a link per file; the next unit reading the thread gets the files back, on any machine: [Files that come back](#files-that-come-back). The model never sees a URL before awaywork writes it, nor picks where a file goes. A tiny store keeping files in a folder beside it, at `file://` URLs, enough for one machine: ```js // store.mjs: keeps each file awaywork puts in a folder next to it, at a file:// URL. import { randomUUID } from 'node:crypto' import { copyFileSync, mkdirSync, readFileSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' const op = JSON.parse(readFileSync(0, 'utf8')) if (op.op === 'put') { const to = join(dirname(fileURLToPath(import.meta.url)), 'files', randomUUID(), basename(op.path)) mkdirSync(dirname(to), { recursive: true }) copyFileSync(op.path, to) console.log(JSON.stringify({ url: pathToFileURL(to).href })) } else { const path = fileURLToPath(op.url) // A pin line anyone could write names this URL: read only this store's files. if (!path.startsWith(join(dirname(fileURLToPath(import.meta.url)), 'files', '/'))) throw new Error(`not in this store: ${op.url}`) process.stdout.write(readFileSync(path)) } ``` It gets one JSON op on stdin per call, with your environment, like a [custom source](../config/custom-sources.md): - `{"op": "put", "item": "ENG-2101", "unit": "eng-2101", "path": "/…/units/eng-2101/out/plan.yml", "kind": "plan"}` prints `{"url": "…"}`. `path` is awaywork's copy out of the clone, readable during the put. `kind` is `plan`, `contract`, `report`, or the file's name under `.away/out/`, so one script can send recordings to a bucket and plans to Notion. - It may print `{"skip": true}` instead, to keep a file out of the store (a 200 MB trace): the file stays in the run dir, the post goes out without a link to it, and nothing is reported. Any other output is a failed put. - A skip decides what goes to the store, never what is posted. A lean report or plan whose `report.md` is skipped still posts the step's `summary.md`, or with none the report's first five lines, with no link to the report. So a skip keeps no secret off the item. - A lean plan can't be approved or fanned out without its `plan.yml` and contract. Skip either and the plan isn't posted: `plan not posted: plan.yml isn't in the store`. - `{"op": "get", "url": "…"}` prints the bytes exactly as put. A put-only store sets `get: false`, and awaywork reads back with a plain HTTP GET, within the command's 10 minutes. After each put awaywork gets the file back and compares its sha256, which the pin carries. A store that returns other bytes (a CMS adding a newline, Notion round-tripping markdown) would make every post look edited, so that file isn't pinned: `report.md not stored: the store gave back different bytes than awaywork put`. Keep such a page for people, the bytes somewhere that keeps them. The script's job, not awaywork's: - **A URL that lasts.** It's read back days later. An S3 presigned link expires within a week: re-sign in `get` from a key in the URL, or pick a host whose links don't expire. - **Secrets in what leaves.** A log or recording can hold a token the agent saw: redact it before the script stores the file. A skip doesn't keep it off the item, and what `summary.md` says is the skill's job. - **Only its own URLs.** awaywork reads pins only off its own posts and the PRs it opened for you, and on GitHub and Jira skips any someone else edited last. Asana, Linear and custom sources don't say who edited a comment, so a teammate who can edit yours can edit a pin line; a `get` that refuses URLs it didn't print, as above, stops the store fetching what they name. - With `get: false` nothing refuses. A file changed in the store no longer matches its pin and is left out of the step, but a rewritten pin line (a new URL with its own hash) passes, and awaywork fetches it at every `plan`. On those sources it trusts whoever can edit the post. - A file edited after its pin never reaches a step: the edit reruns the step without it. When the store fails: - A failed put leaves the file in the run dir, pins nothing and adds a morning report line (`fail.bin not stored: Command failed with exit code 1: …`, or `printed 11 characters that aren't JSON, not {"url": "…"}`, never quoting the output); post and PR still go out. - A lean report whose own `report.md` failed posts the whole report in place of the summary, other pins kept, and says so; over 30 KB it fails like any result. - A failed get on a post the unit depends on (the one an approval covers, its own when it runs again, a PR's footer) skips the unit as `fix store`, with why, counting no attempt. One on a task's result that waits or went ahead skips every task of its card the same way, a done task's result too: the card's tasks stay held until the store gives the file back. - On any other post (an older plan, a newer report) it costs only that post's files and a morning report line, so an expired `plan.yml` on an old plan stops nothing. ## Files that come back With a [store](./store.md), a plan or report rule's post is lean: the step writes `.away/out/summary.md`, a few lines for the thread (its prompt asks for it), and awaywork posts that, its own facts and a pin per file the step left, the rest in the store: ```text workflows-lean-post Result of story A story in one line. Status: done To approve, reply with approve as the first line. Any other reply is feedback: awaywork runs this step again with it. Pinned mock.png: file:///…/cfg/files/71e96381-7e32-4ee7-9aab-5f803b4de816/mock.png (7 bytes, sha256 2d4566582844690f8634a8b2534ea5221560038c6c0650c99140759bad603ae2) Pinned report.md: file:///…/cfg/files/a63b0dec-63e2-4701-968e-8350ad439bae/report.md (22 bytes, sha256 8ce3d4416e83b1da658e0404e2cf700108a3ed2e43b077fadad9eb5ccaa95c8e) — awaywork · waits ``` A lean plan lists its tasks (`Tasks:`, `- api: VAT export endpoint`, ...) in place of `plan.yml`, which is pinned with its contract and report, so only summary and task list count toward the 30 KB limit. - `post: full` keeps the whole comment, report inline, and pins only the other files. - A step with no `summary.md` gets its report's first five lines, and a morning report line. - Pins sit below everything the model wrote, right above the mark, so a `Pinned` line in a report pins nothing. - A PR step's files, screenshots say, are pinned in the PR body's footer the same way. - Without a store, posts are full and files stay in the run dir, for the [notify](../results.md#notify) hook. ### What comes back Before a unit's worker starts, awaywork gets its items' pinned files into `.away/context//files//`, a folder per post named by its place in the thread (`2-story/`): - the post an approval covers (a task's plan, the result `approved: story` names); - its own newest post when it runs again on feedback or a `continue`, and every newer post; - on a PR awaywork opened for you, its footer's. Also: - A footer file changed since its pin is left out, with a morning report line: nothing reruns on a PR body. A stranger's PR carrying awaywork's marker gets nothing read back. - The worker still holds no token, and its prompt calls those files data, like `item.md`: someone else wrote them. - A lean plan's tasks read its `plan.yml` and contract through `get`; what a task `ships` still comes from its PR's head. - `read.max_mb` caps what one unit reads back, 10 by default; the rest stay links, named in the morning report, and a pin's size means one over the cap isn't fetched. `read.files` narrows by name, `*.md` matching `notes/a.md` too. ### An edit is feedback Every `plan`, run and watch poll reads back, within the cap, the files of the posts that decide a rule (a plan's, a waiting or approved report's, a task's result that waits or went ahead) and checks their hash. A mismatch makes the post stale, which counts as your feedback, whoever edited it: the step runs again, though the card didn't change, with the edited file in context and its path in `files/EDITED` (one per line), which its prompt calls feedback, and posts again. A rule that waits needs a fresh `approve`, so you only approve what awaywork has seen: ```text workflows-edited-post 1 api#206 story done report Full report of story. report.md of awaywork's post of 2026-11-07T19:31:26Z changed in the store since awaywork pinned it result posted on https://github.com/acme/api/issues/206#issuecomment-6712036203; it waits for your approve ``` So edit the plan in Notion freely, then approve what comes back. A full post keeps the card's rule: your edit of the plan comment counts, someone else's voids it. # Proving a change How a change is proven differs per repo: an end-to-end run against the PR's preview, a flow on a simulator, a curl script, a seeded query (Playwright, Maestro and friends). So awaywork ships no skill for it and reads no proof format. You write your repo's skill, or [`/away`](../away.md#the-skill) writes it with you, and the pieces above carry it: a [report task](./tasks.md#report-tasks) that [waits on the preview](./tasks.md#waiting-on-a-check), [gates](./tasks.md#a-gate-on-a-report-task) on what it left, the [store](./store.md) for the files. The example: `acme/web` gets a preview per PR, reported as the `Preview` check that links to it, and its `pnpm smoke ` drives one flow there, saving a screenshot per step. The skill is yours, `skills/prove/SKILL.md` beside your config, never the repo's: ```markdown --- name: prove description: Prove a change against its card's acceptance criteria and leave the files that show it. Use on a task that proves other tasks' work. metadata: deliver: report --- # Prove The unit proves work another step did. The card is in its context: its acceptance criteria are what you prove, not what the code seems to do. 1. List the criteria, one line each, in the card's words. 2. The change runs at the link of the check your task waits on, in `item.md`'s plan: `after app's check Preview (passed, )`. Open it only when its host ends in `.preview.acme.dev`, and open no other host. 3. For each criterion, run `pnpm smoke ` with the flow in `smoke/` that covers it, and copy the screenshots that show it from `smoke/out/` to `.away/out/`. 4. Write `.away/out/evidence.yml`: `criteria:` alone on the first line, then one entry per criterion, in these lines and no others, each text on its one line: criteria: - criterion: A month with no invoices shows "Nothing to export" files: [empty-month.png] - criterion: The CSV's total matches the invoice list's files: [] gap: the preview has no seeded invoices The names are plain paths under `.away/out/` (ASCII letters, digits, `.`, `_`, `-`, `/`), never the step's own files, `report.md` and the manifest among them. 5. In `report.md`, per criterion: the command you ran, what it showed, and the file. A criterion you couldn't prove gets `files: []`, a one-line `gap`, and why. Change no code. A criterion that fails is a finding: report it, with the file that shows it failing. ``` `evidence.yml` is a convention between your skill and your gates, so shape it as you like, the gate script along with it; awaywork never reads it: ```yaml criteria: - criterion: The Export button downloads the month's VAT report as CSV files: [export-click.png, export.csv] - criterion: A month with no invoices shows "Nothing to export" files: [empty-month.png] - criterion: The CSV's total matches the invoice list's files: [] gap: the preview has no seeded invoices ``` The queue gives the skill a `planned` rule with two gates, and a plan hands it a task that waits for the preview: ```yaml queue: - skill: plan where: { label: awaywork-feature } deliver: plan instructions: [notes/plan-proof.md] # "End a plan that changes what a user sees with a prove task, after the others' Preview." - skill: implement where: { planned: true } - skill: prove where: { planned: true } deliver: report gates: proof: { command: sh scripts/evidence-gate.sh } # the files are there shown: { skill: judge, instructions: [judges/evidence.md] } # and show what the card asks ``` ```yaml tasks: - { id: app, title: Export screen } - { id: qa, title: Proof, skill: prove, after: { app: Preview } } ``` A step of its own starts from the card, not from code it wrote, so it checks what was asked rather than what was built. Its clone is cut from `app`'s branch, so it has the change but not the session that made it, and its context is the card and the plan. The script gate is [`evidence-gate.sh`](https://github.com/AndreaProvenzale/awaywork/blob/main/plugin/skills/away/scripts/evidence-gate.sh), POSIX sh, copied into the repo and named by its path from the root, so awaywork can tell the agent [didn't rewrite it](./gates.md#gates-fail-closed). Its contract: - It fires unless every criterion names at least one file that is in `.away/out/`, not empty, not a link nor in a linked folder. A `gap` line excuses nothing. - It sees that the files exist, not what they show, and runs nothing the manifest says. - It reads the manifest as plain lines, not as YAML: `criteria:`, then per entry `- criterion: `, `files` as a list on one line or a name per line, and `gap: `, each on one line. Any other line fires it with the line's number, a `|` block, a list carried onto the next line or a quoted key included, so nothing it can't read passes. - A name is a path of ASCII letters, digits, `.`, `_` and `-`, with `/` between folders. - The step's own files aren't proof: `evidence.yml`, `report.md`, `summary.md`, `pr.md`, `push.md`, `questions.md`, `plan.yml`, `review.json`, `status`, `verdict`, `diff.patch`, `check.log`, `verify.log` and any `judge-*.log`, at the top of `.away/out/`. On the manifest above, with the first two criteria's files in place: ```text $ AWAYWORK_OUT_DIR=out sh scripts/evidence-gate.sh; echo $? no file for criterion 3 1 ``` And with its `gap` written as a block: ```text $ AWAYWORK_OUT_DIR=out sh scripts/evidence-gate.sh; echo $? evidence.yml line 8: a block of text isn't read: put the text on this line 1 ``` The judge beside it opens the files. Its instructions come from your config, `judges/evidence.md` here: ```markdown You judge proof of a change. The step left `evidence.yml`: each of the card's acceptance criteria with the files that show it. Fire when a criterion of the card is missing from it or reworded into something easier, when a file doesn't show what its criterion says (open each one), or when the report says a criterion failed. One line per criterion you fire on, naming it. ``` With both clear the result is posted on the card and the task is done. One that fires [posts it to wait](./tasks.md#a-gate-on-a-report-task), `- proof: no file for criterion 3` on top: `approve` accepts the gap, anything else runs the task again. With a [store](./store.md), each file goes through its `put`, `kind` being the file's name under `.away/out/`, so the script decides where screenshots go and what stays behind: ```js if (op.op === 'put' && op.kind.endsWith('.zip')) { console.log(JSON.stringify({ skip: true })) // a trace stays in the run dir process.exit(0) } ``` Other shapes: - **No plan:** a rule on awaywork's own PRs once their checks pass, the preview's among them. Its context has the PR's `checks.md`, whose `## Not failing` part lists each check with its link, so the skill reads the preview's URL there, and names the host it must be on. The issue is in the unit, for the step to read, only while a rule still takes it, as `implement` does here while it stays open and labelled. Clear gates then post the result on the PR and the issue; one that fires makes it wait on both. A card no rule takes any more (it moved column, lost the label) leaves the PR alone in the unit: the step proves against the PR's body, which the implementing agent wrote, and posts there only. ```yaml queue: - skill: implement where: { type: issue, label: awaywork } - skill: prove where: { type: pr, ours: true, checks: passing } deliver: report gates: proof: { command: sh scripts/evidence-gate.sh } shown: { skill: judge, instructions: [judges/evidence.md] } ``` - **No preview:** the step that made the change proves it itself, with a command that needs nothing started: a curl script against staging, a query, a run of the repo's CLI. It's the fallback: the agent that wrote the change picks what counts as proof. The gates go on that rule, and one that fires puts its line on the PR's [decision card](./gates.md#the-decision-card). ```yaml queue: - skill: implement skills: [prove] instructions: [notes/prove-first.md] # "Before you stop, prove the change with the prove skill." where: { type: issue, label: awaywork } gates: proof: { command: sh scripts/evidence-gate.sh } shown: { skill: judge, instructions: [judges/evidence.md] } ``` Details: - The [harness](https://github.com/AndreaProvenzale/awaywork/blob/main/harness.md) lets a worker run no dev server, build, `docker` or background command, and gives it no forge access: the skill proves against something deployed, or with a command that needs nothing started. A script that boots the app is still a dev server: a repo whose proof needs the app running and has no preview can't be proven by a worker. - A plan's task gets the card, not the PR, so no `checks.md`: its `item.md` plan names the check it waits on with its link, while that PR is open. A check that links elsewhere, as Vercel's links its dashboard, leaves the skill to build the address from the branch its own was cut from, as in `https://.preview.acme.dev`, each `/` a `-`. Without a stable one per branch, use the PR rule. - A check's name and link are whatever reported them, on a public repo anyone: the skill names the host it opens. - The gate fires until the script is on the default branch where the clone started: as cloned, or, on an awaywork PR or a task stacked on one, where that PR left it. In neither that commit nor the clone, the command runs and fails: `- proof: Command failed with exit code 127: 'sh scripts/evidence-gate.sh'`. Added or edited on the PR's own branch, it isn't run: ``- proof: `scripts/evidence-gate.sh` changed, not run``. - The gates go on every rule that uses the skill. A judge is one more model run per unit. - Without a store the files stay in the run dir, where only your [notify](../results.md#notify) hook reaches them. # Scheduling A scheduled run is one [window](../config/providers.md#windows-limits-and-budget) opening: it starts when the window opens and starts no unit after it ends. A unit still running at the end stops with `stopped: window ended`, and what it left is published as for a quota stop. ## Pick where it runs | | [This machine](./machine.md) | [GitHub Actions](./actions.md) | [A container](./container.md) | | --- | --- | --- | --- | | What starts runs | `awaywork watch` under launchd, systemd or cron | A cron in your control repo's workflow runs `awaywork run --scheduled` | `awaywork watch` in Docker Compose, on Coolify or a VPS | | [`run: soon`](./machine.md#soon-runs) rules | Within minutes | In the windows: no daemon | Within minutes | | Attempts, skips, a `push` head | Kept | Kept on a self-hosted runner; a GitHub-hosted one starts empty | Kept on a volume | | Codex | Yes | Self-hosted runners only | Yes, its login on a volume | | Run length | The window | About 6 hours on a GitHub-hosted runner | The window | | Bitbucket, `extends` | Yes | No | No | | Needs | The machine awake | A private control repo and its secrets | A Docker host and its env vars | A daemon on a box of yours, the container or a machine you ssh into, also takes your laptop's commands, `/away` included: [From your laptop](./laptop.md). In a terminal, `awaywork schedule install` asks where: ``` ◆ Where should it run on a schedule? │ ● this machine │ ○ GitHub Actions, from the repo in this directory │ ○ a container (Docker Compose, e.g. Coolify), from the repo in this directory └ ``` `--with` answers up front; without a terminal it takes this machine. Pick one per machine: with a local schedule and a runner on the same machine, both start at the window and work the same items on the same state dir. Moving to the runner, run `schedule remove` outside the control repo first; inside it, that deletes the workflow too. # On your machine ```sh awaywork schedule install # macOS: launchd awaywork schedule install --with systemd # Linux: a user service awaywork schedule install --with cron awaywork schedule status # the job, the daemon and its log awaywork schedule remove ``` The job is `awaywork watch`, kept alive by the scheduler. It checks the clock every minute and starts a run when a window opens, even after the machine slept through the opening, and rereads the config each time, so window edits need no reinstall. It also runs [`run: soon`](#soon-runs) rules within minutes. | Scheduler | Writes | Keeps it alive | | --- | --- | --- | | launchd | `~/Library/LaunchAgents/awaywork.plist` | Started at login, restarted after a crash (`KeepAlive`, `SuccessfulExit: false`) | | systemd | `~/.config/systemd/user/awaywork.service` | Enabled at login, `Restart=on-failure` | | cron | two tagged lines in your crontab | Started at boot (`@reboot`) and again every 5 minutes | ```text schedule-status $ awaywork schedule status Scheduled with launchd, keeping awaywork watch running Config: ~/.config/awaywork/config.yml Log: ~/.local/state/awaywork/schedule.log Daemon: running, pid 67014, since 2026-10-25 17:26:00 Run in progress: soon run 20261025-162625 Next poll of flaky: 2026-10-25 17:30:00, backing off after 1 failed poll Next window: none, no windows set Daemon log: ~/.local/state/awaywork/watch.log ``` The daemon's log, `watch.log`, has events only: start and stop, runs and units starting and ending, and errors; past 5 MB it moves to `watch.log.1`. A run's full output goes to `run.log` in its run dir, and into `watch.log` too with `watch: { debug: true }`. `schedule.log` catches only a crashed daemon. The morning report is `awaywork report`. Both list what failed before any unit started: ```text schedule-source-warning warning: source chores: Command failed with exit code 1: ./chores.sh Error: CHORES_TOKEN is not set ``` Details: - **One opening, one run.** A run that finishes early isn't restarted in the window; one started inside a window, after a reboot say, picks up there, skipping items already worked on. - **One daemon at a time:** another copy exits with `awaywork watch is already running (pid 67014)`, which makes cron's restart line harmless. - **Your run or the daemon's.** A manual `awaywork run` during a daemon run exits 1 (`awaywork watch holds the run lock (pid 67014); try again once its runs end, awaywork schedule status lists them`). A window opening during your manual run waits for it, checking every minute and logging it once; a soon run waits for the next poll. A dead process's lock is taken over; `--dry-run` takes none. - **`PATH`:** the job keeps the installing shell's, so it finds the same `claude`, `codex`, `agent` and `gh`, but runs the binaries, not shell functions: [pin logins](../config/providers.md#which-login) in the config instead. - **Secrets** come from `~/.config/awaywork/env`. The daemon reads it and its own code once, at start: after editing it or upgrading, run `schedule install` again, which restarts it. - **Restarting:** `install` and `remove` stop the running daemon first, whoever started it: a SIGTERM, then up to 2 minutes. A running unit stops, publishes nothing and isn't counted as an attempt. If it's still running after that, install changes nothing and says so. Run from inside the daemon (a hook, a worker), they refuse. - launchd and systemd restart it only after a crash; a SIGTERM of yours keeps it down until your next login or `install`. Under cron it's back within 5 minutes. - systemd user services run only while you're logged in (`loginctl enable-linger` lifts that). Without a user session (`sudo -iu`, `su -`) `--with systemd` fails and changes nothing, and `status` and `doctor` report no schedule; log in directly or use cron. - `windows: []` is fine: the daemon idles and install sets no wake. - A failed command gets two lines in the log: its error and the last error line of its stderr. Configured secrets show as `***`, so the report is safe for `notify`. Run ids are the start time in UTC. - A job from an older awaywork (one trigger per window running `awaywork run --scheduled`) still works, but `status` and `doctor` flag it: run `schedule install` once. ## Soon runs Every `watch.every` (5 minutes), the daemon polls the sources its [`run: soon`](../config/queue-rules.md#run-soon) rules read, plans just those rules and starts a run for the units due: new items, or ones changed since their last attempt, like a PR gone red or your reply to awaywork's [questions](../workflows/questions.md#questions). Here a source's item came in, awaywork's questions on it didn't count, the reply did, and a second source was down for a while: ```text schedule-watch-log 2026-10-25 16:17:51 awaywork watch started, pid 27290, config ~/.config/awaywork/config.yml 2026-10-25 16:17:51 source flaky: poll failed, next at 2026-10-25 16:21:00: Command failed with exit code 1: ./flaky.sh 2026-10-25 16:17:51 soon run 20261025-151751 started for ch-1 [...] 2026-10-25 16:21:00 source flaky: polled fine again, every 2m 2026-10-25 16:22:00 soon run 20261025-152200 started for ch-1 ``` Details: - awaywork's own questions comment isn't a change, nor is anything a run already took: a failed unit waits for the item to change, or the next window. One its run never attempted (no quota, sat out a present run, stopped) is due again an hour later, or at the first poll after a restart. - A clock-driven job, Mondays at 09:00, is a [routine](../config/sources.md#routines), which a soon rule starts at the first poll after. - Each poll that finds units makes one run, with its own run dir, report and `per_run` share. One that finds nothing writes nothing (one line with `debug`). - Outside a window a soon run is present: workers stop at `present_session_max` and providers without a quota reading sit out. Inside one it's away and ends with the window. - A poll reads no quota and runs no model. A GitHub poll is 3 searches (your PRs, review requests, assigned issues), one per `search` entry and two more for [finished items](../config/queue-rules.md#finished-items): about 36 an hour at 5 minutes. A tracker poll is its list call plus a PR search per open task without a link. Give a slow or rate-limited tracker its own `every`. - A failed poll backs off as long as `Retry-After` says (Jira and Asana, on a 429), else doubling per failure up to an hour, logging each backoff and recovery. GitHub, Linear and custom sources give no reset time, so they double. - `notify` gets a soon run's `unit` events, not the run's end. A unit that sat out or was passed over by `when` or `quota` fires none, so hourly retries stay quiet; a failed `setup` or a timed-out `when` still fires. - Runs overlap, never on one item, and one worker runs at a time: a soon unit waits for the window run's current chain, not the whole run, routing on the quota it read when planning. A unit another run holds is skipped as `running in run 20261025-010000`. The daemon holds the run lock, and keeps the machine awake, while any run is in progress. - An `isolation` edit applies to the next run that starts with none in progress, logged once while it waits. Restart `watch` or rerun `schedule install` to apply it now. ## Waking up for it A sleeping machine runs nothing. While a run is in progress the daemon keeps the machine from idling to sleep (`caffeinate -i` on macOS, `systemd-inhibit --what=idle` on Linux where it works); the wake itself is a separate step `schedule install` offers in a terminal: ``` ◆ Wake this Mac when the window opens (sudo pmset repeat wake MTWRFSU 01:00:00)? │ ● Yes / ○ No └ ``` - macOS: `pmset` keeps one repeating wake, so every window must start at the same time, and it replaces your other repeating power events, scheduled sleeps and shutdowns too: the question lists them and defaults to No unless all it replaces is a wake. Keep a laptop on the charger; lid closed on battery, it may not wake fully. - Linux: a root timer, `/etc/systemd/system/awaywork-wake.timer` with `WakeSystem=true`. A background job can block idle but not sleep, so turn off a desktop's own suspend timer on AC power. - sudo needs a password, so without a terminal install only says how. `schedule remove` leaves the wake and prints how to remove it. - Moving the windows' start needs `schedule install` again to move the wake; `doctor` warns until then. # In GitHub Actions Run it from a private repo of your own, on GitHub's runners or a machine of yours. On a team, each person keeps their own control repo, so nobody's token sits where a teammate can write; they can all point at the same repos, since each run sees its own token's `@me`. It's GitHub only and one file: install refuses a [Bitbucket](../config/sources.md#bitbucket-and-jira) config or one that [extends a base](../config/index.md#a-base-config). ``` $ cd my-awaywork-repo $ awaywork schedule install [...] ◇ Which runner? │ self-hosted Wrote .github/workflows/awaywork.yml Copied ~/.config/awaywork/config.yml to .awaywork/config.yml Before the first run: commit .github/workflows/awaywork.yml and .awaywork/config.yml add these repository secrets: GH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN GH_TOKEN is a classic personal access token of yours with the `repo`, `workflow` and `read:org` scopes (…) ``` It writes a workflow with a cron at each window's start (none with `windows: []`) plus `workflow_dispatch`, copies your config to `.awaywork/config.yml` and your [skills](../skills.md#where-skills-come-from) to `.awaywork/skills/`, and offers to set each needed secret it finds in your environment or env file with `gh secret set`, never printing values, and naming whose `GH_TOKEN` it is. When it has secrets to offer, it first checks the account gh acts as (`GH_TOKEN`, else gh's active one) has write access to the repo; when it hasn't, it stops before writing anything and names it: switch with `gh auth switch --user ` or `forge.github.user`, or run `GH_TOKEN=$(gh auth token --user ) awaywork schedule install`. Then commit, add the secrets it lists as unset (`GH_TOKEN`, provider tokens, and any `secrets` of sources, repos, MCP servers and `notify`), and run it once from the Actions tab. `schedule remove` deletes the workflow. `GH_TOKEN` is your classic personal access token: `repo` reads your PRs, issues, checks and logs and pushes branches and drafts; `workflow` lets it push branches that touch workflow files; `read:org` shows teammates with private org membership, whose text would otherwise be [hidden](../safety/index.md#public-repos). Details: - The runner is GitHub-hosted by default: a fresh VM, no Codex. Self-hosted: see [Running Codex](#running-codex). Without a terminal, `--with github-actions` keeps the workflow's runner (hosted for a new one) and offers no secrets. - No daemon there, so [`run: soon`](./machine.md#soon-runs) rules run in the windows; install says so once. - `doctor` inside the repo fails naming secrets missing from the repo and its org, and fails a workflow that doesn't parse; when gh can't list secrets, it warns why. The action fails first thing on an empty `GH_TOKEN`, the sign secrets didn't reach the job. - The cron is UTC: install converts each start from your timezone at standard time, and the job's `TZ` keeps ends, `days` and [routines](../config/sources.md#routines) local. Runs never start early, but in summer time up to an hour late (a Rome `18:00` window fires at `17:00` UTC, 19:00 there March to October), so keep Actions windows over an hour. Changed timezone? Run install again. ## The action The workflow calls awaywork's action at the release you generated it with, `AndreaProvenzale/awaywork@v0.3.1` say, which installs that version (`version` input). On a GitHub-hosted runner it frees about 20 GB of preinstalled SDKs (runners otherwise die quietly on large repos) and makes the [worker user](../safety/isolation.md#a-worker-user-on-github-hosted-runners). It commits as the token's user through env, not git config, appends the morning report to the job summary and uploads the run as an [artifact](#reports-from-the-runner). A [custom source](../config/custom-sources.md)'s `setup` runs as its own step first, in the config's folder, without secrets. - `GITHUB_TOKEN` is read-only (`permissions: contents: read`), checkout keeps no credential (`persist-credentials: false`), and only the action's step gets secrets. - Every step inside the action sees them, hence the pin: new action code meets your secrets only when you rerun install. Pin `uses:` to the release's SHA for tighter. The hosted runner's two install steps are the exception: npm packages' install scripts and the `scripts` input get only `PATH`, `HOME` and `USER`. That stops a careless leak, not a hostile installer, which has sudo before `awaywork run`. - To upgrade, upgrade awaywork and rerun `schedule install --with github-actions`; by hand, change `uses:` and `version` together. A workflow from before the action runs its steps inline; `doctor` warns. - Rerun install after config or skill changes. A matching file is up to date; one that differs shows the diff and is replaced only on your yes. Without a terminal it keeps the repo's copy, prints the diff and exits 1. The skills folder is replaced whole, dropping repo-only skills; with no skills of yours, the repo's stay. ## On the runner - **No state on a hosted runner:** no attempts, skips or earlier reports, so unchanged items run again, none is given up on, your local `awaywork skip` doesn't reach it, and a chain starts without the last report. Waiting [questions](../workflows/questions.md#questions) and [posts](../workflows/posts.md#posts-that-wait) still wait, read off the item, and `awaywork approve` replies there. - **No push hold:** without the head a [`deliver: push`](../config/chains.md#push) left, the rule pushes again every run its `where` holds. - **A self-hosted runner** keeps its user's `~/.local/state/awaywork`, as on your machine. - **MCP servers and toolchains:** a stdio [MCP server](../config/mcp.md)'s `command` must exist on the runner (`npx` and `node` do). On a hosted runner it runs as the worker, who can't enter the runner's home, so a script in your checkout won't start; install packages, pnpm included, with the action's `packages`, not a setup action that installs into the home (`pnpm/action-setup` does). HTTP servers must be reachable from GitHub. - **6 hours:** hosted jobs end at 6 hours, so a run stops 5h40 after it starts or at the window's end, leaving 20 minutes to publish; the workflow sets `timeout-minutes: 360`, and `plan` warns about longer windows (`GitHub-hosted runners end a job at 6 hours, so a run stops 5h40 after it starts, before 18:00-07:00 ends`). It stops like any window end, Actions-tab runs included. Self-hosted runs last the window, with `timeout-minutes: 1440`, added on your next install. ## Run one item now The workflow takes a `unit` input, an item key: ``` $ gh workflow run awaywork -f unit=API-12 ``` It runs only `API-12`, outside any window, retrying even after a failed attempt. The item is fetched again and must still match a rule, so "only your items", the ceiling and the prompt hold. - `concurrency: awaywork` cancels nothing: a dispatch waits for the nightly run, and GitHub keeps only the newest pending run. - It spends daytime quota; as a run outside the windows, workers stop at `present_session_max` (40% of the 5-hour window by default). - "Run it when Sentry escalates" needs a relay: as far as we know Sentry's webhooks can't send what GitHub's dispatch API wants. awaywork ships none and holds no credentials for one; a small function calling `gh workflow run` does it. ## Reports from the runner Each run uploads `report.md`, `report.json` and `units.json` as an artifact like `awaywork-20261014-223527`. Inside the control repo, `awaywork report` and `report --list` fetch those through `gh` and mix them with your local runs: ```text schedule-report-list $ awaywork report --list 20261015-160012 9 units, 3 draft PRs, 2 reports (github: awaywork) 20261012-203418 0 units, 0 draft PRs, 0 reports ``` They find runs by the workflow's file name, so keep its `name:` as written. A run downloads once, into `~/.local/state/awaywork/remote////`, never `runs/`, so `stats`, `replay` and attempts stay local. Artifacts expire with the repo's retention (90 days by default). Offline you get a `warning:` and your local runs; outside the control repo, local runs only. ## Credentials on a runner | Provider | Secret | Notes | | --- | --- | --- | | Claude Code | `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token`; lasts a year | | Codex | none | Self-hosted runners only, on the machine's own login | | Cursor | `CURSOR_API_KEY` | A key from Cursor's dashboard | | [Your own CLI](../config/providers.md#bring-your-own-cli) | its `secrets` | Self-hosted runners only | ## Running Codex Codex (experimental) uses the machine's `codex login`, which it rotates in place, so a fresh runner has none to keep. With a hosted runner, install leaves it out and runs route to your other providers: ```text schedule-codex-hosted codex won't run in this workflow: a GitHub-hosted runner has no Codex login, and one copied there goes stale since Codex rotates it in place; run Codex locally or on a self-hosted runner ``` Run it locally ([On your machine](./machine.md)), or on a machine of yours [added as the control repo's runner](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/add-runners), logged in with `codex login` as the runner's user (`codex login --device-auth` on a headless box, [Codex's docs](https://learn.chatgpt.com/docs/auth#login-on-headless-devices)). Pick self-hosted at _Which runner?_: the workflow says `runs-on: self-hosted` and keeps Codex. Details: - Every self-hosted runner the repo can use matches that label, an organization's too, so give yours a [label of its own](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/apply-labels): `runs-on: [self-hosted, alice-mac]`. Install nags while it's plain `self-hosted` and keeps labels on a rerun. - There the action keeps its hands off: no disk freeing, no CLIs installed over yours, no git config. It installs awaywork alone into the job's temp dir and runs the `codex`, `claude` and `agent` on the runner's `PATH`; Claude still reads `CLAUDE_CODE_OAUTH_TOKEN`. - A runner [installed as a service](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/configure-the-application) doesn't read your shell profile: its `PATH` is the `.path` file `./config.sh` saved, exported by [`runsvc.sh`](https://github.com/actions/runner/blob/main/src/Misc/layoutbin/runsvc.sh), other variables in `.env`. nvm or Homebrew binaries may be missing: configure it where `codex --version` works, or edit `.path` and restart (`./svc.sh stop`, `./svc.sh start`). - GitHub recommends self-hosted runners only for private repos, since a fork's PR could run code on them: keep the control repo private. ## Running Cursor Cursor's CLI has no npm package, so on a hosted runner the action installs it with Cursor's installer, as the runner's user, into `/opt/cursor` (readable, not writable, by the worker), linked as `/usr/local/bin/agent`. Install puts it in the workflow's `scripts` input and names `CURSOR_API_KEY` among the secrets: ```yaml packages: "@anthropic-ai/claude-code" scripts: | sudo install -d -o "$USER" /opt/cursor curl -fsS https://cursor.com/install | HOME=/opt/cursor bash sudo ln -sf "$(readlink -f /opt/cursor/.local/bin/agent)" /usr/local/bin/agent agent --version ``` Self-hosted runs the `agent` on the runner's `PATH`. Either way it needs the `CURSOR_API_KEY` secret: a machine login doesn't reach its fresh `HOME`. # In a container No machine to leave on? Run `awaywork watch` in a container, on [Coolify](https://coolify.io) or any VPS with `docker compose`. Setup, the agent and the checks run as a [worker user](../safety/isolation.md#a-worker-user-in-awaywork-s-image) inside. Unlike Actions, state lives on a volume (attempts, skips, a [`push`](../config/chains.md#push) head), soon rules run all day, there's no 6-hour cap, and Codex works, its login on a volume. From a private control repo: ``` $ cd my-awaywork-repo $ awaywork schedule install [...] ◇ Where should it run on a schedule? │ a container (Docker Compose, e.g. Coolify), from the repo in this directory Wrote Dockerfile Wrote compose.yml Wrote entrypoint.sh Copied ~/.config/awaywork/config.yml to .awaywork/config.yml Before the first run: commit Dockerfile, compose.yml, entrypoint.sh and .awaywork/config.yml set these env vars in Coolify, or in .env beside compose.yml on a VPS: GH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, LINEAR_API_KEY GH_TOKEN is a classic personal access token of yours with the `repo`, `workflow` and `read:org` scopes (…) once it runs, log Codex in: docker exec -it codex login --device-auth ``` | File | What it does | | --- | --- | | `Dockerfile` | Debian with Node 22, git, gh, sudo and tini; awaywork and your providers' CLIs, pinned to the awaywork that wrote it; `away`, the user awaywork runs as, and `awaywork`, the worker user, with the sudoers lines [`isolation: user`](../safety/isolation.md) needs | | `compose.yml` | `restart: unless-stopped`, `stop_grace_period: 2m`, `mem_limit: 4g`, volumes for the state dir and Codex's login, each secret your config needs as an env var, `TZ` defaulting to your timezone; `AWAYWORK_REMOTE_TOKEN` and port 7480, exposed to the proxy, for [your laptop](./laptop.md) | | `entrypoint.sh` | Fails on an empty `GH_TOKEN`, commits as the token's user (env vars, as the action does), then `exec awaywork watch`, its log on the container's output | Commit and push: that's a deploy. Details: - `--with docker` asks nothing; the files come from awaywork's `docker/`, fit to your config. Values are never printed: you set them where it runs. - The config is copied into `/home/away/.awaywork/config.yml` at build, so a push is how a change gets there. Only `.awaywork/` goes in, so files the config names by path live there too. - GitHub only for now (no twg, no SSH key for Bitbucket pushes), and no `extends` (the base wouldn't come along): install refuses both. - Rerun install after config, skill or awaywork changes. As for Actions, a differing file shows its diff and is replaced only on your yes; without a terminal it keeps yours and exits 1: ``` $ awaywork schedule install --with docker [...] # Your repos' toolchains go here, as RUN lines: setup runs as the worker user. -RUN npm install -g pnpm@10 [...] Kept Dockerfile; run this again in a terminal to replace it compose.yml is up to date ``` - `schedule remove` in the repo deletes the three files if the Dockerfile is awaywork's, leaving `.awaywork/`. `doctor` warns while the Dockerfile builds another awaywork than yours. ## On Coolify Add a resource from the control repo with the **Docker Compose** build pack, compose location `/compose.yml`. Coolify lists the variables it names: `GH_TOKEN` is required (the [classic token](./actions.md)), `CLAUDE_CODE_OAUTH_TOKEN` is Claude's (`claude setup-token`), `CURSOR_API_KEY` Cursor's, the rest your config's. `TZ` defaults to install's timezone. Deploy; every push redeploys. Not the Dockerfile build pack: its [rolling updates](https://coolify.io/docs/applications/deployments/rolling-updates) start the new container before stopping the old, and awaywork's locks, checked by pid and start time, mean nothing across containers, so two daemons would share a state dir for a moment. Compose apps are replaced, not rolled. ## On a VPS Any box with Docker and the compose plugin; `.env` takes the variables install listed: ```sh git clone git@github.com:you/my-awaywork-repo.git && cd my-awaywork-repo printf 'GH_TOKEN=%s\nCLAUDE_CODE_OAUTH_TOKEN=%s\nTZ=Europe/Rome\n' "$GH_TOKEN" "$CLAUDE_CODE_OAUTH_TOKEN" > .env && chmod 600 .env docker compose up -d --build ``` Keep `.env` out of git. Without `GH_TOKEN`, compose refuses: `required variable GH_TOKEN is missing a value`. After a `git pull`, `docker compose up -d --build` again. ## Living with it - **Secrets are env vars**, no env file: Coolify passes only what `compose.yml` names, so install lists every secret your config needs as `NAME: ${NAME:-}`. Added one? Run install again. - **Codex:** install adds `@openai/codex` to the Dockerfile's `PACKAGES`. Log in once into the volume, `docker exec -it codex login --device-auth`; it survives redeploys. - **Toolchains** go in the Dockerfile as `RUN` lines at the marked spot, system-wide, since the worker can't enter `/home/away` (`RUN npm install -g pnpm@10`), [your own CLI](../config/providers.md#bring-your-own-cli) too. - **Cursor:** no npm package, so install puts [the installer](./actions.md#running-cursor) at that spot as one `RUN`, without `sudo` (the build is root): ```dockerfile RUN install -d /opt/cursor \ && curl -fsS https://cursor.com/install | HOME=/opt/cursor bash \ && ln -sf "$(readlink -f /opt/cursor/.local/bin/agent)" /usr/local/bin/agent \ && agent --version ``` On arm64 the worker ran `agent --version` (`2026.10.22-27dcf5f`) and couldn't write `/opt/cursor`. Set `CURSOR_API_KEY`. - **A [custom source](../config/custom-sources.md)'s `setup`** runs at build, right after the config is copied, in its folder, as root, without secrets, after the sweep for world-writable paths: keep its output there, readable by awaywork, not the worker. - **Upgrading** is a rebuild: upgrade, rerun install, push. Claude Code's auto-updater is off, so it moves with rebuilds. - **A push stops a running unit:** the redeploy's SIGTERM stops it, unpublished and uncounted, and the new container runs it again. `stop_grace_period: 2m` matches the 2 minutes install gives the daemon on your machine, where Docker's default kills after 10 s. Here a stop hit a unit mid-run: ``` 2026-11-03 23:14:39 acme-api-196: stopped: aborted 2026-11-03 23:14:39 awaywork watch stopped 2026-11-03 23:14:53 awaywork watch started, pid 7, config /home/away/.awaywork/config.yml 2026-11-03 23:14:58 acme-api-196: implement on fake ``` - **Memory:** capped at 4 GB, and the watchdog's 1.5 GB floor counts against the cap (`process.availableMemory()` was 2.99 GB under `-m 3g`, 7.19 GB without). Raise `mem_limit` for big builds. - **Reaching it:** `docker exec`, ssh to the host, or Coolify's terminal. `watch.log` is the container's output (`docker logs -f `, Coolify's Logs tab), and `docker exec awaywork doctor`, `report` or `schedule status` work as usual; `status` says `No scheduled job installed`, which is right, since the container keeps `watch` running. # From your laptop Give the container's daemon a token and your laptop's `awaywork report`, `plan`, `skip`, `approve` and `run --unit` run there, [`/away`](../away.md) included. It's the CLI over HTTP, not an API: the daemon runs the same command with its own config and streams back its output. A box of your own without a container is simpler: [over ssh](#over-ssh). 1. Make a token (`openssl rand -hex 32`), set it as `AWAYWORK_REMOTE_TOKEN` in Coolify or `.env`, redeploy. Without it the daemon opens no port; under 32 characters it logs `remote: AWAYWORK_REMOTE_TOKEN is shorter than 32 characters, so no port is open`. 2. Give it a domain. In Coolify's **Domains** field, `https://away.example.com:7480`: the proxy gets the certificate and forwards to 7480, exposed to it, never published. On a VPS, publish it on localhost in a `compose.override.yml` (compose merges it; install never touches it) behind Caddy: ```yaml services: awaywork: ports: - "127.0.0.1:7480:7480" ``` ``` away.example.com { reverse_proxy 127.0.0.1:7480 } ``` In the image the daemon listens on every interface for Coolify's proxy; elsewhere, `watch` with the token in its env file listens on `127.0.0.1:7480` only (`remote: serving the CLI on port 7480, 127.0.0.1 only`). 3. On the laptop, in `~/.config/awaywork/env` (`AWAYWORK_REMOTE` takes `https://` or `http://` with the token, or [`ssh://`](#over-ssh) without): ```sh AWAYWORK_REMOTE=https://away.example.com AWAYWORK_REMOTE_TOKEN= ``` Against the image on Colima, Caddy standing in for Coolify, a stand-in agent and a `chores` source: ```text schedule-remote-run $ awaywork run --unit CH-1 Queued CH-1 as run 20261104-220657; awaywork report 20261104-220657 shows it once it ends $ awaywork skip chores:CH-1 --until 2030-01-01 skipped with awaywork skip chores:CH-1 until 2030-01-01 $ awaywork report 20261104-220657 --unit CH-1 1 CH-1 Write a hello report status done files /home/away/.local/state/awaywork/runs/20261104-220657/units/ch-1/1-plan/worker.log [...] Hello from the stand-in agent, as awaywork ``` The paths are the container's. Each request is a line in its log, never the token: `remote: ["run","--unit","CH-1"]: exit 0`. Details: - What goes there: `report`, `plan`, `stats`, `sources`, `doctor`, `schedule status`, `config path`, `skills list`, `skip`, `unskip`, `approve` and `run --unit`, with their flags here. The rest stays local: `init`, `config schema` and `edit`, `skills eject`, a full `run`, `schedule install`, `--help`. - One of the twelve with a flag the daemon refuses fails rather than running locally (`AWAYWORK_REMOTE (http://127.0.0.1:7480): refused --dry-run`); a value starting with `--` is written `--reason=-- see thread`. - `-c`/`--config` means this machine. To run anything else here, blank it: `AWAYWORK_REMOTE= awaywork report`. - `run --unit` starts no process on the box: the daemon queues a one-off run, like the workflow's [`unit` input](./actions.md#run-one-item-now). - The item must still match a rule unless `--skill` [picks one](../skills.md#run-one-on-an-item-no-rule-takes); it runs after a failed attempt, present outside a window, away inside one, and waits for the worker in progress like a [soon run](./machine.md#soon-runs). `--detach` changes nothing. Failures say why and exit 1: `no unit "CH-9" in the plan; awaywork plan --why CH-9 says why`, `CH-1 is running in run 20261104-220137`. - Two commands at a time, a third turned away (`2 commands are running, try again`), 2 minutes each. A silent command gets a `{}` line every 30 s to keep the proxy open; Ctrl-C stops it on the box. A queued `run --unit` is the daemon's by then. - A box that's down or a wrong token fails in one line: `can't reach AWAYWORK_REMOTE (http://127.0.0.1:8480): read ECONNRESET`, `AWAYWORK_REMOTE (http://127.0.0.1:7480) refused AWAYWORK_REMOTE_TOKEN`. - Remove the two lines to go back to local runs. A laptop daemon opens no port while `AWAYWORK_REMOTE` is set. A leaked token and rotation: [Safety](../safety/index.md#the-remote-token). ## Over ssh awaywork on a box of its own, a VPS under systemd from [`schedule install`](./machine.md), a home server, a Mac mini: you can ssh in, so use that. No proxy, TLS or token on the laptop: ```sh AWAYWORK_REMOTE=ssh://box # or ssh://you@host:2222 ``` The host is whatever `ssh` takes, aliases included. The same calls go through the box's daemon: the laptop runs `ssh -T -o BatchMode=yes box env AWAYWORK_REMOTE=http://127.0.0.1:7480 awaywork --from-ssh WyJzc2g6Ly9ib3giLCJwbGFuIl0`, the call (`["ssh://box","plan"]`) as one base64url word no login shell reads into, csh included, and the box forwards it to its daemon. The box needs: - **The same awaywork or newer:** an older one says `error: unknown option '--from-ssh'`. Install the version `awaywork --version` prints on the laptop. - **`watch` with a token:** `echo "AWAYWORK_REMOTE_TOKEN=$(openssl rand -hex 32)" >> ~/.config/awaywork/env`, then restart it. The token never leaves the box: ssh is the lock. - **awaywork and node on ssh's command `PATH`**, not your login shell's (nvm's usually isn't). `ssh box env awaywork --version` checks it as the laptop's calls do; exit 127 (`env: 'awaywork': No such file or directory`, or `node`) means install them system-wide, or `sudo ln -s "$(command -v node)" "$(command -v awaywork)" /usr/local/bin/` from an interactive shell there. - **Key auth:** `BatchMode` never asks for a password or a host key, either of which would hang the `/away` pane. ssh in once by hand to know the host key. Exit codes are the box's and stdout and stderr stay apart, so `awaywork plan --json | jq` works. Shell characters in arguments arrive as text (`--reason "it's done; \$(rm -rf ~)"` is just a reason). There's no Ctrl-C without a terminal; the box's awaywork leaves when the laptop's ssh goes, and the daemon stops the command (`remote: ["plan"]: stopped, the client left`). Errors name the remote and the box's side: ```text schedule-ssh-errors $ awaywork report can't reach AWAYWORK_REMOTE (ssh://dev@127.0.0.1:2222): Host key verification failed. $ awaywork report AWAYWORK_REMOTE (ssh://box): awaywork isn't on the box's PATH for non-interactive ssh; install it and node system-wide there, or link both into /usr/local/bin $ awaywork run --unit CH-1 AWAYWORK_REMOTE (ssh://box): the box's daemon isn't serving, the box's /home/dev/.config/awaywork/env has no AWAYWORK_REMOTE_TOKEN; add one there and restart awaywork watch $ awaywork report --list AWAYWORK_REMOTE (ssh://box): the box's daemon runs with another AWAYWORK_REMOTE_TOKEN than its env file; restart awaywork watch there ``` A call over ssh never runs on the box outside its daemon, so a box without a token refuses rather than running `run --unit` itself. The container is the exception: its port goes to the proxy, not the host, so Coolify and compose use HTTP. # Safety An agent working unattended on your repos is only acceptable if the worst run leaves nothing you have to undo. These rules are enforced in code, not asked of the model. In short: - **Workers hold no forge token.** Of the secrets awaywork knows about, they get only their own agent's and those of the MCP servers their rule names: [What the agent can reach](#what-the-agent-can-reach). - **awaywork does every write, where the config says**, and the model never picks where its work goes: [What awaywork publishes](./publishes.md). - **Not a sandbox by default.** With `isolation: none` the agent can read what you can; a worker user or a container closes most of that: [Not a sandbox](./isolation.md#not-a-sandbox). - **A watchdog** stops a unit that runs the machine low, and kills what it left running: [Resources](#resources). ## What the agent can reach A worker gets no forge or tracker token, only its own secret, a throwaway clone, a fixed harness, and none of your user-level agent config; setup, context and checks run outside it, and other people's PRs are data. Below, the credentials awaywork passes a worker and where its work goes. That's what awaywork hands over, not a wall: with `isolation: none`, the default on your machine, the agent runs as you and can read your files, tokens included ([Not a sandbox](./isolation.md#not-a-sandbox)). ```mermaid flowchart TD subgraph before["awaywork, with your forge and tracker tokens"] prefetch["Prefetch the item's context"] end subgraph worker["Worker, in a fresh clone"] agent["Agent CLI"] end subgraph after["awaywork, with your forge and tracker tokens"] publish["Push a branch, open the PR, post on your item"] end login["Its own agent login, a rule's MCP secrets, the rest of your environment"] --> agent prefetch -- ".away/context/ files" --> agent agent -- "commits and .away/out/" --> publish publish --> forge["Forge and tracker"] agent -. "readable with isolation: none" .-> home["Your files: env file,
gh login, ssh keys"] ``` ### No forge or tracker token Sources prefetch context into files before the agent starts, and awaywork makes any [tracker write](./publishes.md#tracker-writes) itself after the chain. A custom source's `write` gets its `secrets`; the agent never does. - **gh:** `GH_TOKEN` and `GH_ENTERPRISE_TOKEN` are `none`, similar variables removed, `GH_CONFIG_DIR` empty. gh prefers the placeholders, so a plain `gh` call fails, but your keyring login is there for whatever unsets them ([Not a sandbox](./isolation.md#not-a-sandbox)). - **twg**, whatever the forge, since you may use it for Jira alone: `TWG_CONFIG_DIR` empty and every other `TWG_` variable removed (`TWG_BBC_TOKEN`, `TWG_TOKEN`, `TWG_KEY_CONTENT` and any new one), so `twg` fails with `AUTH_INVALID`. `TWG_` names are reserved like `GH_`: no MCP server's `secrets` can use them. - **Not tokens either:** the agent and the checks get no `SSH_AUTH_SOCK`, `SSH_ASKPASS`, `GIT_ASKPASS`, `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_COUNT` and friends. - **Git config:** their git skips the system config (`GIT_CONFIG_NOSYSTEM`) and reads awaywork's global one in `.away/` (`GIT_CONFIG_GLOBAL`): your `user.name`, `user.email` and git-lfs's `filter.lfs`, no signing, credential helpers, hooks or includes. A gate's command and judge get those entries in a fresh copy, with no attributes file. - Worker commits aren't signed, which costs nothing: the [trailer](./publishes.md#the-trailer) drops a signature anyway. - `setup` keeps the ssh agent, the askpass helpers and your git config. ### Only its own secret - Every configured source's secret (`ASANA_TOKEN`, `LINEAR_API_KEY`, `JIRA_API_TOKEN` or your `token_env`), `notify.secrets`, `store.secrets`, every context command's, repo's and MCP server's `secrets` and every other provider's (`CLAUDE_CODE_OAUTH_TOKEN` for a Codex worker) are removed. - A Claude worker keeps `CLAUDE_CODE_OAUTH_TOKEN`, a Cursor worker `CURSOR_API_KEY`. Codex uses the machine's login and gets no secret. A unit keeps its rule's [MCP servers](#mcp-servers)' secrets. - The rest of your environment stays, so repo tooling runs, including env-file variables nothing declares. ### Setup, context and checks outside the agent - **Setup** runs before the agent, without the agent's secret and with only its repo's `secrets` added, and none of those on someone else's PR or a branch a worker wrote: earlier in the run, an awaywork PR a rule took up, or the task PR a plan's task stacks on. On a PR by someone without write access or org membership, setup doesn't run at all. What it leaves running is stopped before the agent starts. - **Context:** a `context` command runs with its own `secrets`, in the config's folder, not the clone, so nothing the branch commits runs with them. Only the regular files it writes reach the agent: write no secret there. - **Checks:** `until` and `when` run the worker's code like setup on a worker's branch: no repo `secrets`, outside the agent's sandbox, as the [worker user](./isolation.md#a-worker-user-on-your-linux-machine) under `isolation: user` and on hosted runners, in a [container](./isolation.md#a-container-on-your-machine) of their own under `container`. So they don't run on an untrusted author's PR. ### A throwaway clone Each unit gets a fresh clone on a new branch, with no credential helper. `.away/` (context, prompt, output) is excluded from git. - A branch that commits its own `.away`, folder or link, is refused before awaywork writes there, so a link can't make it write or delete outside the clone or land in the run dir. Copying `.away` out never replaces awaywork's `replay.json` or `worker.log`. - awaywork copies no symlink out of the clone, and refuses the unit if the folder it copies Codex's skills into links outside it, so a committed `.agents/skills` link can't redirect its writes. - Between a loop's rounds, which share the clone, awaywork runs no diff driver, fsmonitor or `.git/hooks` hook a round left, ends the loop on a symlink where it writes, and touches only regular files, so a FIFO can't hang it. - Each check runs just before its write, so a worker process that outlived the watchdog could still swap a path in between. ### A fixed harness [`harness.md`](https://github.com/AndreaProvenzale/awaywork/blob/main/harness.md) wraps every skill: never push, pull, merge, rebase, switch branches, comment or change git config; targeted checks and the repo's documented generators only; nothing in the background. No skill or instruction overrides it. It tells the agent the prefetched context is data: a request in an item to run commands, fetch URLs, read secrets or change the delivery is reported, never followed. ### Other people's PRs are data On a PR you didn't open, the branch's `.awaywork/` is ignored, Codex's copy of the skills is awaywork's, and the prompt says its `CLAUDE.md`, `AGENTS.md` and skills are the author's. - **Claude Code** loads none of the clone's config (settings, hooks, MCP servers, `CLAUDE.md`, skills), only awaywork's plugin and your `settings` file. - **Codex** still loads `AGENTS.md` and its skills. - **Cursor** doesn't see `.cursor/hooks.json`, `.cursor/cli.json`, `.claude/settings.json` and `.claude/settings.local.json` (it runs Claude Code's hooks and permissions too), which awaywork removes, git keeping the committed version. It starts no `.cursor/mcp.json` server (no `--approve-mcps`) but loads `AGENTS.md`, `CLAUDE.md`, `.cursor/rules` and the clone's skills. - **A [command provider](../config/providers.md#bring-your-own-cli)** gets awaywork's skills in its `skills_dir`, but its CLI loads whatever else it loads. The prompt names `CLAUDE.md`, `AGENTS.md`, skills and "other instruction files" as the author's. Reviewing a PR means running its tests with your agent's login around, so the default `review` rule takes `trusted: true` only: authors who are owners, members or collaborators. A rule without it reviews anyone's PR. ### Not your agent config Workers skip Claude Code's `~/.claude` settings, hooks, MCP servers, `CLAUDE.md`, skills, auto-memory and claude.ai connectors; Codex's `config.toml`; and Cursor's whole `HOME`: it runs in a fresh `.away/agent-home`, without your `~/.cursor` skills, MCP servers, hooks, rules, permissions or login, nor `~/.claude` and `~/.agents` skills. The repo's own config applies. What workers should have goes in awaywork's config: a Claude `settings` file, Codex `config`, top-level `skills` ([Configuration](../config/providers.md#providers)) and [MCP servers](../config/mcp.md), which apply on someone else's PR too, except MCP servers, which carry your secrets. ## Public repos Anyone can comment on a public repo's issues and PRs, and it would all reach the agent. So the GitHub source keeps only text by you, the repo's owners, members and collaborators, and apps installed on it (the review bots `fix` works on). The rest is dropped before the agent starts: ```text safety-hidden-note _Hidden: 2 comments from outside the repo's owners, members and collaborators._ ``` - A stranger's thread is hidden whole and doesn't count toward `unresolved_threads`, so it starts no `fix`. If GitHub won't say who started a PR's threads, all count, and the run sees only trusted ones. In a trusted thread, only strangers' replies go. Always on. - Membership comes from GitHub: without `read:org`, a teammate with private membership looks like an outsider. `gh auth login` and `schedule install` ask for it. It can't hide the item's own title and body (the default `review` rule skips strangers' PRs with `trusted: true`), an untrusted author's diff when a rule reviews it anyway, CI logs and check names and links in `checks.md` or a task's `item.md`, or what a trusted person or bot quotes. So the agent can still be steered, and without a worker user it can read your `GH_TOKEN` ([Not a sandbox](./isolation.md#not-a-sandbox)). The classic token `schedule install` asks for reaches every repo you can; a dedicated account is your call. A fine-grained token reaches less but [can't call the Checks API](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens-limitations), which `checks` and `checks.md` read, so those likely break. Untried. ## MCP servers A rule's `mcp` gives its units your servers, and more reach: - **A server's secret is one the agent can read.** They run as the same user, in one process tree. Claude Code starts stdio servers with its whole env, which its Bash gets too, and so does Cursor's shell (its stdio servers get only their named secrets). Codex keeps them out of its shell (`shell_environment_policy`), but a process can read another's env. So a unit has its agent's secret plus its rule's servers', nothing else. - **Injected text can call the tools.** Use read-only tokens where offered, and `tools` to allow only what the skill needs; the agent refuses the rest. For read-only context, a source's prefetch keeps the token from the model entirely. - **Not on someone else's PR,** trusted or not, nor an issue it closes, since that code would get the secrets in its env, as with setup's `secrets`. A server with an unset secret doesn't start either; the unit's log says which didn't and why. ## Resources A watchdog polls every 2 s. Below 1.5 GB free memory or 2 GB free disk it stops the unit (not counted toward the item's 3 attempts), and no unit starts below them. At the end it kills every process the agent started, detached too. The agent also runs in its own process group, killed when it exits or awaywork stops (short of `SIGKILL`); a process that leaves the group is the watchdog's, and one that also detaches between polls escapes both and could change the clone after awaywork checked it. Not with a [worker user](./isolation.md#a-worker-user-on-your-linux-machine): after setup, each check and each agent run, awaywork kills all its processes in one `kill -9 -1`, at once on a timeout or the window's end. Nor in a [container](./isolation.md#a-container-on-your-machine): it goes with its command, and a timeout or window end `docker rm -f`s it. Runs also have a turn cap and stop at the quota ceiling, except a command provider's, stopped by `limits.unit_minutes`. A process holding the output pipe open can't hold the unit up: a second after the command exits, awaywork closes its end. ## Secrets on a runner In GitHub Actions your credentials are the control repo's secrets. The workflow passes them only to awaywork's step, checks out without keeping a credential, and gives `GITHUB_TOKEN` read access only; awaywork pushes with your `GH_TOKEN`. Anyone with write access can still read them, by pushing a workflow that prints them, so keep the control repo your own, one per person. ## The remote token `AWAYWORK_REMOTE_TOKEN` lets your laptop [reach the container's daemon](../scheduling/laptop.md). Whoever has it and the domain gets: - **Reads:** `report`, `plan`, `stats`, `sources`, `doctor`, `schedule status`: your items' text, reports and the box's paths. `sources ` takes any built-in source, configured or not, so it shows what the box's credentials see (login and orgs; workspaces, teams, projects, statuses). Configured secrets are masked. - **A few writes, inside the usual guardrails:** `skip`, `unskip`, `approve` with notes (marking it, or replying `approve `, marked as awaywork's, on its plan), and `run --unit` of an item a rule already takes. Nothing a run couldn't do on your items. - **Nothing else:** an allowlist of commands and flags, never a shell; arguments are plain text (`x; touch /tmp/pwned` is just a missing key). Always its own config: `--config` or `-c` is refused anywhere, a flag's value included; no cwd or env from the request; `report`'s run id must be in its runs dir. So no `init`, `config`, `schedule install` or `remove`, no full `run`, no env or credentials: `GH_TOKEN` and the agents' logins stay in the container. The daemon blanks it before anything starts, so no agent, setup, check or spawned command gets it. Under `isolation: user`, which the image always uses, they can't reach it. Under `none` they run as the daemon's user and can read its env (`/proc//environ`), like your other secrets ([Not a sandbox](./isolation.md#not-a-sandbox)). From inside the image: ``` as awaywork, AWAYWORK_REMOTE_TOKEN=[unset] {"error":"wrong token"} ``` The daemon speaks plain HTTP; TLS is the proxy's (Coolify's Traefik, your Caddy), which is why outside the image it listens on `127.0.0.1` only. Use an `https://` `AWAYWORK_REMOTE` across networks you don't own. It's compared in constant time and never logged. To rotate, set a new one in Coolify (or `.env`), redeploy and update your laptop's env file: the old one dies with the old container. Remove it and redeploy to close the port. [Over ssh](../scheduling/laptop.md#over-ssh), the token stays in the box's env file: what reaches the daemon is your ssh login, which can do all that and more already. Calls still go through the daemon's allowlist. ## Vendor terms awaywork runs each vendor's **unmodified CLI** with **your own login**. It never calls a provider's backing API with a subscription credential, never stores, pools or proxies anyone else's, and never changes the client's identity. That is the line the vendors draw. | Provider | Unattended use | | --- | --- | | Claude Code | Allowed: headless runs with your own login or a `setup-token`, documented for CI. | | Codex | Grey: account login in CI is documented as advanced, with API keys recommended; keep one `auth.json` per machine. | | Cursor | Allowed: `CURSOR_API_KEY` is documented for CI and the terms say nothing against headless use; the account holder answers for everything it does. | | Command provider | Your call: it's your CLI and your login, and awaywork doesn't know its vendor's terms. `doctor` flags it grey. | `doctor` shows this per provider. Other agents, and why some (Gemini on a personal plan, Alibaba's plan) won't be supported: [Providers](../../providers.md). ## Reporting a vulnerability See [SECURITY.md](https://github.com/AndreaProvenzale/awaywork/blob/main/SECURITY.md). # Isolation By default setup, the agent and the checks run as you: ```yaml isolation: user # default: none; or container ``` - **`none`:** as you. [Safety](./index.md)'s measures keep credentials out of the agent's way, not its reach ([Not a sandbox](#not-a-sandbox)). - **`user`:** as `awaywork`, a user with nothing of yours, which you make once on Linux. - **`container`:** a fresh container of the repo's image per command: [In a container](#in-a-container). `doctor` says how to make the user, here for a user `runner` (uid and gid 1001): ```text config-doctor-isolation-user ✖ isolation: user, but can't run as the awaywork user (sudo: unknown user awaywork); make the worker user once: sudo useradd --no-create-home --user-group --shell /usr/sbin/nologin awaywork echo awaywork | sudo tee -a /etc/cron.deny /etc/at.deny sudo visudo -f /etc/sudoers.d/awaywork # with these two lines: runner ALL=(awaywork) NOPASSWD:SETENV: ALL runner ALL=(root) NOPASSWD: /usr/bin/chown -R -h -P awaywork\: /tmp/awaywork-[A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9], /usr/bin/chown -R -h -P 1001\:1001 /tmp/awaywork-[A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9] ``` No home, no login shell: each clone gets a fresh `HOME`. The first sudoers line runs anything as the worker; the second only the two `chown`s that hand it a clone and its `HOME` and take them back ([why that's safe](#a-worker-user-on-your-linux-machine)). Then `doctor` says `✔ isolation: user: setup, the agent and the checks run as awaywork`. What changes: - **Logins.** Your keychain login isn't there: give Claude a `CLAUDE_CODE_OAUTH_TOKEN` (`claude setup-token`) in the env file; `doctor` warns until you do. Cursor needs `CURSOR_API_KEY` anyway. Codex borrows your `auth.json` for the run and its refreshed tokens are saved back, under Codex's lock; read [what that doesn't close](#a-worker-user-on-your-linux-machine) first. - **Tools** must be system-wide: the worker can't run what nvm, `~/.local/bin` or `~/.cargo` put in your home, and `doctor` warns about each such `PATH` entry. For Cursor: `sudo env HOME=/opt/cursor bash -c 'curl https://cursor.com/install -fsS | bash'`, then link `agent` into `/usr/local/bin`. - **Your home** must be closed (`chmod o-rwx ~`); `doctor` warns until it is. - **Linux only:** `isolation: user runs on Linux only, not darwin`. - **Always on** a GitHub-hosted runner and in awaywork's image, whatever the config says; the action or the [Dockerfile](../scheduling/container.md) makes the user. - **One developer per worker user:** the `chown` line reaches anyone's awaywork clones. ## In a container ```yaml isolation: container repos: acme/api: image: ghcr.io/acme/api-dev:latest # what setup, the agent and the checks run in setup: pnpm i --frozen-lockfile ``` The one for your Mac, and for more than a user hides on Linux. Every setup, agent run and check starts a fresh container of the repo's `image` (Docker Desktop, Colima or Linux's engine). It mounts the clone, at its own path, and the skills plugin read-only, nothing else: no home, no docker socket. It runs as your uid and gid, every capability dropped, `no-new-privileges`. `doctor` checks Docker, each image (pulling it if missing, failing if it can't) and the agents' CLIs in it: ```text config-doctor-container ✔ isolation: container: setup, the agent and the checks run in a fresh container of the repo's image (Docker 27.4.0) ✔ isolation: acme/leak: awaywork-leak has claude 2.1.290 (Claude Code stand-in), codex codex-cli 0.162.1 (stand-in) ``` The image is yours to build, and all the units get: - **The agent CLIs and toolchain** the repo's rules use, and the commands of its stdio [MCP servers](../config/mcp.md), which start inside. Not `gh` or `glab`: awaywork's forge calls run outside. Install system-wide, not under `/root`. awaywork skips the `ENTRYPOINT`. - **One per repo:** under `container`, a `repos` entry without `image` is an error, and a unit in a repo with no entry is skipped (`no image for acme/web: isolation: container needs one in its repos entry`). Nothing falls back to you. - **Pulled once**, by the first `docker run` or `doctor`; `docker pull` to update. - **Only the env it needs:** the image's, awaywork's placeholders, git config and prompt path, and that phase's secrets by name, never on argv. Nothing else of yours, ssh agent included. - **A `HOME` in the clone,** `.away/home`, fresh per clone: what setup puts there reaches the chain, then goes. Bake caches into the image. - **Logins:** Claude needs `CLAUDE_CODE_OAUTH_TOKEN`, Cursor `CURSOR_API_KEY` (its `HOME` is `.away/agent-home`). Codex gets your `auth.json` (`~/.codex`, `$CODEX_HOME` or `config_dir`) for the run, copied back after under its probes' lock when it's a refresh of the one lent, since a refresh rotates it ([Providers](../../providers.md#codex)). Probes and `--version` checks run on your machine. - **Clones in your home,** in the run dir, not `/tmp`: the one place Colima and Docker Desktop both share. `doctor` fails if the engine can't mount it. - **Removal:** each container is gone before awaywork reads the clone, `docker rm -f` on a timeout, the window's end or Ctrl-C. No answer from docker within a minute, and the clone is lost. `awaywork replay` runs in one too. - **Cost:** an image per repo to keep current, a few hundred ms per command on Colima, slow file sharing on macOS, and Linux only: no Xcode. ## Not a sandbox With `isolation: none`, the default on your machine and a self-hosted runner, all of the above keeps credentials out of the agent's way, not its reach. It runs as you, and its tools aren't sandboxed: Claude Code has Bash and file tools pre-approved, Codex runs `--sandbox danger-full-access` (it needs it to commit), Cursor any shell command with read and write (no `--force`, no sandbox), a command provider whatever your command does. So what it reads can leave [I, from the code]: - `~/.config/awaywork/env`, with your `GH_TOKEN` and tracker tokens. - gh's login, by unsetting the placeholders (an empty `GH_CONFIG_DIR` doesn't hide a keyring from `gh auth token`); `glab`'s keyring login needs nothing unset. Git credential helpers too: the worker's config names none, but your `~/.gitconfig` and the system one are readable, and a helper answers when run directly. - twg's login in `~/.config/twg`: the empty `TWG_CONFIG_DIR` only points twg elsewhere. Its encrypted storage, keyed from your OS keychain, is untried with the empty dir; `doctor` warns. - Your ssh keys and agent: no `SSH_AUTH_SOCK`, but the socket can be found (`launchctl getenv SSH_AUTH_SOCK` on macOS). On [Bitbucket](../config/sources.md#bitbucket-and-jira) those keys push, so a worker reading `~/.ssh` can push as you. - A VS Code terminal's git askpass (`VSCODE_GIT_*`). - On Linux, awaywork's own env under `/proc`, `GH_TOKEN` included: on a self-hosted runner, your personal access token. Also open: - **Prompt injection.** Strangers' GitHub comments are [hidden](./index.md#public-repos), but issue bodies, CI logs, check names and links, other sources' text and trusted people's quotes may be anyone's. The harness calls them data; a model can still be talked into things, with the reach above. - **Hooks across a loop's rounds:** on your repo, Claude Code loads the clone's `.claude/settings.json`, so a round can leave hooks the next runs. - **MCP secrets in Claude's Bash** and Cursor's shell, on your items ([MCP servers](./index.md#mcp-servers); fix in [#155](https://github.com/AndreaProvenzale/awaywork/issues/155)). - **Codex and Cursor read a PR author's `AGENTS.md`**, Cursor also `CLAUDE.md`, `.cursor/rules` and skills. - **A command provider's repo config** on someone else's PR, e.g. MCP servers or hooks in its config dir. So run awaywork where that's acceptable, or give it a [worker user](#a-worker-user-on-your-linux-machine) on Linux or a [container](#a-container-on-your-machine) wherever Docker runs. GitHub's hosted runners [always get a worker user](#a-worker-user-on-github-hosted-runners), as does [awaywork's image](#a-worker-user-in-awaywork-s-image). ### A worker user on your Linux machine With [`isolation: user`](./isolation.md), setup, the agent and the checks run as `awaywork`, a user you make once. As on a [hosted runner](#a-worker-user-on-github-hosted-runners), the clone is the worker's only while repo code runs, awaywork kills all the worker's processes after each phase, and its own git runs only on a clone that's yours again. Without the user or the sudoers lines, `run` refuses rather than run as you. It closes: - **Your files:** the env file, gh's `hosts.yml`, `~/.ssh`, `~/.gitconfig` and the rest of a home closed to others (`chmod o-rwx ~`; `doctor` warns until then). - **Logins in other processes:** `/proc//environ` is owner-only, your ssh agent's socket sits in a folder only you can enter, and gh's keyring answers over your session bus in `/run/user/`, closed to other users [C, not exercised: the test container has no keyring]. - **A way back in:** no sudo, cron or `at`, a `nologin` shell, so no `ssh` or `su`: once its phase is killed, nothing of it runs. - **What one unit leaves the next:** its `HOME` is a fresh `/tmp/awaywork-XXXXXX` per clone, removed with it; there's no `/home/awaywork`. An `~/.npmrc`, a Claude hook or a shim in `~/.local/bin` never reaches another repo. - **A swapped clone:** `/tmp` is sticky, so the worker could rename its folder and leave a link to yours. After each phase awaywork checks clone and home are the same folders (`lstat`, same inode) and closes them (`chmod -R u+rwX,go-rwx`, top folder `711` for the next phase; a fresh clone is `700`). Otherwise the clone is lost: no more phases, no git of awaywork's, nothing published. A lost clone keeps the worker's `.git`, config and hooks: run nothing in it, as with [replay's](../skills.md#try-an-edit-on-a-past-unit); `git fetch ` from your checkout runs nothing of it. Tried: a worker that moved its clone or `HOME` and linked one of the developer's repos there lost the clone, and that repo's `.git/config` was untouched; `chmod 777` on the clone, its `.git` and `~` left `711`, `700` and `711`, unwritable to another local user. A real run in a Debian 12 container, a leak script as the agent, the developer's home at 755. With `none` it read the env file, the home, gh's `hosts.yml` and the ssh agent, and left a detached `sleep` running after the run. With `user`, after `chmod o-rwx ~`: ``` agent: as awaywork (awaywork), HOME=/tmp/awaywork-6etN3Q, cwd=/tmp/awaywork-lLAmqr agent: ok the env file agent: ok the developer's home refused agent: ok awaywork's env /proc/10571/environ agent: ok any /proc/*/environ agent: ok gh's hosts.yml agent: ok the ssh agent at /tmp/ssh-*/agent.* agent: ok sudo -n true refused agent: left a detached sleep 4242 behind ``` Nothing of the worker's was left running, nor any folder. (At 755, the home and `hosts.yml` still leaked.) The sudoers lines give your user no more than that. The first runs anything as the worker, which can do nothing you can't. The second is all awaywork runs as root, `chown -R` of a clone to the worker and back; a loose rule there would let anything running as you chown `/etc/shadow` to the worker. So: - **No `*`:** sudoers' `*` matches spaces, so `/tmp/awaywork-*` also allowed `/tmp/awaywork-x /etc/hostname`, which chowned it. Six character classes match six letters or digits, on any sudo. - **Straight under `/tmp`:** a deeper rule loses to a link; with `/tmp/awaywork-??????/work/*` allowed, a `work -> /etc` link chowned `/etc/passwd`. - **No link followed:** `-h -P` changes links themselves, and `doctor` and `run` refuse a kernel with `fs.protected_hardlinks` off. - **No env:** the root line has no `SETENV`, so sudo refuses `--preserve-env=PATH` and drops `LD_PRELOAD`. Tried with only those two lines (sudo 1.9.13p3): - Everything but the exact two commands asked for a password: `/etc/shadow`, a seventh character, `../`, a space, a second path, `-L`, `root:`, `-u root`, `-g root`. - The same words split differently passed sudo, and `chown` refused them. - Through links to `/etc` and `/etc/shadow`, only the links changed; `ln /etc/shadow` failed. - 30 rounds of a loop swapping folders for links to `/etc` changed nothing there. - No file under `/etc` ended up anyone's but root's. Still open: - **The network.** - **The worker's own login,** in its env as it must be: `CLAUDE_CODE_OAUTH_TOKEN`, `CURSOR_API_KEY`, or a copy of your Codex `auth.json` in its `HOME` ([the lent Codex login](#the-lent-codex-login)). - **`/tmp`:** every unit is the same user, so a file left in `/tmp` or `/var/tmp` outside its folders is there for a later unit. Nothing runs it unless something looks. - **The machine:** localhost services answer it, and anything world-writable is a place to plant a binary you run later. Hosted runners clear that bit; on your machine `find / -xdev -perm -o+w ! -type l ! -perm -1000` lists them. - **Other developers:** the `chown` line reaches any awaywork clone, so one developer per worker user. #### The lent Codex login awaywork has no Codex login of its own and lends the file your `codex` uses (`$CODEX_HOME/auth.json`, env file included, else `~/.codex/auth.json`). Since a refresh rotates it, awaywork saves back only a refresh (tokens and `last_refresh`), refused when the mode, API key or account differs, and only while yours is still the one lent. The tokens are the worker's word, though: a hostile worker can return another account's, labelled with your `account_id`, and your Codex then runs on that account, awaywork's runs and your own sessions, prompts and code included, until you `codex login` again. Codex being experimental, that ships as is; [#315](https://github.com/AndreaProvenzale/awaywork/issues/315) tracks a login it can't swap. ### A container on your machine With [`isolation: container`](#in-a-container), every setup, agent run and check runs in a fresh container of the repo's image that sees the clone and the skills plugin and nothing else. That's not a list of things to hide, so it closes more, on a Mac too: - **Your files:** only the clone in your home is mounted: no env file, `hosts.yml`, `~/.ssh`, `~/.gitconfig` or keychain files, and no `security` to ask the keychain. - **Other processes:** its own `/proc`, never awaywork's env. Neither the ssh agent's socket nor the docker socket is mounted, so no container of its own with your disk. - **Root inside:** your uid and gid, no capabilities, `no-new-privileges`, so a setuid `sudo` goes nowhere, even with `NOPASSWD: ALL`. Setuid files it makes are yours and Colima keeps the bit, so awaywork drops it when copying `.away` out, and the run dir is `700`. - **Your env:** the image's, awaywork's placeholders and git config, and that phase's secrets by name (`-e NAME`, never on argv), nothing else of yours. - **Nothing outlives its phase:** a container goes with its command, and awaywork `docker rm -f`s it on a timeout, the window's end, Ctrl-C or the watchdog. If one may still be running, listed or docker silent for a minute, the clone is lost, as with the worker user: nothing is published from it, awaywork leaves it alone and saves no Codex login back from it. A lost clone may still have a container: run nothing in it. A real run on a Mac (Colima 0.8.1, Docker 27.4.0), a leak script as setup, `when` check and Claude stand-in, in an image granting `NOPASSWD: ALL`: ``` agent: as dev (501:20), HOME=~/.local/state/awaywork/runs/20261021-003248/work/leak-1/.away/home agent: ok the env file agent: ok your home holds only the folder on the clone's path agent: ok ~/.ssh agent: ok the keychain (no security, no ~/Library/Keychains) agent: ok no ssh agent socket agent: ok /proc/*/environ agent: ok no docker socket found agent: info mounts from the host: …/work/leak-1 (rw) …/plugins/leak-1 (ro) agent: ok sudo -n true refused (the image allows everyone NOPASSWD: ALL) agent: ok git credential fill has nothing [...] agent: left a detached sleep 4242 behind ``` No container or `sleep` was left; an agent hung past `unit_minutes: 1` was stopped at 64 s. Still open: - **The network:** what it reads, it can send. Colima's gateway didn't reach a Mac `localhost` service here, but your LAN is reachable, and Docker Desktop's `host.docker.internal` is your Mac. Domain allowlists: [#316](https://github.com/AndreaProvenzale/awaywork/issues/316). - **The agent's secrets:** its token, Codex's `auth.json` for the run, its MCP servers' secrets. The Codex login it hands back has [the worker user's](#the-lent-codex-login) limits. - **The clone:** what a phase leaves is what checks run and awaywork publishes, though awaywork restores git config and refuses links after each phase. - **The image:** a token baked into a layer or `.npmrc` reaches every unit. - **The kernel:** a container shares the VM's kernel, on Linux your machine's: it's a namespace, not a VM, and a kernel bug is a way out. On a Mac that lands in the VM, and Colima mounts your whole home read-write (`colima ssh -- mount` shows `/Users/` over sshfs, `rw`), so the VM is no second wall. - **Memory on a Mac:** the watchdog watches your Mac, not the VM, whose OOM killer can take `dockerd` and lose the clone. The disk floor holds. ### A worker user on GitHub-hosted runners A hosted runner is where your classic token meets issue text unattended, with passwordless sudo, so repo code never runs as you there. Setup, the agent and the checks run as `awaywork`, made by the action, always: - **A user with nothing:** no sudo, `docker`, other groups or crontab. - **A locked runner:** the runner's home is `chmod 700`, with your checkout, step scripts, `RUNNER_TEMP` and `GITHUB_ENV` (the env of later steps). - **Nothing world-writable:** the action clears the bit on some 800,000 paths (`/usr/local/bin` and the toolcache's `node` among them, where the worker could plant a `gh` awaywork runs with your token) and on the default ACLs of some 36,000 folders. Under two minutes, mostly disk reads. - **A safe kernel:** it refuses one with `fs.protected_hardlinks` or `fs.protected_symlinks` off (both on in GitHub's image). - **Only the clone is shared:** clones, per-clone `HOME`s and the plugin live in `/tmp`. A clone is the worker's while repo code runs, then the runner's again, checked to be the same folder and closed, before awaywork's git touches it. - **awaywork's git** uses the clone's own `.git` only, never a repo above it in `/tmp`, and only once its config is awaywork's again. A `.git` turned into a link, with a symlink outside its hooks, or borrowing another repo's objects gets no more git and publishes nothing. A clone the runner can't take back (a failing `chown`) ends its chain unpublished and untouched. - **Nothing outlives its phase:** awaywork kills all the worker's processes after each one. - **No value on argv:** env goes through sudo by name (`sudo --preserve-env=PATH,CLAUDE_CODE_OAUTH_TOKEN,…`), and pushes use gh's credential helper, never a token in a URL. - **No silent fallback:** without the user, say `awaywork run` without the action, it refuses: `can't run as the awaywork user on this GitHub-hosted runner: run awaywork through its action, which creates it`. A spike tried every leak above from setup, a `when` check and two agent runs, with `GITHUB_TOKEN` as `GH_TOKEN`. As `runner`, all of them worked: awaywork's `/proc` env, `RUNNER_TEMP`, `GITHUB_ENV`, sudo, docker, crontab, planting in the toolcache, a leftover process. As the worker user: ``` agent: as awaywork (1 groups: awaywork), HOME=/home/awaywork agent: ok awaywork's env /proc/2890/environ agent: ok any /proc/*/cmdline agent: ok gh auth token, GH_TOKEN and GH_CONFIG_DIR unset agent: ok RUNNER_TEMP refused agent: ok GITHUB_ENV not writable agent: ok sudo -n true refused agent: ok docker refused agent: ok crontab refused agent: ok no PATH or toolchain dir writable agent: ok nothing left from an earlier phase ``` That spike predates per-clone `HOME`s, now a `/tmp/awaywork-XXXXXX`. Still open: the agent's own secrets (its token, its MCP servers'), the network, the clone, what's world-readable (toolchains, `/tmp`, every command line but not env), and the disk and memory, where the watchdog stops the unit. ### A worker user in awaywork's image The [container](../scheduling/container.md) for `awaywork watch` is built from awaywork's Dockerfile, which sets `AWAYWORK_IMAGE=1`: setup, the agent and the checks always run as `awaywork`, whatever `isolation` says. awaywork runs as `away`, whose env holds `GH_TOKEN`, so an agent running as `away` could read it from `/proc/1/environ`. Another image without the user refuses: `can't run as the awaywork user in this image: build it from awaywork's Dockerfile, which creates it`. At build, the Dockerfile makes the worker as the action does: no sudo, cron or `at`, `nologin`, no home, `/home/away` `700`, world-writable bits and default ACLs cleared. `away` gets the two [`isolation: user`](#a-worker-user-on-your-linux-machine) sudoers lines, kept equal by a test. The kernel settings are the host's, checked at the daemon's first run. A stand-in agent: ``` agent: as awaywork (awaywork), HOME=/tmp/awaywork-W8BnHa agent: ok /proc/1/environ refused (/usr/bin/tini -- /usr/local/bin/awaywork) agent: ok /home/away refused agent: ok sudo -n true refused agent: GH_TOKEN=none agent: committed smoke.txt ``` It closes and leaves open what the [local worker user](#a-worker-user-on-your-linux-machine) does. On a shared box: - **One container per developer:** everything is in one `GH_TOKEN`'s name, and the `chown` line is for one developer. A company box runs a container, control repo and token per person. - **Host root reads every token:** `docker inspect` shows env, so a Coolify admin or anyone with `docker` has them all. Put your token only where you'd trust root. - **Never mount the Docker socket:** whatever reaches it is root on the host, and every container's tokens with it. # What awaywork publishes Integrity rules never move, whatever the config says: - The model never picks where its work goes and never holds a write token: awaywork does every write, where the config says. - Nothing force-pushes, merges, approves, pushes onto someone else's branch, or comments on someone's behalf. - awaywork pushes only [branches that didn't exist](#new-branches-only), unless a rule opts in to fast-forwarding [your own PR's branch](#onto-your-own-pr) (`deliver: push`), or it fast-forwards [its own draft](#its-own-draft), while the draft's head is still the one it left, to continue work it started. - [PRs it opens](#prs-it-opens) are drafts unless the rule says `deliver: pr`, and go only into the default branch or your own PR's branch. - It [writes to a tracker](#tracker-writes) only on items a rule matched as yours, its comments marked as awaywork's, at a destination the config names, and puts [files](#files-in-the-store) only in the store the config names. - It opens a [new issue or page](#thread-issues) only as a rule's thread, one per item a rule matched as yours, assigned to you, at the host the config names, and closes only its own threads. - A review it leaves (`deliver: pending_review`) [stays your pending one](#reports-and-pending-reviews), which only you see until you submit it. - Every PR and pushed commit [says it came from awaywork](#the-trailer). Reach, how loud a PR is, is the rule's choice and quiet by default: a draft that pings nobody, unless a rule opts in to a ready PR or a push onto your PR. On someone else's PR there's only a report, or with `deliver: pending_review` your pending review, both seen by you alone. [Where results go](../results.md) lays out who sees what. ## New branches only - Only a branch that doesn't exist on the remote, checked before the worker and again at push; a taken name gets `-2` to `-10`. Never someone else's branch, never a rewrite. A plan's task in [another repo](../workflows/tasks.md#across-repos) pushes there the same way, only to a repo the config names. - Every push, the exceptions below included, uses `--force-with-lease` as a compare-and-swap, not to force: the remote takes the new head only while the branch is absent, or at the clone's start commit, and awaywork checks the head descends from it. A rewrite, or bringing back commits you dropped meanwhile, is refused. - Nothing is published when the new commits include a merge (but `sync`'s own) or the branch lost the start commit: the harness forbids both, and either carries commits that aren't the unit's. ## Onto your own PR `deliver: push` adds commits to **your own open PR's** branch as a fast-forward, while the branch is where the clone started. Right before the push awaywork rereads the PR and refuses if it isn't yours, is closed or merged, has auto-merge on, or its branch is on a fork, the default branch, protected, or another person's open PR's branch too. It's refused on someone else's PR and on items without a PR of yours. The commits land unreviewed where your CI and teammates run, so opt in only where that's fine. Under [`sync`](../config/chains.md#sync) the push is one merge of the PR's base, which awaywork made before any repo code ran: - The head must still be exactly that commit; the base's commits go as they are, the trailer on the merge alone. An amend or a commit on top is refused. - On a conflict, the merge carries the model's resolution, committed by awaywork from the worker's tree on exactly the PR's and base's heads, only the files git's merge holds, so nothing untracked gets in. It went through `verify` and the gates, which read the model's changes on top of git's merge and always the conflicted paths, and the PR is held for review. - Neither setup nor the worker merges or commits: a moved `HEAD` pushes nothing, nor does a resolution that keeps nothing of the base or reverts a file the base changed outside the conflicts. ## Its own draft To [continue](../workflows/questions.md#continue) its work, the next run fast-forwards awaywork's **own draft** the same way, only while its head is the commit awaywork left and you, or awaywork as you, edited the body last. - That last check is why the footer can be trusted: someone with write access who pushed onto your draft could otherwise point it at their commit, and awaywork would build on it, ungated, and mark it ready. Anyone's footer can still hold the draft's dependents back, which only makes them wait. - Right before the push, made even with no new commits, it rereads the PR with the same checks, so a draft that moved, was marked ready, or had its body edited by someone else since the plan is untouched. - It marks the draft ready only under `deliver: pr`, `verify` and the gates reading every commit on it: the agent's word can hold a draft back, never push it past a gate. Where [the rule](../workflows/questions.md#continue) doesn't hold, finish it by hand. ## PRs it opens - One PR per unit, a **draft** unless `deliver: pr`, into the default branch or your own PR's branch (including an awaywork PR a rule naming `ours` took up). Never into someone else's PR or a fork's: those get a report or pending review. A fork PR is cloned from the repo's pull ref, and the unit fails if that ref moved past the head its source read; nothing goes to the fork. - It never merges, approves or requests reviewers, and writes to PRs and issues only as [Tracker writes](#tracker-writes) says. Your [`notify` command](../results.md#pr-labels-reviewers-and-ready-for-review) can, as you. - A `deliver: pr` PR opens ready, so GitHub asks the code owners. It opens as a draft when a step failed or stopped or the run ended first, a step asked with `ask` or said `continue`, `verify` failed, a loop ended before its `until`, or a [gate](../workflows/gates.md) fired. - A PR stacked on an awaywork PR joins that PR's GitHub stack, or starts one, only while that PR tops its stack or has none. Linking edits neither, but GitHub merges stacks bottom-up (merging one merges those below) and rebases the ones above. - Only when the agent wrote `pr.md`, for a PR or a push, and only once the chain ended. A run killed before that publishes nothing more. ## The PR body - After `ask` or `continue`, awaywork puts `Status: ask, questions on ` or `Status: continue (1 of 3): …` under the title, above any gates, and records status, count, pushed head and where the draft's commits start in the footer. The line is for you; the footer is the record, since the model could write such a line but nothing after the footer. - With gates, the top of the body is awaywork's: the decision card when one fired, else `Gates: none fired (…)`, ending in a `---` rule. The agent's text, any card it made up included, is below the rule: that's all it guarantees. A `<` in a gate's line is escaped, so it can't hide the rest in a comment. ## Gates and judges Gates fail closed. A `command` gate fires when it can't run, times out, is stopped or isn't run on an untrusted author's PR, and, without running, when a file it names differs from the chain's start. It runs afresh with `$AWAYWORK_FROM` and a read-only copy of the steps' `.away/out/`, ignoring replace refs, reading attributes from the start and a fresh global config the steps can't edit, unlike `verify`, `when` and `until`. It still runs repo code the agent may have changed, and a process that outlived the watchdog could swap the script after the comparison, so `changed` gates are the trustworthy kind ([Gates](../workflows/gates.md)). A `skill` gate's judge is a model and can be fooled, which only makes it fail to fire: it adds lines to the card, never clears a script's gate. - Its git sees the commits as pushed. On a plan or report it reads a read-only copy of what the step left, and the post is what the step left either way. - If it leaves a commit, a moved HEAD, an uncommitted change or a changed `.away/out/` file, awaywork restores the clone and fires the gate; an error, stop or failure to run fires it too. Only a process that outlived the watchdog could change a file after that check. - It runs as on [someone else's PR](./index.md#other-people-s-prs-are-data), the clone being the agent's: its skill is yours or built in, never the repo's, though Codex still reads the `AGENTS.md` and skills the agent could edit ([Judges](../workflows/gates.md#judges)). ## Tracker writes Tracker writes are awaywork's, never the agent's. The model writes only the text, never where it goes. ### What it writes Only these: - A step's [questions](../workflows/questions.md#questions), as one comment on the first of the unit's items from the rule's source that is **yours**; a `deliver: plan` rule's [plan](../workflows/plans.md) the same way, once `plan.yml` passed its checks. - Your `approve` reply, when you run `awaywork approve` on an item whose plan or result waits, through the same check. - A [waiting](../workflows/posts.md#posts-that-wait) report's result, posted as `on_done` does; the move it holds happens only after your own approving reply, while the card is still where the post saw it. A teammate's comment never answers a post. - What [`on_done`](../config/queue-rules.md#on-done) asks: once the chain published, the unit's result on each of its items that is yours, from the rule's source, then a move to the status, state, section or label the config names. A chain that left no commits writes neither when one of the [rule's own gates](../workflows/gates.md#gates-of-a-rule-s-own) fired. - A plan's [task](../workflows/tasks.md) isn't a tracker item: its questions, a [report task](../workflows/tasks.md#report-tasks)'s result and its rule's `on_done` go to its card, whose ownership is reread every run. The item's description can steer that text, mentions included (on GitHub it isn't filtered by author like comments), the same exposure as a PR body. ### On whose items - Yours is your own PR, an issue assigned to you, a Jira, Linear or Asana item (read only when assigned to you) or a custom source's item. - Never a PR you were asked to review, an item only an extra `search` found, a new item but a [thread issue](#thread-issues), or a destination a model picked. - It writes as you, with the source's token (GitHub through `gh`, trackers with their `token_env`, which needs write scope). A failed write is reported, never fails the unit. Right before writing, awaywork checks the item is still yours: | Source | Writes | Before each write | Knows who edited a comment last | | --- | --- | --- | --- | | GitHub | Yes | An issue is reread and refused once no longer assigned to you; a PR's author can't change, so your PR stays yours | Yes | | Jira, Linear, Asana | Yes | Reread and refused once no longer assigned to you | Jira yes; Linear and Asana no | | Custom source | Through its `write` | Its `write` checks that itself | No | | A custom source without `write` | No: leaves items alone | — | — | ### How its comments are marked - Its comments end with `— awaywork`, counted as its own only when that line comes from your account. - So pasting the line can't fake an item waiting for an answer, approve a plan, or put text under the `awaywork's questions` or `awaywork's plan` heading the agent reads. - Nor can editing your comment where the source knows who edited (the table above): there a comment someone else edited last isn't yours. Where it doesn't, a teammate allowed to edit yours could add the line. ## Files in the store Files go only to the [store](../workflows/store.md) the config names, put by awaywork's call of your command after the step. The model picks neither store nor URL, and a post links only what awaywork put, below the model's text. Files come back only off awaywork's own posts and the PRs it opened for you, into the item's context as data, outside the tracked tree. Lasting links, redacting what it stores and skipping what shouldn't go there are your script's job; a skip keeps nothing off the post. ## Thread issues A new issue only as a rule's [thread](../workflows/posts.md#threads): one per item a `thread: github` rule matched as yours, in its repo on the configured forge, assigned to you, through the `gh` login PRs use. - awaywork's posts for the item go there, marked as usual, and only your replies there are answers. - It finds the issue again only among open issues your account opened whose marker names the item, so someone else's issue with the marker is never taken for it (it stays an ordinary item). - It closes only that issue, with a marked comment, once the card's source says it's done. Whether the card is yours is read off the card, every run. - A [custom thread host](../config/custom-sources.md#thread-hosts) opens, comments on and closes threads through its `write`, and says which replies are yours with `mine`; awaywork sends it only the card's key, title and link and its own posts. It must keep each post one comment, since part of a post is the model's text and a split-off piece could pass for your reply. ## Publishing from the clone awaywork fetches the clone's HEAD into a repo of its own, adds the trailer, diffs and pushes there. The unit's commits are those after what the clone held before setup. - The clone's config, attributes, hooks and refs, which the agent can write, don't steer the push, pick which commits get the trailer, or run anything: after setup and each agent run, and around each check, awaywork restores the original config and deletes `.git/info/attributes`. - Its git runs no hook, ignores replace refs, diffs only from commits it resolved itself (so `changed` gates and `when: { changed }` see what's pushed), and runs no git in a submodule, even with your global `submodule.recurse` or `diff.submodule`. - A `.git` with a symlink outside its hooks, an alternates file, a `commondir` or `info/grafts` stops it: no more git, checks or `verify` in that clone, nothing published. - In the clone it only reads the body or report, writes `diff.patch` beside it, tops the body with the gates' section and appends the chain's notes, the items' link lines, a GitHub issue's `Closes` line (for a chain from the default branch) and a stacked PR's "merge that first" line. It refuses symlinks there and touches only regular files; a process that outlived the watchdog could still swap a path after that check. ## Reports and pending reviews - `report` delivery pushes nothing. - `pending_review` pushes nothing either: awaywork leaves the last report and the line comments from `review.json` on the PR as your **pending** review, at the commit the clone read. Only you see it, and nobody is notified, until you submit, edit or discard it. awaywork never submits, approves or requests changes. It works on any PR, forks included; on your own you can submit it only as a comment. - When a step failed or stopped or the run ended first, it delivers the report alone with the reason, so a cut-short review doesn't sit ready to submit. Likewise when GitHub refuses the review, as when you already have a pending one there, which stays. GitHub refuses a whole review over one comment off the diff, so then every comment goes in the body. ## The trailer Every PR has a footer with the run id and its items, and an `Awaywork: ` commit trailer, pushed commits too. Adding it re-creates each commit, dropping signatures: a branch requiring signed commits refuses the push. # A full config Each part has its own page under [Configuration](../guide/config/index.md); every key, with its type and default, is in the [reference](../reference/config.md). ```yaml windows: # when scheduled runs start; this one is the default - { start: "01:00", end: "07:00" } limits: # per run, every provider; unset by default units: 8 units_per_repo: 3 unit_minutes: 90 # stop a worker after this many minutes keep_runs: 30d # default; older run dirs go when run or watch starts watch: { every: 5m, debug: false } # defaults; how often run: soon rules poll, and true logs every run line in watch.log budget: # defaults; for every provider reserve: 10% # never go past 1 - reserve of any window per_run: 15% # at most this share of the long window per run session_max: 90% # stop a worker past this share of the short window present_session_max: 40% # the same, in a run outside every window fallback: { units: 4, turns: 60 } # when the quota can't be read providers: # order = routing preference - claude: { model: opus, settings: claude-worker.json } # default: the binary's own model - codex: { config: { model_reasoning_effort: high } } repos: # the only repos awaywork works in acme/*: # a bare key lists them, no settings acme/api: branch: feat/{key}-{title} setup: pnpm install --frozen-lockfile # run before the worker secrets: [NPM_TOKEN] # only setup sees these test: pnpm vitest run {files} instructions: notes/acme-api.md sources: github: {} # your PRs, reviews and issues in those repos asana: workspace: "123" token_env: ASANA_TOKEN repo_map: { "1201": acme/api } # project gid -> the repo its tasks' code lives in every: 15m # poll it for run: soon rules at most this often branch: away/{key} # default; {key} or {title} is required instructions: [notes/me.md] # every unit skills: [house-style] # every unit, next to a rule's skills; yours, in skills/ beside this file queue: # first matching rule wins; top = highest priority - skill: fix where: { type: pr, mine: true, checks: failing } branch: fix/{key} run: soon # within minutes, not only in the windows - skill: implement source: github where: { type: issue, label: awaywork, repo: acme/api } order: oldest units: 2 # at most 2 of these per run - skill: review where: { type: pr, review_requested: true, trusted: true } model: { claude: sonnet } - skill: implement source: asana where: { section: Ready, tags: backend } provider: codex skills: [our-api-conventions] # yours too instructions: notes/backend.md ``` # The morning report on your phone A [`notify`](../guide/results.md#notify) hook. A config has one `notify.command`: to run several, see [Examples](../guide/results.md#examples). `$AWAYWORK_REPORT` is markdown for GitHub, with folded `
` a phone or chat app shows as tags, so send the plain text `awaywork report` prints. Write the full path of `awaywork` there: after an `npx` install the first on your `PATH` is none or another version. ```yaml notify: # ntfy, the morning report only command: >- [ "$AWAYWORK_EVENT" = run ] || exit 0; awaywork report "$AWAYWORK_RUN_ID" | curl -sf -H "Title: awaywork $AWAYWORK_RUN_ID" --data-binary @- "https://ntfy.sh/$NTFY_TOPIC" secrets: [NTFY_TOPIC] ``` For a Slack incoming webhook, pipe the report through `jq -Rs '{text:.}'` and post it with `curl -sf -H 'Content-Type: application/json' -d @- "$SLACK_WEBHOOK_URL"`, `SLACK_WEBHOOK_URL` in `secrets`. # A Notion page per plan A [`notify`](../guide/results.md#notify) hook. A config has one `notify.command`: to run several, see [Examples](../guide/results.md#examples). The hook holds your Notion token and picks where the page goes; the model only wrote `report.md`. A sketch against Notion's [create a page](https://developers.notion.com/reference/post-page), with an integration token and a parent page shared with it: ```yaml notify: command: | set -e [ "$AWAYWORK_EVENT" = unit ] && [ "$AWAYWORK_SKILL" = plan ] || exit 0 [ "$AWAYWORK_STATUS" = done ] && [ -n "$AWAYWORK_REPORT" ] || exit 0 key=$(printf '%s\n' "$AWAYWORK_ITEM_KEY" | head -n 1) links=$(printf '%s\n' "$AWAYWORK_ITEM_URL" "$AWAYWORK_PR_URL" | sed '/^$/d; s/^/- /') body=$(jq -Rs --arg parent "$NOTION_PAGE_ID" --arg title "Plan: $key" --arg links "$links" '{ parent: { page_id: $parent }, properties: { title: { title: [{ text: { content: $title } }] } }, markdown: ($links + "\n\n" + .) }' "$AWAYWORK_REPORT") curl -sSf -o /dev/null https://api.notion.com/v1/pages \ -H "Authorization: Bearer $NOTION_TOKEN" \ -H 'Notion-Version: 2026-03-11' \ -H 'Content-Type: application/json' \ -d "$body" secrets: [NOTION_TOKEN, NOTION_PAGE_ID] ``` Swap `plan` for any skill; a committing step has the PR body in `$AWAYWORK_REPORT` and the PR in `$AWAYWORK_PR_URL`. Why a hook rather than a Notion [MCP server](../guide/config/mcp.md) the skill writes through: - **Deterministic:** the page is written whenever the step is done, in your script's shape, not when the model remembers. - **The token stays with you:** a server's secret is one the agent can read, and its tools are ones injected text can call ([Safety](../guide/safety/index.md#mcp-servers)). The hook runs after the worker; the agent never sees its secrets. - **Every PR:** MCP servers never start on someone else's PR, so a `review` of a teammate's PR would have no Notion. The hook still sees that step. # A Slack message when a review is ready A [`notify`](../guide/results.md#notify) hook. A config has one `notify.command`: to run several, see [Examples](../guide/results.md#examples). A Slack [incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks), once per finished `review` step: ```yaml notify: command: | set -e [ "$AWAYWORK_EVENT" = unit ] && [ "$AWAYWORK_SKILL" = review ] || exit 0 [ "$AWAYWORK_STATUS" = done ] || exit 0 text="Review of $AWAYWORK_ITEM_KEY${AWAYWORK_VERDICT:+, verdict $AWAYWORK_VERDICT} $AWAYWORK_ITEM_URL" body=$(jq -n --arg text "$text" '{ text: $text }') curl -sSf -o /dev/null -H 'Content-Type: application/json' -d "$body" "$SLACK_WEBHOOK_URL" secrets: [SLACK_WEBHOOK_URL] ``` ``` Review of acme/api#14, verdict approve https://github.com/acme/api/pull/14 ``` A shared channel shows it to everyone in it, so pick your reach: your own channel keeps it to you, `@` mentions ping people. # Acting on a skill's own output A [`notify`](../guide/results.md#notify) hook. A config has one `notify.command`: to run several, see [Examples](../guide/results.md#examples). Files a skill writes in `.away/out/` besides the report reach the hook in `$AWAYWORK_OUT_DIR`; awaywork doesn't read them. Say your `triage` skill writes the labels it would add to `.away/out/labels.txt`, one per line: ```yaml notify: command: | set -e [ "$AWAYWORK_EVENT" = unit ] && [ "$AWAYWORK_SKILL" = triage ] || exit 0 [ "$AWAYWORK_STATUS" = done ] && [ "$AWAYWORK_SOURCE" = github ] || exit 0 file="$AWAYWORK_OUT_DIR/labels.txt" [ -n "$AWAYWORK_OUT_DIR" ] && [ -f "$file" ] && [ ! -L "$file" ] || exit 0 for label in bug docs flaky needs-repro; do if grep -qFx "$label" "$file"; then gh issue edit "$AWAYWORK_ITEM_URL" --add-label "$label" fi done ``` The file is untrusted, written after reading others' text: the hook picks from labels it knows rather than passing lines on, reads a regular file, not a symlink, and never runs or `eval`s it. `$AWAYWORK_SOURCE` being exactly `github` also means one item, from GitHub. The folder also holds earlier [chain](../guide/config/chains.md#then) steps' files, so check `$AWAYWORK_SKILL`. # Chores A [custom source](../guide/config/custom-sources.md) to adapt. Quota left when the queue is done? A source can hand out work with no tracker item, like tests for an untested module. Print one item per chore and repo, with its rule last: ```yaml sources: chores: command: node chores.mjs fields: [chore] queue: # ...your other rules first - skill: implement source: chores where: { chore: tests } quota: { headroom: 20% } # only on spare quota ``` ```js // chores.mjs: the `description` is the brief the worker reads const chores = [{ name: 'tests', task: 'Add tests for the least tested module.' }] const repos = ['acme/api', 'acme/web'] const monday = new Date() monday.setUTCDate(monday.getUTCDate() - ((monday.getUTCDay() + 6) % 7)) const updated = `${monday.toISOString().slice(0, 10)}T00:00:00Z` console.log(JSON.stringify(repos.flatMap((repo) => chores.map(({ name, task }) => ({ id: `${name}:${repo}`, key: `chore-${name}-${repo.replace('/', '-')}`, title: `Chore: ${name} in ${repo}`, url: `https://example.com/chores/${name}`, description: task, fields: { chore: name, repo, updated }, }))))) ``` - **Attempts** skip a unit whose items haven't changed since a worker finished it ([how it works](../guide/how-it-works.md)). `updated` is the week's Monday, so a chore runs at most weekly, and not while its last draft is open; print the day's date for daily. - **Weekdays** are the script's: print nothing on days off. - **A fixed time**, Mondays at 09:00, needs no script: that's a [routine](../guide/config/sources.md#routines), each occurrence a new item and branch, so every week opens its own PR. - **`quota`** is rechecked at unit start: `plan` shows the gate and notes missing headroom, and the run skips the chore without it. - **No `link`**: a chore has no tracker page, so its drafts carry no link line. # Sentry errors A [custom source](../guide/config/custom-sources.md) to adapt. An error with a stack trace is a problem listed on an item, so `fix` takes it like a red check. A recipe to adapt, not yet run against a real Sentry: your unresolved issues with their latest stack trace, and a rule picking the loud ones. ```yaml sources: sentry: command: node sentry.mjs fields: [level, count] secrets: [SENTRY_TOKEN] # a personal auth token, read access to events queue: - skill: fix source: sentry where: { level: [error, fatal], count: { gte: 10 } } ``` ```js // sentry.mjs: your unresolved Sentry issues, one item each const org = 'acme' const repos = { api: 'acme/api', web: 'acme/web' } // Sentry project: GitHub repo const query = encodeURIComponent('is:unresolved assigned:me') async function get(path) { const res = await fetch(`https://sentry.io/api/0/${path}`, { headers: { Authorization: `Bearer ${process.env.SENTRY_TOKEN}` }, }) if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`) return res.json() } function stackTrace(event) { const exceptions = event.entries.find((e) => e.type === 'exception')?.data.values ?? [] return exceptions.map((ex) => [ `${ex.type}: ${ex.value}`, ...(ex.stacktrace?.frames ?? []).toReversed() .map((f) => ` at ${f.function ?? '?'} (${f.filename}:${f.lineNo})`), ].join('\n')).join('\n\n') } const items = [] for (const [project, repo] of Object.entries(repos)) { for (const issue of await get(`projects/${org}/${project}/issues/?query=${query}`)) { const event = await get(`organizations/${org}/issues/${issue.id}/events/latest/`) items.push({ id: issue.id, key: issue.shortId, title: issue.title, url: issue.permalink, context: `Culprit: ${issue.culprit}\n\n\`\`\`\n${stackTrace(event)}\n\`\`\``, fields: { repo, level: issue.level, count: Number(issue.count), updated: issue.lastSeen }, }) } } console.log(JSON.stringify(items)) ``` - **`assigned:me`** keeps it to your errors; drop it if the project is all yours. - **One request per issue** for the latest event: fine for a handful; cap the list or skip `context` if noisier. - **No `type`:** items are `task`s, and only tasks link to your open PRs, awaywork's included, so an error with an open draft waits for you rather than getting a second. - **`updated` is `lastSeen`:** an error firing again after you closed its draft is fair game. One `fix` can't turn into a PR gets 3 tries however often it fires, then one a week ("gave up after 3 attempts"). A hosted runner keeps no attempts, so there it's tried every run: cap it with `units:`. - **Frames** come oldest first; the script flips them so the throwing line is on top. - **EU-region org?** Use `de.sentry.io`. # A folder of Markdown files as threads A [thread host](../guide/config/custom-sources.md#thread-hosts) script. One file per card, in a folder only you write to, like a corner of your Obsidian vault. A `### ·