# bay docs, full text (~84,200 tokens)

> Every page of https://getbay.io/docs in sidebar order. The index is https://getbay.io/llms.txt; each page is also at its own .md address.

---

# Overview

> bay merges the exact change your agents checked, and it keeps the evidence in Git.

Source: https://getbay.io/docs (HTML).

bay is the merge layer for agent fleets. It is a small open-source command for several coding agents that work in one codebase. An agent opens a change, edits, and asks bay to merge it. bay builds the candidate, checks that exact result, and merges it into main. A runner finishes the merge after the agent leaves. Your repository stays an ordinary Git repository on the host you already have.

## Who bay is for

bay is for teams that run many coding agents in one codebase. It suits a fleet where no person can watch each merge. bay is not a code-review tool. No model sits in its decision path.

## Free, open and private

bay is free for any team under the MIT licence, with no paid tier and no account. It runs on your machines, and your code goes only to your own Git remote. It has no server and sends no telemetry, so your code never reaches a bay service. You or your coding agent can read every line of its source. The Security and privacy page lists everything bay sends, and where.

## The daily loop

Five commands make the daily loop. Each command gives the agent one JSON result, and a person at a terminal one plain sentence. A refusal names the reason, what you can still do, and when to retry.

```sh
bay start "fix the login page" --paths src/api   # open a view of the paths this change needs
# ... the agent edits ...
bay merge                                        # submit and return; a runner checks and merges it
bay status                                       # what each change does now, and why it waits
bay status --since                               # what changed that affects my work
bay undo c7k2m4qa                                # a new change that undoes one merge
```

## What you can count on

- What bay merges is exactly what it checked, unless you turn on batching or footprint reuse.
- Unknown is never a pass.
- The agent can leave after `bay merge` while a runner runs.
- A retry never merges twice.
- Every refusal says what to do next.
- Git stays the only store.

## Read next

Start with Install, then Quickstart and Concepts. The sidebar lists every page.

---

# Several agents, one repository

> How several coding agents share one Git repository without races, double merges or unchecked merges.

Source: https://getbay.io/docs/several-agents (HTML).

Give each agent its own change and its own view, and let one process merge changes into main. With bay, an agent submits a change once. A runner checks the exact candidate and merges it. Agents never push or merge main by hand, and every refusal names the next step.

## What goes wrong without it

- Two agents push at once. One push fails, and its agent retries, merges or force-pushes.
- A check passes on a branch, but main moved before the merge. What merges was never checked.
- A retry after a lost reply merges the same work twice.
- An agent edits a file that another change just changed, and nobody tells it until a merge conflict.

## What bay does

| Need | bay |
|---|---|
| Work apart | `bay start <intent> --paths <paths>` opens a change and a sparse view of its paths. The intent says what the work is, in a few words. |
| A branch made outside bay | `bay start <intent> --paths <paths> --from <branch>` takes it in as one change, checked like any other. |
| Work that needs other work first | `bay start <intent> --paths <paths> --after <change>` merges a change only after the named change merged. |
| Submit once | `bay merge` returns at once, and a runner finishes the merge after the agent leaves. `bay merge --wait` waits for the outcome. |
| Merge what was checked | The runner checks the exact candidate, and bay merges only that candidate. |
| Main moved meanwhile | `MAIN_MOVED`: bay retries once more, then joins the queue. |
| A conflict in the queue | `bay status` names a conflict with main while the change waits, so its agent can repair it early. |
| No double merge | bay records each step by op id, so a crash loses no work and a retry never merges twice. |
| A stale input | `INPUT_CHANGED` refuses the merge until the agent reads the new version and checks again. |
| See the others | `bay status --fleet` shows every machine and session. `bay status --since` lists what changed. |
| Ask another agent | `bay talk` asks one agent for one change, decision or check, with a reply time. |
| Know why it merged | Evidence of every check stays in Git, signed once keys are enrolled. |

## Limits

- Check time caps how many changes merge a day. Merge more changes a day gives the limit.
- Ready builds cover macOS and Linux. On Windows, build from source, or run the Linux build inside WSL.
- Early refusals in hooks need Claude Code or Codex; with other agents, a stale change still cannot merge.
- Without the hook or a protected main, an agent can still push main around bay.
- Security and privacy says what bay guards, and when.

## Try it

Install bay, then follow the Quickstart to merge a first change.

```sh
curl -fsSL https://getbay.io/install.sh | sh
```

---

# Adopt bay step by step

> Try bay in small steps on your own repository, score the trial, and switch it off if needed.

Source: https://getbay.io/docs/adopt (HTML).

Adopt bay in small steps, so you can undo every step. Check each machine first, then let bay merge into a scratch copy of main. Next, give bay one small repository or stream, and then main itself, one stream at a time. Write down how you will judge the trial before it starts.

bay keeps its state in Git refs, and every merge is a plain Git commit on main. So switching bay off removes a few settings and deletes no history.

## When to use this page

- **A first trial**: you want to see bay on real changes before it merges into your main.
- **A team decision**: you need numbers that show whether bay helps your team.
- **A way back**: you want to know how to switch bay off before you switch it on.

## The adoption ladder

Climb one rung at a time, and climb only when the scorecard says so. From each rung you can step back to the one below it.

| Rung | What runs | Risk to your main |
|---|---|---|
| 0. Readiness | `bay doctor` on each machine. Nothing merges. | None |
| 1. Beside your flow | bay checks and merges real changes into a scratch copy of main. Your current flow keeps merging main. | None: bay never writes your main |
| 2. Small | bay merges one non-critical repository or stream. | That repository or stream only |
| 3. Main | bay merges main, one stream at a time. The old tool retires last. | Earned by rungs 1 and 2 |

### Rung 0: check each machine

Install bay and run `bay doctor` on each machine that runs agents or a runner. Fix each red row before you go on. Each red row names its fix.

```sh
bay doctor
```

### Rung 1: merge beside your flow

Make a scratch copy of main, and let bay merge into that copy only. The cleanest copy is a separate repository, for example a new host from `bay host init`. Push main there, then commit `.bay/config.toml` and `.bay/policy.toml` to the copy. Agents in a clone of the copy start and merge changes as usual. Your real main never sees them. Send a share of real tasks through the copy, and compare the outcome with your current flow. Make a fresh copy when it falls far behind main.

```sh
# on the host: an empty repository for the trial
bay host init /srv/git/app-trial.git --main main

# in a fresh clone of your repository
git remote add trial ssh://git.example.com/srv/git/app-trial.git
git push trial main
# commit .bay/config.toml and .bay/policy.toml, push them to trial, then
bay install --main main --remote trial
```

> On a forge, a scratch branch on your current remote works too, for example `bay-trial`.
> Run `bay install --main bay-trial` in its own clone, and name the same branch in its config.
> bay refuses a config whose `main` differs from the clone's.
> bay's own refs, under `refs/bay/` and `refs/notes/bay/`, then go to your current remote as well.

### Rung 2: one small stream

Pick one repository or stream that nothing urgent depends on. Follow the Quickstart on its real main. On a forge, let only the runner's identity push to that main.

### Rung 3: main, stream by stream

Move one team or one group of agents onto bay at a time. On a forge, or a bay-hosted main without a key registry, commits from your old flow still reach main. `bay status --since --merged` marks each of those commits with `outside_bay`. Without the agent hook, `bay doctor` is then red with `HOOKS_MISSING`, and counts those commits. `bay start <intent> --paths <paths> --from <branch>` takes in a branch from your old flow as one change. Retire the old tool when no stream uses it.

## Score the trial

Write the scorecard before the trial starts, with a target for each measure. Measure the same weeks of your current flow, so you compare like with like. bay records the times and the check cost. People count interventions, wrongful blocks and recovery.

```sh
git fetch origin 'refs/bay/log/*:refs/bay/log/*' 'refs/notes/bay/*:refs/notes/bay/*'
git log --format='%H %s' refs/bay/log/<runner>                # one terminal record per operation, named by its op id
bay status <change>                                            # operation.timing, merge_cost and operation.cost
```

| Measure | How to read it |
|---|---|
| Time from a green check to merged, p50 and p95 | Per merged change: `operation.timing` in `bay status <change>`. `merged_at` less the last `checks[].finished_at` is the wait. |
| Queue wait and time to merge, p50 and p95 | From the same timing, in seconds: `queue_wait_s` from queue place to the head, `check_s` for the checks, and `total_s` from submit to merged. |
| Human interventions per 100 merges | Count by hand each time a person acted so that a change could merge. Each refusal in `bay status` names its reason code, which helps the count. |
| Wrongful blocks | Review each refusal by hand. A refusal of a change that was correct and current counts as wrongful. |
| Recovery time | Count by hand the time from a broken main or a stuck queue to the next merge. `bay status --lineage` lists the `Reverts:` and `Fixes:` pairs on main. |
| Check compute per accepted change | `bay status <change>` shows `merge_cost.worker_seconds`, the check time on the merged result. `operation.cost` also counts discarded attempts. |

## Switch bay off

Switching bay off takes a few steps and deletes no history. Afterwards, a plain `git push` to main works again.

1. **Stop the runners** Stop every `bay runner run` through its supervisor. A change still in the queue then stays unmerged. Its record stays under `refs/bay/changes/`.

2. **Open main on a host from `bay host init`** Remove bay's two hooks and its main branch name from the host's Git config. Then Git on the host applies only its own rules again.

```sh
git --git-dir=/srv/git/app.git config --remove-section hook.bay-trunk
git --git-dir=/srv/git/app.git config --remove-section hook.bay-scope
git --git-dir=/srv/git/app.git config --unset bay.main
```

3. **Open main on a forge** Remove the branch protection or ruleset that lets only the runner's identity push to main. Keep any rule that refuses a force push to main.

4. **Clean each agent machine** Follow the uninstall steps on the Install page, in each repository and then on the machine. Remove bay's hook entries first. While they stay, bay's hook refuses an agent's `git push` of main. When `.claude/settings.json` sits on main, push that change to main too.

5. **Push without bay** Push one commit to main with plain Git, and confirm that the remote takes it.

```sh
git push origin main
```

> The other settings from `bay host init` are plain Git settings, so they can stay.
> The host also keeps the `reftable` ref format, which Git reads as usual.
> Without bay's main hook, the host takes a force push of main.
> To refuse one, set `receive.denyNonFastForwards` to `true` on the host.

## What stays after you switch off

- Every merge stays on main as a plain Git commit.
- Evidence stays under `refs/notes/bay/evidence/`, and `git notes` reads it.
- Change records and runner logs stay under `refs/bay/`.
- No step above deletes a ref. Keep bay's refs on the remote, so the record stays whole.

## Not built yet

- A shadow runner that checks real changes and records evidence, but never merges. Rung 1 uses a scratch copy instead.
- One command that reports the scorecard measures.

## Limits

- Check time caps how many changes merge a day. Merge more changes a day gives the limit.

---

# Run bay without a runner

> A small team can merge with bay from its own machines, with no runner to host or keep running.

Source: https://getbay.io/docs/no-runner (HTML).

A small team can use bay with no runner at all. `bay merge --wait` runs the whole merge on the machine that asks for it. It builds the candidate, runs the checks, and pushes the result to main. Nothing else has to stay on, and your clone stays on your own machines.

This suits a few people with a few changes a day. A fleet of agents that should submit and leave still needs a runner.

## When to use it

- **A small team**: two or three people, each merging their own work from their own machine.
- **A private repository**: you would rather not clone it onto a shared machine.
- **A first trial**: try bay before you decide where a runner should run.

## Merge from your machine

Run `bay merge --wait` from the change's view. It returns the real outcome: merged, refused with a reason, or failed. Checks run in the sandbox on your machine: `bwrap` on Linux, `sandbox-exec` on macOS. Without a key registry, the merge is unsigned, and results say `trust: unsigned`. Leave `[runner] command` out of `.bay/config.toml`. With it set, `bay merge --wait` first submits to that command, and fails when the command cannot take it.

```sh
bay merge --wait
```

> While main is held, `bay merge --wait` returns `queued` with `MAIN_HELD`, and merges nothing.
> Run it again once the hold clears. A fix or an undo merges during a hold.

## Save edits from your main checkout

`bay save <intent>` merges the edits in your main checkout, the folder you cloned, as one change. It suits a person who edits files in place, with no `bay start` and no view. It takes every changed, new and deleted file, except files Git ignores. It opens a change with those files as its paths, and never commits on your branch. Then it runs `bay merge --wait`, so it merges the same way, with or without a runner. After the merge, your checkout fast-forwards to the new main when nothing is in the way.

```sh
bay save "fix the footer" --message "Footer links fit on a phone"   # --message is optional
```

> The intent says what the work is, in a few words, or gives a card id.
> `--message` sets the first draft's message. The commit on main keeps bay's own message.
> A save with words whose change already merged opens a new change for them.
> If your checkout is off the main branch, or files changed during the save, it stays where it was.
> Then `detail.checkout` says why, and main already holds the saved edits.

### When bay refuses a save

A refused save leaves every file in your checkout exactly as it was. When it opened a change, the result names it under `change`, and the change stays open. Run `bay save` again with the same words to go on. It reuses that open change. A change awaiting approval returns `APPROVAL_PENDING` and `detail.queued`. Once approved, a runner merges it; without one, save again. When main changed the same lines, it refuses with `MERGE_CONFLICT`. Bring those files up to main, then redo your edits.

- It runs only in the main checkout, never in a change's view or another worktree. In a view, run `bay merge`.
- It refuses while a merge, rebase, cherry-pick or revert is under way.
- It refuses a file with an unresolved conflict, and a submodule.
- It refuses when your `HEAD` holds a commit that main lacks.
- Each of these fails before bay opens a change, and changes nothing.
- With nothing to save, it returns `ok` and merges nothing.

## If your machine sleeps

The push to main is one atomic step, so main never gets half a merge. When the machine wakes, `bay merge --wait` reads the change's record and goes on. If main moved, it checks again. If its own lease ran out, it takes it back. If it slept after the push, bay reads the first result and never merges twice. Until the machine wakes, the change waits, unless a runner also runs, which may finish it for you.

## Approvals without a runner

An approval counts only with a key registry and an enrolled approver key. With `[policy] signing = "approvals"` in `.bay/policy.toml` on main, only approvers need keys. Then merging machines need none, and merges and check records run unsigned. Keys and the registry explains this mode. Require a person's approval shows how a person approves.

## When to add a runner

- **Agents should leave**: a runner finishes each merge after `bay merge` returns at once.
- **Machines sleep or move**: an always-on runner keeps merges going.
- **More changes a day**: the throughput settings need runners to run checks side by side.
- **Targets that follow main**: only `bay runner run` moves a target that sets `advance = true`.

> Runners shows how to run one on a machine or in your CI.

## Limits

- An operation's lease lasts `[runner] lease_s`, 60 seconds by default.

---

# Install

> Install bay with one command, then check the machine with `bay doctor`.

Source: https://getbay.io/docs/install (HTML).

Install bay with one command on macOS or Linux, on ARM or Intel. The script downloads a signed release, checks it, and puts one program in `~/.local/bin`. Then `bay install` sets up the machine and the repository, and `bay doctor` checks both.

## Install bay

**macOS and Linux**

```sh
curl -fsSL https://getbay.io/install.sh | sh
```

It installs the newest release that has a build for your machine. The script installs one program, then runs `bay install`. Inside a repository it also runs `bay doctor`. It needs no root access. It needs `curl` or `wget`, `sha256sum` or `shasum`, and `ssh-keygen` from OpenSSH 8.2 or newer.

**Windows**

```sh
git clone https://github.com/gudjon/bay.git
cd bay
cargo build --release
```

There is no ready Windows build. Build bay from source with a Rust toolchain. Or run the macOS and Linux command inside WSL, which installs the Linux build. bay's tests run on macOS and Linux, so WSL is the tested path. Windows has no check sandbox, so a check fails unless global Git config sets `bay.executorSandbox` to `none`. Then a check runs with your account's full access, network included. A `#!` check script for `sh` or `bash` runs through Git for Windows' `sh.exe`; other `#!` scripts do not start.

**From source**

```sh
git clone https://github.com/gudjon/bay.git
cd bay
cargo build --release
./target/release/bay install
```

You need a Rust toolchain. `cargo build --target <target>` builds bay for other targets too. `bay install` places the program at `~/.local/bin/bay`. The source is on GitHub at `github.com/gudjon/bay`.

## Where the program comes from

The script downloads bay from GitHub Releases. Each release holds one archive per target, a `VERSION` file, `SHA256SUMS`, and its SSH signature `SHA256SUMS.sig`. The bay release key, held in hardware, signs `SHA256SUMS`, which covers the archives and `VERSION`. The signed `VERSION` file names the release, so an older signed release cannot stand in for it.

```text
https://github.com/gudjon/bay/releases/download/v<version>/bay-<target>.tar.gz

targets: x86_64-apple-darwin, aarch64-apple-darwin,
         x86_64-unknown-linux-musl, aarch64-unknown-linux-musl
```

> The Apple silicon build passes a full merge test on a Mac.
> The Intel macOS build is built but not yet run on an Intel Mac.

## What the script does

1. **Detect the platform** The script reads the operating system and the CPU architecture.

2. **Pick the release** It picks the newest release with a build for your machine. `BAY_VERSION` picks an exact version instead.

3. **Verify the signature** It verifies `SHA256SUMS` against `SHA256SUMS.sig` with `ssh-keygen -Y verify`. It stops when the signature does not verify. It trusts one key: the bay release key.

4. **Check the sha256** It checks that the signed `VERSION` file names the version it picked. It compares the sha256 of the archive with the signed `SHA256SUMS`. It stops on a mismatch, and it installs nothing.

5. **Install the program** It writes the program to `~/.local/bin/bay`.

6. **Run install and doctor** It runs `bay install`, then `bay doctor` when you run it inside a repository. When `~/.local/bin` is not on `PATH`, it names the fix and stops before both.

## What `bay install` changes

`bay install` has two scopes: this machine, and the clone it runs in. `bay install --machine` writes only the machine's part, wherever it runs. `bay update` runs it this way. `bay install` inside a clone also writes that clone's part. Outside a clone, it writes the machine's part only. Hook entries go into the project's settings file, and one merge of that file into main reaches every clone. The table is bay's own list, as `bay help` shows it. Roll bay out to many machines gives the order for a fleet.

|  | `bay install` |
|---|---|
| Writes | the bay program and its skill in each Claude config directory |
| Writes | in a clone, without --machine: bay's hook entries in the project's agent settings file and this clone's bay.* Git config |
| Never | main: you commit and merge the settings file |
| Never | with --machine: hook entries, a project's settings or its Git config |

> Every clone and view gets the hook entries from main. It needs no `bay install` of its own for them.
> While the settings file holds uncommitted edits, the result sets `detail.hooks_to_land`, and `detail.next` says to merge it.
> `bay install` leaves global Git config and user-global Claude settings unchanged.
> It never removes or reorders hook entries that another tool installed.
> It creates no SSH keys.
> When `~/.local/bin` is not on `PATH`, `bay install` stops and names the fix.

### Where each part goes

The program and the skill are the machine's part. Every other row is the clone's part.

| Part | Where it goes |
|---|---|
| The program | `~/.local/bin/bay`. |
| The skill | `skills/bay/` in every Claude config directory bay finds, as the next section says. |
| bay's hook entries | `.claude/settings.json` or `.codex/hooks.json`, when `[agent] product` on main names that product. A digest of other tools' entries goes in the local Git config. |
| `bay.main` and `bay.remote` | The local Git config: from `--main` and `--remote`, else what they hold, else the remote's default branch and `origin`. |
| `bay.machine` | The local Git config, when it is unset. |
| The commit-graph settings below | The local Git config. |
| SSH signing | The local Git config, with a key registry and a key on this machine. |

### Every Claude config directory

`bay install` and `bay update` write the skill into every Claude config directory they find. Your own comes first: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. Then `~/.claude`, and each `~/.claude-*` folder that holds `settings.json`, `.claude.json`, `projects` or `skills`. On Linux, bay also takes the config directory of each running `claude` session of yours under the same `HOME`. `bay doctor` checks the copy in each one. Its `skill` row lists each directory with its state. Name any other directory in your global Git config, one value for each directory.

```sh
git config --global --add bay.claudeConfigDirs <dir>
```

> Each value is one config directory, absolute or starting with `~/`.
> bay does not look inside a named directory for more config directories.

### The commit-graph

In a clone, `bay install` also sets Git to keep a commit-graph with changed-path Bloom filters. Git reads the graph to walk history faster. Without it, bay gives the same answers, only slower. It sets `fetch.writeCommitGraph` to `true` in the local Git config, so each fetch refreshes the graph. Unless `core.commitGraph` is `false`, it writes the first graph when the clone has none with filters.

> On Git 2.52 or newer, it also sets `commitGraph.changedPaths` to `true`.
> On Git 2.46 or newer, while the clone has no filters yet, it sets `commitGraph.changedPathsVersion` to `2`.
> It leaves any of these keys that your Git config already sets.
> A graph it cannot write appears under `detail.commit_graph.write_failed`, and the install still succeeds.
> `bay install --machine` sets none of this.

## Environment variables

Set these variables for the `sh` that runs the script.

```sh
curl -fsSL https://getbay.io/install.sh | BAY_VERSION=<version> sh
```

| Variable | Effect |
|---|---|
| `BAY_VERSION` | Install this exact version. |
| `BAY_INSTALL_SKIP_SIGNATURE=1` | Check only the sha256, when `ssh-keygen` is missing. Read the warning below first. |

> `BAY_INSTALL_SKIP_SIGNATURE=1` turns off the signature check, and the script checks only the sha256.
> Then nothing shows that the program came from a bay release.
> Use it only for a local test, and never on a machine that runs agents.

## Verify

At a terminal, `bay doctor` prints one status line; a pipe or `--json` gets one JSON result instead. Exit `0` means that every required row is green. A red row names its floor and the fix. Without an agent hook, the `hooks` row reads `off`. Security and privacy says what bay then guards. If `bay --version` does not print a bay version, another `bay` comes first on your `PATH`. Put `~/.local/bin` first on your `PATH`, or remove the other `bay`.

```text
$ bay --version
$ bay doctor
bay <version> | git <version> | claude <version> | doctor ready
```

> In a repository set up for bay whose remote answers, doctor also pushes its verdict.
> Then `bay status --fleet` shows it. See what your fleet is doing explains it.

## Requirements

`bay doctor` goes red below the Git floor for the role of the machine. With Claude Code's hooks, the machine needs Claude Code 2.1.302 or newer; with Codex, Codex 0.153.2 or newer. A machine that runs checks needs a sandbox: `bwrap` on Linux. macOS has `sandbox-exec`. On Linux, `bwrap` must be able to cut off the network, because checks run without it. When AppArmor restricts user namespaces, as on Ubuntu 24.04 and later, `bwrap` cannot build the sandbox. Then the red `sandbox` row names an AppArmor profile for `bwrap` that fixes it.

| Where | Git | Why |
|---|---|---|
| An agent view | 2.40 or newer | The floor. `bay doctor` goes red below it. |
| Recommended | 2.50 or newer | Below it, `bay doctor` names the features it degrades. |
| A bay-hosted main branch | 2.54 or newer | The main hook that keeps main append-only. |
| A runner | 2.50 or newer | `bay runner run` refuses below it with `TOOL_BELOW_FLOOR`. |

## Uninstall

First remove bay's hook entries from the project's settings file, and merge that change into main. Then unset bay's local Git config in each clone where you ran `bay install`. Then remove the program, its kept versions, the bay skill and bay's state directory from the machine. Never delete bay refs on a shared remote: other agents still read them.

```sh
# once, on main: remove bay's entries from .claude/settings.json (or .codex/hooks.json), and merge that change
# in each clone where you ran bay install:
git config --local --unset gpg.format
git config --local --unset gpg.ssh.allowedSignersFile
git config --local --unset bay.unrelated-hooks-digest
git config --local --unset bay.machine
git config --local --unset bay.main
git config --local --unset bay.remote
git for-each-ref refs/bay/          # local bay refs; delete them only in this clone
# bay's views and sessions: remove its worktrees, then "$(git rev-parse --git-common-dir)/bay"

# on the machine
rm ~/.local/bin/bay
rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/bay/versions"
rm -rf "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/bay"   # and skills/bay in each directory doctor's skill row lists
rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/bay"
git config --global --unset-all bay.claudeConfigDirs
git config --global --unset bay.updateVersion
git config --global --unset bay.updateAuto
git config --global --unset bay.executorSandbox
```

---

# Quickstart

> Install bay, set your checks, start a runner, then start and merge a first change.

Source: https://getbay.io/docs/quickstart (HTML).

This page takes a repository to its first merge. You install bay, commit a config and a policy to main, and start a runner. Then an agent opens a change with `bay start` and submits it with `bay merge`.

1. **Install bay** Run the script on each machine that runs agents or a runner.

```sh
curl -fsSL https://getbay.io/install.sh | sh
```

2. **Check the machine** Fix each red row before you go on. Each red row names its fix. Until the next step turns on the agent hook, the `hooks` row reads `off`. Security and privacy says what bay guards with the hook, and without it.

```sh
bay doctor
```

3. **Configure the repository** `.bay/config.toml` names the agent product. Each check's command lives in `.bay/policy.toml`. A check command reads a `bay.check.request/1` on stdin and writes a `bay.check.result/1` on stdout. Test one before you commit it: `bay plugin test check -- <command>` runs bay's contract cases and changes nothing. Commit the config and push it to main: bay reads it only from main. Configuration lists the keys. Then run `bay install --main main --remote origin`, so it records main and adds the hook entries. Commit the new `.claude/settings.json` and push it to main too. Every clone and view then runs bay's hooks.

```toml
# .bay/config.toml
[agent]
product = "claude"
```

4. **Set the required checks** `.bay/policy.toml` on main lists each required check. Each check needs `expected_minimum`, the least number of tests it must collect. The `adapter` list names every file that decides a pass, and bay runs those files from the base. A change to the policy or a check's files also runs its own new files, and both must pass. Without `.bay/policy.toml` on main, the runner blocks every merge with `POLICY_MISSING`. Push the first policy to main yourself. A repository that wants no checks says so: `unchecked = true` in its policy.

```toml
# .bay/policy.toml (on main)
revision = 5                       # an integer; every policy change raises it

[[check]]
id = "tests"
environment = "linux-x86_64"
command = "scripts/bay-check tests"
adapter = ["scripts/bay-check", "scripts/bay-check.d/", "pytest.ini"]
inputs = []                        # inputs beyond the tree; "commit" or "history" keys evidence by commit
expected_minimum = 400             # required, no default
timeout_s = 1800
path = ["/usr/local/bin", "/usr/bin", "/bin"]   # absolute directories; the check's PATH
env = ["DATABASE_URL"]             # the only variables passed from the runner
retry_on_failure = 1               # rerun a failure on the same tree, up to 5 times
allow_flaky = false                # a failure then a pass is flaky: CHECK_FLAKY

[[check]]
id = "e2e"
environment = "linux-x86_64"
command = "scripts/bay-check e2e"
adapter = ["scripts/bay-check", "scripts/bay-check.d/"]
expected_minimum = 20

[[quarantine]]                     # optional: one check set aside for a stated time
id = "e2e"                         # a required check with expected_minimum above zero must remain
environment = "linux-x86_64"
reason = "red on main since change k3m9p1; fix in progress"
end = "2026-10-25"                 # a date or a Change-Id
owner = "platform-team"            # optional: who answers for it
since = "2026-10-18"               # optional: when it was set aside; bay status shows its age

[limits]                           # optional; each key a positive integer
max_file_mib = 10                  # any one file the change adds or changes, in MiB
max_files = 200                    # the files it touches
max_added_lines = 5000             # the lines it adds; a binary file adds none
max_waiting_approvals = 5          # changes that may wait for a yes at once

[locks]                            # optional: files that never merge
paths = ["art/", "**/*.psd"]       # globs; one session at a time edits each, across machines
```

5. **Start a runner** The runner finishes each merge after its agent leaves. Run it under a supervisor, such as the one `bay runner install` writes, and run at least two. Each check runs in a sandbox: `bwrap` on Linux, `sandbox-exec` on macOS. There, a check sees only its candidate, its own files and system files, with no network. Set `network = true` on a `[[check]]` that needs the network.

```sh
bay runner run
```

6. **Start a change** The agent says what the work is, in a few words, and names the paths it needs. bay opens a sparse view of those paths and prints its result. Set `BAY_ACTOR` to name the agent, or bay uses your user name.

```sh
bay start "fix the login page" --paths src/api tests/api
```

7. **Edit, then merge** The agent edits files in the view. `bay merge` returns at once with an operation id. The runner checks the candidate and merges it into main. To wait here for the outcome instead, run `bay merge --wait`. A person who edits the main checkout directly runs `bay save <intent>`; Run bay without a runner explains it.

```sh
bay merge
```

8. **Read the state** `bay status` shows each change, its state, and why it waits. A refusal names what you can still do and when to retry. For a failed check, the refusal and `bay status` say why each failed unit failed.

```sh
bay status
```

---

# Concepts

> bay gives each name one meaning, and these pages use each name only in that meaning.

Source: https://getbay.io/docs/concepts (HTML).

bay uses one name for each idea. A change is one intended outcome, and its draft lives in a view. bay builds a candidate from the draft and main, and a runner merges it when its checks pass. Evidence records what each check saw.

## Main

Main is the one branch bay merges into. You name it with `bay install --main`, such as `main` or `dev`. It is one linear branch that only grows, and each merge adds one child of the checked base. bay never forces a push to main. Agents create no branches: each change is bay's own record, edited in a view bay makes. Branches after main, such as `staging` and `production`, are promotion targets. Work that must never reach main goes in a lane.

## Change

A change is a stable id for one intended outcome. A new implementation keeps the same id. A change id is eight lower-case base32 characters that bay generates. An intent says what the work is, in a few words, or gives a card id. One intent claims one change, even when several starts race for it. Only the same words, in the same case and spacing, name the same intent.

> bay keeps each change's record under `refs/bay/changes/<change>`, and reading it from the remote never moves local work back.
> When the remote's record moved apart from this clone's, bay keeps the local head under `refs/bay/kept/<change>/`.
> These kept refs stay in that clone, are never pushed, and are yours to inspect or delete.

## View

A view is the sparse checkout of the declared paths for one change. It also holds the agent product's hook settings, such as `.claude/settings.json`, when main holds that file. An absent file in a view is not a deletion. Each view is a Git worktree of your clone, so views on one clone share its objects. After `bay start`, the view works offline. A machine's `core.autocrlf` setting never changes the line endings of a view's files.

### When a view goes

- Each `bay start` releases this machine's views of changes that merged, were abandoned, or ended as `no_effect`.
- It releases a view once its change ended `[retention] window_s` ago or more, one week by default.
- It keeps a view with edits or untracked files, a commit that bay did not place, or a lock.
- It also keeps the view you run `bay start` in, and the view of a live session on this machine.
- It keeps a folder that is no valid worktree of this clone, and a view that `git worktree remove` refuses.

> Files that the view's ignore rules match are not work, and they go with the view.
> The result names each released change under `detail.released_views`.
> A sandbox lane change's view goes sooner, after its lane's `window_s`, one day by default.

### Views in a blob-less clone

A clone from `git clone --filter=blob:none --no-checkout` holds commits and trees, but no file contents. Views on it share one store, and each view fetches only the files it needs, after every `bay start`. With Git 2.45 or newer, Git never fetches a missing object on its own inside bay's reads. bay fetches what its own reads need, such as `.bay/config.toml`, by name from the clone's promisor remote. When it cannot fetch `.bay/config.toml`, the command fails and names that file. Use Git 2.48 or newer for such a clone.

> A view's files are its declared paths plus the agent hook settings file.
> Git still fetches on purpose in a view's checkout, `merge-tree`, the undo's `read-tree` and a check's copy of the candidate.
> Below Git 2.48, `bay doctor` marks the `shared-cache` row `refused`, but no command stops you.
> A `.bay/policy.toml` that bay cannot fetch fails the merge with a fault that names it, never `POLICY_MISSING`.
> Then `bay status` shows `detail.red` and `detail.quarantines` as objects with `unread` and the reason, never as clear.
> When bay cannot fetch a `live.json`, the fleet map shows `readable: false`, and `bay start` fails rather than adopt.

### Warm a view's history

Set `[view] warm_history = true` in `.bay/config.toml` to fetch the history of a view's declared paths. Then `bay start` fetches every past version of those paths with `git backfill`, also when it picks up a change. So `git log -p` and `git blame` on those paths need no fetch, also offline. `detail.history` says how much: its `state`, the objects missing before and after, and the bytes fetched. Below Git 2.54, bay skips the warm-up, and `detail.history` names that floor. A warm-up that fails says `failed` there, and the start still succeeds.

> It is off by default, so a view costs only what its task reads.
> A change with no declared paths skips it, because its history is the whole repository's.
> `state` is `warmed`, `partial`, `skipped` or `failed`, and one warm-up runs for at most 10 minutes.

## Candidate

The draft is the work in the view. bay builds the candidate from the draft and the current main. The candidate is one exact result: a Git tree and the commit that carries it. bay checks the candidate and merges that same commit.

## Check

A check is a command that tests a candidate and returns JSON. Each check reports `pass`, `product_failure`, `infra_failure` or `unknown`. Unknown is never a pass. With `retry_on_failure`, a failure followed by a pass is `flaky`, never a plain pass. `.bay/policy.toml` on main lists the required checks.

## Evidence

Evidence is one record of what one check, review or runtime observation saw on one exact result. With keys in the registry, a producer key signs each check record, and a reviewer key signs each review. bay keeps evidence in Git notes under `refs/notes/bay/evidence/<producer>`. Evidence counts only for the exact tree it names, or the exact commit for a commit-keyed check. Results say `trust: signed` with a key registry, `trust: approvals` when it signs only approvals, and `trust: unsigned` without one. A producer outside bay, such as a reviewer, writes its records with `bay evidence record`.

## Runner

A runner is the process that finishes a merge after the agent leaves. It claims the operation, runs the checks, and merges the candidate. On a main branch from `bay host init` with a registry, only a runner's `executor` key can move main. On other hosts, use the host's branch protection. `bay runner run` is one runner, and a CI system can be another.

## Merge

To merge is to put one checked candidate on main. `bay merge` freezes the draft, builds the candidate, submits it, and returns at once. A runner then checks the candidate and merges it. The push to main is the only commit point. A change has merged only when its commit is on main. When the work is already on main, bay merges nothing, and the result's state is `no_effect`.

## Refusal

Most refusals are results with `decision` set to `blocked`. `UPDATE_UNVERIFIED` is `failed`. It carries one fixed reason code, such as `CHECK_FAILED`. `allowed` lists what you can still do. `retry_when` says when a retry can succeed. A retry with no change in the cause gets the same refusal and does no work.

## Keys

Keys are optional; with a registry, six classes of key sign in bay: `agent`, `executor`, `producer`, `reviewer`, `approver` and `registry`. An agent key signs a request to merge. A runner's `executor` key signs the commit that merges into main. A producer key signs check evidence, and a reviewer key signs a review. A registry key adds and removes keys. One key belongs to exactly one class.

## The program, the skill and hook entries

bay puts three things in place, and each has its own home. The program is `bay` itself, at `~/.local/bin/bay` on each machine. The skill is bay's guide for an agent: one copy in each Claude config folder, under `skills/bay/`. Hook entries are lines in a project's settings file, such as `.claude/settings.json`, that run `bay hook`. That file is on main, so every clone gets the entries once their change merges. Roll bay out to many machines puts all three in place, in order.

---

# Runners

> The runner finishes each merge after the agent leaves.

Source: https://getbay.io/docs/runners (HTML).

`bay merge` returns at once with an operation id. A runner then runs that operation to an outcome. It claims the operation, runs the required checks, and merges the candidate into main. With keys in the registry, the runner signs each merge commit. When it cannot merge, it records a refusal with a reason and a next step.

## Run the local runner

`bay runner run` drives queued operations on one machine. It makes a pass every 10 seconds by default. `--once` makes one pass and exits. Run it under a supervisor that restarts it, such as the one `bay runner install` writes. Between operations, it moves to the bay that `bay update` installed, and keeps its process id. With `--once`, or on Windows, it keeps the version it started with.

```sh
bay runner run                      # a pass every 10 seconds
bay runner run --interval-s 30      # a pass every 30 seconds
bay runner run --once               # one pass, then exit
```

### Keep it running

`bay runner install` writes a service that keeps `bay runner run` going for this repository. On macOS, that is a launchd agent in `~/Library/LaunchAgents/`. On Linux, it is a systemd user unit in `~/.config/systemd/user/`. The service runs the bay that wrote it, with your current `PATH`, and restarts it whenever it ends. It logs to `bay/executor.log` in the repository's Git directory. bay starts nothing. It prints the one command that starts the service, also under `detail.load`. `--print` shows the file and writes nothing. `--force` replaces a file that is already there.

```sh
bay runner install             # write the service; print the command that starts it
bay runner install --print     # show the file, write nothing
```

> On Linux, `loginctl enable-linger` keeps the service running after you log out.
> Without `--force`, bay leaves a service file that is already there, and the command fails.
> On other platforms, it fails, so run `bay runner run` under a supervisor of your own.

## Check the machine

`bay doctor --role executor` judges this machine as a runner. It checks Git 2.50 or newer, the check sandbox, the environments the machine serves, and each check's `env` variables. With a key registry, it also checks the runner's `executor` and `producer` keys. It exits 10 when a row is red. `bay runner run` itself refuses with `TOOL_BELOW_FLOOR` below Git 2.50, before it fetches or claims anything. Its `presence` row shows this runner's own presence, its age, and how its last push went.

```sh
bay doctor --role executor
```

## Choose the environments a machine serves

Each `[[check]]` names an `environment`. `bay.checkEnvironment` in Git config lists the environments this machine serves, one value each. Without it, the machine serves every environment. A single empty value serves nothing, for example during maintenance. When no machine serves an operation's environment, the operation waits with `CHECK_UNAVAILABLE`. That wait never counts toward a queue deadline.

```sh
git config --global --add bay.checkEnvironment linux-x86_64
git config --global --replace-all bay.checkEnvironment ""   # serve nothing during maintenance
```

## Run a change's checks at once

By default, a runner runs a candidate's checks one after another. `[runner] parallel_checks = N` runs up to N of them at once, each in its own directory. The default is 1 and the most is 16. bay refuses a config outside that range. bay still keeps the records in the policy's check order, and the time budget starts with the first check. When a check answers `unavailable`, no further check starts, and bay keeps no record for the checks after it. A check with `baseline = "base"` runs alone, after the checks before it.

```toml
# .bay/config.toml
[runner]
parallel_checks = 4                # up to 4 of a candidate's checks at once; 1 to 16
```

> The checks share this machine, so give it the CPU and memory for N checks at once.
> A check's reruns for `retry_on_failure` stay in that check's own slot.
> bay's bisect of a red post check and `bay plugin test` still run one check at a time.

## Keep a disk floor

`bay.minFreeMiB` in Git config sets the least free disk space for this machine, in MiB. Below it, `bay start` refuses `DISK_LOW`, and makes no change and no view. Below it, a check copies nothing and does not run. It ends `infra_failure` naming `DISK_LOW`, never a pass. bay reads the space that `df` says your account can use, where the view or the copy would go. Set it globally or for one clone. A repository cannot set it. Without it, bay refuses nothing. On Windows, bay does not judge the floor.

```sh
git config --global bay.minFreeMiB 2048   # start nothing below 2 GiB free
```

> `bay doctor` also names a `gc.log` that stops Git's automatic garbage collection, in its `gc-log` row.

## When a check cannot reach a worker

A check command can answer that no worker could run the check. It prints an `unavailable` reason and no `checks`. bay then writes no evidence and records no attempt. The operation waits with `CHECK_UNAVAILABLE`, and the wait never counts toward a queue deadline. The runner stops renewing its lease, so a machine that can serve the check claims it.

```json
{"schema": "bay.check.result/1", "unavailable": "<reason>"}
```

## Run at least two

One runner is a single point of failure. Run at least two, so that one can take over from the other. An owner renews its claim with a heartbeat, every `heartbeat_s` seconds, 10 by default. After `lease_s` seconds with no heartbeat, 60 by default, another runner can claim the operation. A replaced runner can never merge that operation. Before a takeover, a runner watches the claim for one full lease on its own clock, even after a reboot.

> When a fault ends a runner's run of an operation, the runner tries to give its claim back.
> Then another runner can claim the operation at once, without waiting out the lease.

## See that a runner runs

Each pass writes the runner's presence: version, machine, environments, last pass, held operation and queue depth. It writes at most once per heartbeat, to `refs/bay/executor/<runner>`. `bay status --fleet` lists each runner under `runners`, with its age and whether it is `stale`. When operations wait and no runner is fresh, `bay status` warns `no_live_runner` under `detail.warnings`. The warning refuses nothing. Queued operations merge once a runner runs again. With agent hooks on, a session also hears once, at its start or next prompt, that no runner runs.

```sh
bay status           # detail.warnings: no_live_runner when no runner is fresh
bay status --fleet   # runners: machine, bay, environments, last_pass, holding, queue_depth, stale
```

> The Fleet page says when a presence turns stale.
> The hook reads only local refs, as of the last fetch.
> It says so again only after that state cleared and came back.

## Operation states

An operation moves through `submitted`, `admitted`, `running`, `checked` and `merged`. It can also end as `blocked`, `failed` or `superseded`, or as `no_effect` when its work is already on main. Each step is a commit in the change chain, so `bay status` reads the true state. While an operation runs, `refs/bay/inflight/<op id>` keeps its candidate safe from Git maintenance. That ref stays local, and bay never pushes it.

### How long an operation took

When an operation ends, `operation.timing` holds its times, in Unix seconds. bay reads each timestamp from a commit it already wrote, never from a wall clock. `submitted_at` is the request, `queued_at` its queue place, and `head_at` its first claim at the head. `merged_at` is when it merged. Another outcome has `ended_at` instead. `checks[]` holds each attempt's start, finish and check runs, and `attempts` counts them. `queue_wait_s`, `check_s` and `total_s` follow from those times.

```sh
bay status <change>   # operation.timing, once the operation ends
```

> `bay status <change>` shows the same fields as `operation.timing`.
> bay leaves out a field whose source it cannot read.

## Conflicts found early

At the start of each pass, and after each merge, `bay runner run` test-merges queued operations onto main. It tests up to 8 operations behind the head of the queue, and the test writes nothing to main. A conflict shows in `bay status` as `operation.conflict`, with `MERGE_CONFLICT` and the conflicting paths. The operation keeps its place, so its agent can refresh and repair it before it reaches the head. Its own merge at the head still decides, and a later clean test clears the conflict. With agent hooks on, the change's session hears of each conflict once, with its paths.

## Merge after another change

Sometimes one change needs another change on main first, such as an API before its callers. `bay start <intent> --paths <paths> --after <change>` opens a change that merges only after that change. Repeat `--after` for each change it needs. Each must exist on the remote, and need not have merged yet. You can submit it at once. Until each named change merges, the operation waits with `DEPENDENCY_PENDING`. It holds no queue place meanwhile, so the changes behind it can merge. Its checks run once the wait ends. Its merge carries one `Bay-After: <change>` trailer for each change it named.

```sh
bay start CARD-124 --paths web/ --after c7k2m4qa   # merge only after c7k2m4qa merged
bay merge                                          # queued with DEPENDENCY_PENDING until then
```

> A named change that ends `no_effect` counts as merged. One abandoned or superseded refuses this one with `DEPENDENCY_ABANDONED`.
> Then abandon this change and start again without that `--after`, or restart the named change and merge it.
> Adopting a change with `--after` adds to the changes it waits for.
> bay refuses an `--after` that would make a change wait on itself, directly or through others.
> The wait counts toward no queue deadline, and a sandbox lane change never waits.

## Large histories

A runner pass costs the same however many closed changes and finished queue places the repository holds. After `[retention] window_s`, the runner removes finished queue places below the head. It always keeps the highest place, and removes at most 64 places in one push.

## The external runner contract

A CI system or a queue host can act as a runner, named by `[runner] command` in `.bay/config.toml`. `bay merge` runs that command with the `submit` argument, and the request as JSON on stdin. The command must exit `0` and print a JSON object with a `job` id within `submission_timeout_s`. That reply admits the operation. Otherwise the operation ends `failed`, and nothing merges it later. Your system then drives the operation, for example with `bay runner run --once`. No agent types these actions.

| Action | Input | What it does |
|---|---|---|
| `submit` | JSON on stdin: `action`, `op_id`, `attempt_id` (null), `request_digest` and `candidate` | Prints `{"job": "<id>"}` and exits 0; another exit refuses the submission. Must be idempotent: the same op id and request digest return the existing job, and a different digest under one op id is refused. |

> With a key registry, a runner needs its own key in the `executor` class.
> It never holds or uses an agent key.
> bay records each step by op id, so a crash between steps loses no work.
> bay reads `[runner] command` from the work tree's `.bay/config.toml`.
> It runs during `bay merge`, on the merging agent's machine and account, with no sandbox.
> A late reply ends as `submission_timeout`, a command that cannot start as `submission_failed`, and a refusal as `submission_refused`.

## Refusals

| Code | Meaning |
|---|---|
| `TOOL_BELOW_FLOOR` | `detail.tool` is `git`: Git on this machine is older than 2.50. `bay runner run` fetched and claimed nothing. |
| `DISK_LOW` | From `bay start`: this machine has less free space than `bay.minFreeMiB`. bay made no change and no view. |
| `CHECK_UNAVAILABLE` | No machine serves the check's environment, or the check answered `unavailable`. The operation waits. |
| `QUEUE_DEADLINE_EXCEEDED` | The operation lost its place at the head of the queue three times, past the execution budget plus `publish_window_s`. |
| `DEPENDENCY_PENDING` | A change named by `--after` has not merged yet. The operation stays `queued` and holds no queue place. |
| `DEPENDENCY_ABANDONED` | A change named by `--after` was abandoned or superseded, so this change cannot merge as it is. One that ends `no_effect` counts as merged. |
| `SANDBOX_ABSENT` | From `bay doctor`: this machine runs checks and has no check sandbox. |
| `CHECK_ENV_UNSET` | From `bay doctor --role executor`: a check's `env` names a variable this machine does not set. |
| `RUNNER_KEY_MISSING` | From `bay doctor --role executor` with a registry: the runner's `executor` or `producer` key is not set or not enrolled. |

## Not built yet

- `inspect`: return the job id, its owner, its state and the evidence so far.
- `resume`: reconcile an interrupted job with the work already done.
- `cancel`: record a cancel and report whether the merge already happened.
- `complete`: return the evidence for the exact candidate and the inputs it used.

---

# Merge more changes a day

> Let idle runners check queued changes early, carry records across unrelated merges, and check several changes as one tree.

Source: https://getbay.io/docs/throughput (HTML).

One main branch merges one change at a time, and each change waits for its own check run. Three settings let a change merge on a check run made earlier, or shared with other changes. Each is off by default, and each is a rule of evidence reuse, never a weaker verdict. With `batch`, members merge on the check of the batch's last commit; with `footprint`, a record names its source.

Merges still go one push at a time, in queue order. Every merge still rests on evidence, and its result names what that evidence covers. A missed prediction, a changed read set or an unknown never counts as a pass: the check runs. The gain depends on your queue, your runners, your paths and how often your checks fail.

## When to use these settings

- **Runners sit idle while the head checks**: set `ahead`, so they check the entries behind it.
- **Most changes touch separate paths**: set `footprint = true` on narrow checks, and `footprint_lanes`.
- **Checks are slow and rarely fail**: set `batch`, so one run covers several changes.
- **Checks fail often**: `max_failure_rate` stops batches until the failure rate falls.
- **One change runs several slow checks**: `[runner] parallel_checks` runs them at once, with no evidence reuse.

## Before you start

Each setting turns on exact-inputs evidence reuse, with no other key. bay refuses a config that sets one and also `[policy] evidence_reuse = "off"`. Each also needs a key registry that signs everything, or `reuse_unsigned = true` in the policy. Without either, the settings do nothing. Results still name their trust: `signed`, `approvals` or `unsigned`. Each runner is its own producer and check slot, so more runners give more early checks.

```toml
# .bay/config.toml
[runner]
ahead = 4                          # check up to 4 queued entries early; at most 16
footprint_lanes = true             # check an entry beside the head when paths are disjoint
batch = { min = 2, max = 8, max_failure_rate = 0.3 }
```

## Check queued changes early

With `ahead = N`, a runner that does not hold the head checks the next N entries now. It checks each on the base the entries ahead of it predict: main with each of them merged, in order. It merges nothing and takes no ownership. The operation lists the run under `prechecks`. When the entry reaches the head and main matches the prediction, it merges on that evidence with no new run. When the prediction misses, the record is about another tree, and bay ignores it. A miss wastes that work, and never counts as a pass.

```toml
[runner]
ahead = 4
```

> bay predicts that an entry ahead that conflicts, or whose work is already on main, does not merge.
> An entry ahead that changes the policy or a check's files stops the prediction there.
> Two runners that check one entry on one base run it at most twice.

## Carry a record across unrelated merges

Without this, any merge to main makes a queued candidate's records stale, and its checks run again. `footprint = true` on a `[[check]]` says its verdict reads only its `paths`, else its `when_paths`. bay then stamps each record with digests of that read set, in the candidate and in its base. A later candidate of the same operation, with the same stamp, policy and fixtures, gets the record carried. The carried record names the record it came from, and the check does not run. Carrying needs `[runner] footprint_lanes = true`. A check whose `inputs` name `commit` or `history` never carries.

```toml
# .bay/policy.toml
[[check]]
id = "api"
environment = "linux-x86_64"
command = "scripts/bay-check api"
adapter = ["scripts/bay-check"]
expected_minimum = 50
when_paths = ["api/"]
footprint = true                   # the verdict reads only api/
```

> Set it only on a check whose verdict depends on nothing outside that read set.
> A suite that imports a library from elsewhere would carry a pass onto a tree it never judged.
> bay reads `footprint` at the base, and it enters the check's definition digest.
> So a candidate cannot grant it to itself, and no record made before it counts.
> `footprint = true` without `paths` or `when_paths`, or with `materialize = "changed"`, makes the policy invalid.
> An approval carries without `footprint` or `footprint_lanes`. Require a person's approval explains when.

## Check beside the head

An entry's footprint is the union of the `when_paths` of the checks its diff requires. A required check with no `when_paths` makes the footprint the whole tree. With `footprint_lanes = true`, a runner checks an entry early when its footprint is disjoint from every entry ahead. It checks it on the current main; only `footprint = true` records and approvals carry to its final candidate. Merges stay in queue order: a disjoint entry never passes a blocked head. If every required check carries over, the entry then merges with one push and no new run.

```toml
[runner]
footprint_lanes = true
```

> bay treats two footprints as disjoint only when the literal prefixes of their globs show it.

## Check several changes as one tree

With `batch`, the head's runner takes the head and the live entries behind it, in queue order. It checks their combined tree once, on the current main. On a pass, every member merges in one push, and main moves once, to the last member. Each member keeps its own commit, `Change-Id` and trailers, plus a `Bay-Batch: <id> <i>/<k>` trailer. Each member's result names the batch under `detail.batch`, because its own tree was not checked alone. With `footprint_lanes`, a batch takes only entries whose footprint overlaps the head's.

```toml
[runner]
batch = { min = 2, max = 8, max_failure_rate = 0.3 }
```

### When a batch fails

- bay bisects over prefixes of the batch on the same main, so no prefix runs twice.
- It finds the largest passing prefix and the first failing member, the culprit.
- When two members or more pass, that prefix merges as one batch.
- The culprit runs alone when it reaches the head, judged by its own candidate.
- When fewer than two pass, no batch merges. The head takes its usual path on its own records.
- The culprit and the members behind it keep their places in the queue.

### When no batch forms

- No batch forms while the recent failure rate is above `max_failure_rate`, 0.3 by default.
- That rate reads the last 20 check outcomes of finished queue places.
- With `footprint_lanes`, it reads only the places whose footprint overlaps the head's.
- A batch never holds a lane change, a change with named inputs, a policy change or a known culprit.
- A policy with `unchecked = true`, or a check keyed by commit, never batches: `inputs` `commit` or `history`, or `materialize = "changed"`.
- While main is held, no batch forms.

## On a host from `bay host init`

- The host admits a `Bay-Batch` commit only within a whole batch, in one push.
- The config at the batch's base must set `batch`, or the host refuses the push.
- The host judges the last member's evidence by the policy at the batch's base, over the whole diff.
- A policy repair never merges in a batch.

## What these settings cannot do

- They never merge out of queue order, even for a disjoint entry behind a blocked head.
- They never turn an unknown, a missed prediction or a changed read set into a pass.
- They do nothing without a key registry that signs everything, or `reuse_unsigned = true`.

## Limits

One check slot runs at most 1,440 divided by the check minutes runs a day. With 12-minute checks, that is 120 a day. With `batch`, one run can merge up to `max` changes. `ahead` and `footprint_lanes` run more checks side by side, but only with more runners. One push at a time stays the single commit point, so pushes still go in order. bay states no number for the gain. Treat any ratio as a target, and measure your own.

- `ahead`: at most 16 entries.
- `[runner] parallel_checks`: up to 16 of one candidate's checks at once, on one machine. It needs no evidence reuse.
- `batch`: at most 64 members, 8 by default, and never fewer than two.
- `max_failure_rate`: from 0 to 1, 0.3 by default, over the last 20 outcomes.

---

# Undo a merge

> Take one merged change back out of main with a new checked change, and keep main's history as it is.

Source: https://getbay.io/docs/undo (HTML).

`bay undo` reverses the effect of one merged change. It opens a new change that takes that merge's edits back out of current main. The new change runs the same checks and merges like any other change. Use it when a merge breaks something and a fix would take longer.

An undo never rewrites main. Every commit before it stays as it was. The undo merges as a new commit with a `Reverts:` trailer that names the undone change.

## When to use undo

- **A red after merging**: a post check fails. An undo of the merge that owns the red is never refused.
- **A wrong merge**: a change reached main that should not have.
- **Bring a change back**: undo the undo, and the original edits return.
- **Main is held**: an undo merges during a hold, as a fix does.

## Undo a change

Name the merged change. bay returns at once with `queued`, and a runner finishes the merge.

```sh
bay undo c7k2m4qa
```

## Wait for the outcome

`--wait` runs the work here and returns its outcome, as `bay merge --wait` does. `--wait=<seconds>` watches that long while the undo waits for a runner, then returns `queued`; the run goes on.

```sh
bay undo c7k2m4qa --wait
```

## Repair a conflict

Later changes may have edited the same lines. Then the undo does not merge, and bay returns `MERGE_CONFLICT`. bay leaves the undo change open. `detail.view` names its view, with conflict markers in the files. Resolve the paths in `detail.conflicts`, then merge the undo from that view.

```sh
cd <detail.view>
# resolve each path in detail.conflicts
bay merge --wait
```

## How an undo works

1. **Find the merge** bay asks the remote for the merged marker `refs/bay/landed/<change>`. It then finds that merge commit on main. It reads the undone change's record from the remote, and never moves this clone's copy of it back.

2. **Build the reverse** bay merges the reverse of that commit onto current main with `git merge-tree`. When the result equals main's own tree, the effect is already gone. bay opens no change.

3. **Open the undo change** bay opens a new change on the intent `undo of <change>`. Its paths are the paths that the merge touched. bay writes the reverse into the view of that change.

4. **Merge** The undo change takes the normal path to main, with the same required checks. Its merge commit carries `Reverts: <change>`.

## Undo the same change twice

- Each undo of one change claims the next generation of the intent `undo of <change>`.
- A second undo waits for the first one to merge. Before that, bay refuses it with `CHANGE_SUPERSEDED`.
- The second undo also carries a `Supersedes:` trailer that names the first undo.
- An undo never claims a generation of the undone change's own intent.

## Fix a merged change instead

Sometimes a small fix is better than an undo. `bay start --fixes <change>` opens a new change that names the merged change it fixes. Its merge carries a `Fixes: <change>` trailer. When the named change is not on main, bay refuses with `CHANGE_NOT_MERGED` and opens no change.

```sh
bay start CARD-130 --fixes c7k2m4qa --paths src/api
```

## Read the lineage

`bay status --lineage` lists the `Reverts:`, `Supersedes:` and `Fixes:` pairs on main. Give a range, such as `<from>..<to>`, to read part of main. It reads local refs only and never fetches, so run `bay status` first. `publication` says whether bay merged the naming commit. Without a key registry, `publication` is always `true`. A trailer typed on a commit outside bay is only a claim.

```sh
bay status --lineage
```

## Results

`ok`, `merged` and `queued` exit `0`. `blocked` exits `10`, and `failed` exits `20`.

| Result | When | `detail` |
|---|---|---|
| `queued` | Without `--wait`: the undo change is open and waits for a runner. After `--wait=<seconds>` runs out, `state` is `running` and the run goes on. | As for `bay merge`. |
| `merged` | With `--wait`: the undo merged. | As for `bay merge --wait`. |
| `ok` | The effect is already gone from main. bay opens no change, so the result has no `change`. | `undo: "nothing to undo"`, `reverts`, `main` |
| `blocked`, `CHANGE_NOT_MERGED` | The change is not on main. | `main` |
| `blocked`, `MERGE_CONFLICT` | The reverse does not merge onto main. The view stays open for repair. | `conflicts`, `view`, `reverts`, `main` |
| `failed` | bay cannot read the remote, so it cannot tell whether the change merged. A merge Git cannot read also fails. An argument that is not a change id fails too. | A string that says why. |

> A remote that bay cannot read is never `CHANGE_NOT_MERGED`.
> The undo change can also meet any refusal of `bay merge`.
> With a key registry, `detail.open_talks` lists open talks to you about the undone change.

## What an undo cannot do

- It cannot remove a commit from main. Main stays append-only.
- It cannot skip the checks. The undo is an ordinary change.
- It cannot undo a change that is not on main. Abandon that change instead.
- It undoes one change per call. Run it once for each change.

## Refusals

Each refusal is `blocked` and exits `10`. The Results page lists what to do next for each one.

| Code | Meaning |
|---|---|
| `CHANGE_NOT_MERGED` | The named change is not on main. |
| `MERGE_CONFLICT` | The reverse does not merge onto current main. `detail.view` holds it for repair. |
| `CHANGE_SUPERSEDED` | An earlier undo of the same change has not merged yet. |

---

# Configuration

> Two files configure bay: `.bay/config.toml` for the repository and `.bay/policy.toml` for the checks.

Source: https://getbay.io/docs/configuration (HTML).

Two files on main configure bay. `.bay/config.toml` names the plugins, the runner timings, retention, promotion targets and lanes. `.bay/policy.toml` lists the checks a change must pass before it merges. bay reads both only from main, so a change never sets its own rules.

## `.bay/config.toml`

bay reads it only from main: the committed copy at `<remote>/<main>`. A work tree's copy and a change's own commits never set the rules that change runs under. bay finds main first, from `bay.main` and `bay.remote` in local Git config, else the remote's default branch. Without main, or without this file at main, bay refuses with `CONFIG_MISSING`. A machine's own settings, such as updates and `bay.claudeConfigDirs`, live in its global Git config. A key that a newer bay brought is unknown to an older one, so run `bay update` on that machine.

```toml
# .bay/config.toml, read from main
main = "main"                      # optional; must match bay install --main
remote = "origin"                  # optional; must match bay install --remote

[trailers]                         # map Intent and Actor to your own trailer keys
intent = "Intent"
actor = "Actor"

[plugin.notify]
command = "scripts/bay-notify"     # the notify plugin

[plugin.wake]
command = "scripts/bay-wake"       # the wake plugin: an accepted talk, or an outcome to act on

[plugin.runtime]
command = "scripts/bay-runtime"    # optional: running and effective records for bay status

[policy]
profile = "default"                # the [profile.<name>] that bay status judges completion by
evidence_reuse = "exact-inputs"    # optional: reuse a pass on matching inputs; a throughput key turns it on

[hooks]
cache_max_age_s = 30               # older coordination cache: COVERAGE_UNKNOWN
checkpoint_idle_s = 600            # a draft checkpoint after this much idle time

[view]
warm_history = false               # true: bay start also fetches the declared paths' history

[red]
window = 50                        # main's commits a red judgement reads, 1 to 10000

[agent]
product = "claude"                 # or "codex"; leave this table out with no agent hooks
instructions = ["AGENTS.md", "CLAUDE.md"]   # compared with main at SessionStart

[runner]
# command = "scripts/bay-runner"   # optional: an external runner; it must exist, or bay merge fails
submission_timeout_s = 120
execution_budget_s = 1800          # no default; the queue deadline assumes 30 minutes
lease_s = 60                       # an owner silent this long may be replaced
heartbeat_s = 10
takeover_s = 120                   # default: twice the lease
publish_window_s = 60
bisect_post = false                # true: find the commit that turned a post check red
ahead = 0                          # throughput; turns evidence reuse on
footprint_lanes = false            # throughput; turns evidence reuse on
parallel_checks = 1                # 1 to 16 of a candidate's checks at once; 1 by default

[retention]
window_s = 604800                  # one week
ref_budget = 100000                # bay refs on the remote before markers are compacted

[plugin.prune]
command = "scripts/bay-on-prune"   # optional: runs after each branch bay prune deletes
timeout_s = 30                     # 1 to 600 seconds; 30 by default

[[target]]                         # optional: a promotion target
name = "staging"
ref = "refs/heads/staging"         # default: refs/heads/<name>
from = "main"                      # main, or another target's name
requires = ["e2e"]                 # checks that must pass on the promoted commit
soak_s = 3600                      # the commit's least age before promotion
advance = false                    # true: the runner moves it to its newest promotable commit

[[hold]]                           # optional: hold main, or one target, until end
target = "staging"                 # optional: with no target, the hold is on main
end = "2026-12-24T00:00:00Z"       # RFC 3339 UTC, to the second
reason = "release week"

[[lane]]                           # optional: sandbox refs whose changes never merge
name = "research"
refs = ["refs/heads/research/*"]
window_s = 86400                   # shorter than [retention] window_s
```

| Key | Meaning |
|---|---|
| `main` | Optional. The branch bay serves, any name, such as `main` or `dev`. It must match the main branch this clone uses. A different name is refused, never followed, and the refusal says to run `bay install --main <name>`. |
| `remote` | Optional. It must match the remote this clone uses. Set both with `bay install --main --remote`. |
| `[trailers] intent`, `actor` | Your own trailer keys for Intent and Actor. The other bay trailers have fixed names. |
| `[plugin.notify] command` | The notify plugin. It gets one event and exits 0 when it delivered it. |
| `[plugin.wake] command` | The wake plugin. bay asks it once when this machine accepts a talk, and records its answer on the ack. A runner also asks it once for an outcome the change's author must act on. |
| `[plugin.hold] command` | Optional. The hold plugin: bay runs its program from main's tip as `<command> status`, and it answers `{"held": true\|false, "reason": "..."}`. While it answers held, or gives no answer bay can read, main is held. |
| `[plugin.hold] timeout_s` | How long the hold plugin may take, from 1 to 120 seconds. The default is 10. |
| `[plugin.prune] command` | Optional. The prune plugin: after `bay prune` deletes a branch, bay runs its program from main's tip as `<command> pruned <branch> <sha> <archive ref>`. Its outcome never undoes the delete. An empty value is refused. |
| `[plugin.prune] timeout_s` | How long the prune plugin may take for one branch, from 1 to 600 seconds. The default is 30. |
| `[plugin.notify]`, `[plugin.wake]`, `[plugin.runtime]` `timeout_s` | How long one call of that plugin may take, in seconds, at least 1. The defaults are 5, 5 and 10. |
| `[policy] profile` | The `[profile.<name>]` of the policy that `bay status` judges completion by. The default, `default`, requires `merged` and `checked`. A name with no such table makes the verdict `unknown`. |
| `[policy] evidence_reuse` | Optional. `exact-inputs` reuses a pass only when every input matches, and `off` turns reuse off. Left out, reuse is on only when a throughput key of `[runner]` is set. It needs a key registry that signs everything, or `[policy] reuse_unsigned = true` in `.bay/policy.toml`. |
| `[hooks] notice_budget_bytes` | The notice bytes one session gets per hour. The default is 2048. |
| `[hooks] cache_max_age_s` | The oldest coordination cache the hook accepts, in seconds. The default is 30. With an older cache, a write to a declared input gets `COVERAGE_UNKNOWN`. |
| `[hooks] checkpoint_idle_s` | How long after its last checkpoint a session's next hook event saves a draft checkpoint. The default is 600. A killed session loses at most this much work. |
| `[view] warm_history` | `true` makes `bay start` fetch the history of the declared paths, also when it picks up a change. Off by default. It needs Git 2.54 or newer. |
| `[red] window` | How many first-parent main commits the red and unattributed judgements read. The default is 50, at most 10,000. A post check that no commit in the window answers is `red_pending`, never green, so set it as deep as your post checks lag. A bay merge that a merge outside bay put off that line counts at that merge. |
| `[agent] product` | The agent product whose hooks bay installs: `claude` or `codex`. Codex's hooks only sense, so main stays the guard. Leave the table out to use no agent hooks. |
| `[agent] instructions` | The instruction files bay compares with main at `SessionStart`. The default is `AGENTS.md` and `CLAUDE.md`. |
| `[runner] command` | An external runner that follows the runner contract. Leave it out to use `bay runner run`. The command must exist on every machine that runs `bay merge`. When it cannot start, or refuses, `bay merge` and `bay merge --wait` end `failed`, and nothing merges that request later. |
| `[runner] submission_timeout_s` | How long the runner's `submit` may take before the submission ends as timed out. The default is 30. |
| `[runner] execution_budget_s` | The time budget for the checks before merging. It has no default. Without it, only each check's `timeout_s` applies. The queue deadline assumes 30 minutes. |
| `[runner] lease_s` | An owner whose chain does not move for this long may be replaced. The default is 60. |
| `[runner] heartbeat_s` | How often an owner renews its lease. The default is 10. |
| `[runner] takeover_s` | How long a change stays idle before another agent may adopt it. A lock that no step renews for this long goes to the next edit. The default is twice the lease. It must be longer than `lease_s`. |
| `[runner] publish_window_s` | Time added to the execution budget for the head of the queue. The default is 60. A head past both loses its place; after three times, its operation is blocked with `QUEUE_DEADLINE_EXCEEDED`. |
| `[runner] bisect_post` | `true` makes a runner find the merge that turned a post check red, by bisecting its `suspect` range. Off by default. |
| `[runner] ahead` | How many queued entries behind the head a runner checks early, on the base the entries ahead predict. The default is 0, off. At most 16. |
| `[runner] footprint_lanes` | `true` checks a queued entry early on the current main when its paths are disjoint from every entry ahead of it. It also turns on carrying `footprint = true` records. Off by default. |
| `[runner] batch` | `{ min, max, max_failure_rate }` checks the head and the entries behind it as one tree. Absent, it is off. The defaults are 1, 8 and 0.3. A batch always has two members or more, and at most 64. |
| `[runner] parallel_checks` | How many of a candidate's checks one runner runs at once, each in its own directory. The default is 1, one after another, and at most 16. It needs no evidence reuse. |
| `[runner] sandbox` | Not allowed: a repository can never turn off the check sandbox. Only a machine can, with `git config --global bay.executorSandbox none`. |
| `[retention] window_s` | How long a closed change keeps its drafts before the retention cut, and its clean view before `bay start` releases it. The default is one week. |
| `[retention] ref_budget` | How many bay refs the remote holds before a runner compacts the markers of merged changes into an index. The default is 100,000. |
| `[plugin.runtime] command` | Optional. The runtime plugin. `bay status` asks it for `running` and `effective` records about a merged commit. Only producer-signed records count, so it needs a key registry. |
| `[[target]]` | Optional. A promotion target, such as `staging`: `name`, `ref` (default `refs/heads/<name>`), `from` (main or another target), `requires` (check ids) and `soak_s`. bay never merges onto it. `bay status` names its newest promotable commit. |
| `[[target]] advance` | `true` makes each pass of `bay runner run` move the target to its newest promotable commit. Off by default. It needs `requires`. |
| `[[hold]]` | Optional. Holds main until `end`, an RFC 3339 UTC time to the second, with a `reason`. Until then only a change that carries `Fixes:` or `Reverts:` merges, and others wait with `MAIN_HELD`. |
| `[[hold]] target` | Optional. Holds that promotion target instead of main. Until `end`, only a merge that carries `Fixes:` or `Reverts:` is promotable to it. |
| `[[lane]]` | Optional. A sandbox lane: a `name` of a-z, 0-9 and `-`, and `refs`, full ref patterns with at most one `*`. A change opened with `bay start --lane <name>` runs its checks and never merges. |
| `[[lane]] window_s` | How long an ended lane change keeps its view. The default is one day, or the retention window when that is shorter. It must be shorter than `[retention] window_s`. |

> bay refuses an unknown key in this file, and a `takeover_s` that is not longer than `lease_s`.
> It also refuses `ahead`, `footprint_lanes` or `batch` beside `[policy] evidence_reuse = "off"`.
> It also refuses `advance = true` without `requires`, `parallel_checks` outside 1 to 16, and any `timeout_s` out of range.
> Then every bay command fails with exit 20, and its message names the problem.
> Each pass of `bay runner run` reads the file again from main.
> A `[runner] command` that cannot run fails every `bay merge`, so commit its script with the config.

## `.bay/policy.toml`

The acceptance policy lives on main at `.bay/policy.toml`. bay judges each candidate by the policy at its base. A change to the policy or a check's `adapter` paths also runs its own new paths, and both must pass. Without `.bay/policy.toml` at a change's base, the runner blocks its merge with `POLICY_MISSING`. To merge with no checks, set `unchecked = true` in the policy. Every policy change raises `revision` and merges as an ordinary checked change.

```toml
# .bay/policy.toml (on main)
revision = 5                       # an integer; every policy change raises it

[[check]]
id = "tests"
environment = "linux-x86_64"
command = "scripts/bay-check tests"
adapter = ["scripts/bay-check", "scripts/bay-check.d/", "pytest.ini"]
inputs = []                        # inputs beyond the tree; "commit" or "history" keys evidence by commit
expected_minimum = 400             # required, no default
timeout_s = 1800
path = ["/usr/local/bin", "/usr/bin", "/bin"]   # absolute directories; the check's PATH
env = ["DATABASE_URL"]             # the only variables passed from the runner
retry_on_failure = 1               # rerun a failure on the same tree, up to 5 times
allow_flaky = false                # a failure then a pass is flaky: CHECK_FLAKY

[[check]]
id = "e2e"
environment = "linux-x86_64"
command = "scripts/bay-check e2e"
adapter = ["scripts/bay-check", "scripts/bay-check.d/"]
expected_minimum = 20

[[quarantine]]                     # optional: one check set aside for a stated time
id = "e2e"                         # a required check with expected_minimum above zero must remain
environment = "linux-x86_64"
reason = "red on main since change k3m9p1; fix in progress"
end = "2026-10-25"                 # a date or a Change-Id
owner = "platform-team"            # optional: who answers for it
since = "2026-10-18"               # optional: when it was set aside; bay status shows its age

[limits]                           # optional; each key a positive integer
max_file_mib = 10                  # any one file the change adds or changes, in MiB
max_files = 200                    # the files it touches
max_added_lines = 5000             # the lines it adds; a binary file adds none
max_waiting_approvals = 5          # changes that may wait for a yes at once

[locks]                            # optional: files that never merge
paths = ["art/", "**/*.psd"]       # globs; one session at a time edits each, across machines
```

| Key | Meaning |
|---|---|
| `revision` | An integer. Every policy change raises it. |
| `[[check]] id` | The check id. |
| `[[check]] environment` | The environment the check runs in. One check can be required on several environments. |
| `[[check]] command` | The command that runs the check. Its executable must be in `adapter`. |
| `[[check]] adapter` | Every path that computes the verdict: the command, its harness, and the test tool's configuration. |
| `[[check]] inputs` | Inputs beyond the tree: `commit` or `history`. Both key the evidence by commit, so a result for one commit never stands for another with the same tree. `history` also gives the check read-only access to the repository's history. Any other value makes the policy invalid. |
| `[[check]] fixtures` | Optional. Content-addressed files the check reads, each `<name>@sha256:<64 lowercase hex>`, each name once. They enter the check's definition. bay passes them to the check and never reads the files. |
| `[[check]] expected_minimum` | Required for a check bay runs, with no default. The least number of tests the check must collect. An approval check leaves it out. |
| `[[check]] path` | Absolute directories that make the check's `PATH`. The default is `/usr/bin` and `/bin`. A relative entry makes the policy invalid. |
| `[[check]] env` | The only variables the check gets from the runner. Their names enter the digest, never their values. `PATH`, `HOME`, `TMPDIR`, `LANG` and `BAY_ADAPTER_DIR` are not allowed. |
| `[[quarantine]]` | One check set aside, with `id`, `environment`, `reason` and `end` (a date or a change id). |
| `[[quarantine]] owner` | Optional. Who answers for the quarantine. `bay status` and `bay doctor` show it. An empty owner is invalid. |
| `[[quarantine]] since` | Optional. The date (`YYYY-MM-DD`) the check was set aside. `bay status` counts the entry's age in days from it. |
| `[[check]] timeout_s` | The time limit of the check in seconds. The default is 1800. Queue wait does not count. |
| `unchecked` | `true` merges changes with no check. It cannot sit beside a `[[check]]`. |
| `[[check]] paths` | Optional. The only files and directories of the candidate the check sees. Without it, the check gets the whole tree. |
| `[[check]] materialize` | Optional. `"changed"` gives the check only the files the candidate changes against its base, plus its `paths`. Its evidence is then keyed by commit. Not with `footprint = true`, and not on an approval check. |
| `[[check]] materialize_timeout_s` | Optional. The time limit for copying the candidate for the check, 600 by default. |
| `[[check]] network` | Optional. `true` lets the check use the network. The default is `false`, and bay reads it at the base. |
| `[[check]] stage` | `pre`, the default, is required before merging. `post` is answered after merging and never blocks it. A failed `post` check turns main red on its paths. |
| `[[check]] when_paths` | Optional globs. The check is required only when the candidate's own diff touches a matching path. An empty list is invalid. |
| `[[check]] producers` | Optional. Only records from these producers answer the check. An empty list is invalid. |
| `[[check]] kind` | `check`, the default, is a check bay runs. `approval` is answered by an enrolled approver's signed record, and never runs. An approval check needs `when_paths`, is never `post`, and names no `command`, `adapter` or `expected_minimum`. |
| `[[check]] carry` | Optional, on an approval check only. Left out, an approval holds for later candidates of the same change while the files under `when_paths` stay the same. `false` asks for an approval on every candidate. bay reads it at the base, and it enters the check's definition digest only when set. A check bay runs that names it makes the policy invalid. Require a person's approval explains it. |
| `[[check]] footprint` | Optional. `true` says the verdict reads only the check's `paths`, else its `when_paths`. A record then carries to a later candidate whose read set is byte-identical, with `[runner] footprint_lanes` on. It needs `paths` or `when_paths`, and is invalid with `materialize = "changed"`. |
| `[[check]] retry_on_failure` | How many times bay reruns a `product_failure` on the same tree. The default is 0, at most 5. A failure then a pass is `flaky`. Not on a `post` check. |
| `[[check]] allow_flaky` | `true` lets a `flaky` check merge, named in `coverage_gaps` with every run. The default, `false`, refuses it with `CHECK_FLAKY`. Not on a `post` check. |
| `[[check]] baseline` | Optional. `"base"` fails a change only on failing units that did not also fail on its base. Pre-existing failures merge named in `coverage_gaps` as `baseline_failures`. Not on a `post` or approval check. |
| `[[check]] waivable` | Optional. `true` lets an enrolled approver waive the check on one candidate, with a signed reason. A waiver is never a pass: the merge names it in `coverage_gaps` with the real verdict. It needs a key registry. On an approval check it makes the policy invalid. bay reads it at the base, and it enters the check's definition digest only when set. Require a person's approval explains it. |
| `[profile.<name>] require` | The completion dimensions this profile requires: `merged`, `checked`, `running`, `effective`, or a `[[dimension]]` name. `[policy] profile` in the config picks one. |
| `[profile.<name>] require_signed` | `true` counts only signed evidence. A dimension that rests only on unsigned records is then `unknown`. |
| `[[dimension]] name` | A completion dimension of your own, such as `reviewed`. It cannot reuse a name bay defines, or appear twice. |
| `[[dimension]] kind` | The evidence kind that meets it, such as `review`. |
| `[[dimension]] producers` | The producers whose records count. List at least one. |
| `[agent] require_actor` | `true` makes `bay start` refuse with `ACTOR_UNNAMED` while `BAY_ACTOR` is unset. |
| `[agent] require_actor_proof` | `true` makes `bay merge` refuse with `ACTOR_UNPROVEN` while several actors share one OS account that can read a file agent key. Off by default. |
| `[agent] max_live_changes` | The most live changes one actor may hold. At that many, `bay start` refuses with `WIP_LIMIT`. A lane change counts. No limit by default. |
| `[policy] signing` | What a key registry signs. `all`, the default, signs every request, merge and record. `approvals` signs only `approval` and `waiver` records, and everything else runs unsigned. Any other value makes the policy invalid. This `[policy]` table takes one other key, `reuse_unsigned`, and is apart from the one in `.bay/config.toml`. Keys and the registry explains both modes. |
| `[policy] reuse_unsigned` | `true` lets evidence reuse, early checks, footprint carry and `batch` run where evidence counts unsigned: without a registry, or with `signing = "approvals"`. Off by default, and only `true` or `false`. Results still say `trust: unsigned` or `approvals`. bay reads it at the base, so a change never turns it on for its own merge. With a registry that signs everything, it changes nothing. |
| `[limits]` | Optional caps on one change: `max_file_mib`, `max_files` and `max_added_lines`. Each is a positive integer. Over one, bay refuses `CHANGE_TOO_LARGE`. Cap the size of a change explains them. |
| `[limits] max_waiting_approvals` | Optional. The most live changes that may wait for an approval at once, a positive integer. At that many, `bay merge` refuses `WIP_LIMIT` for a change that needs one more. Require a person's approval explains it. |
| `[locks] paths` | Optional globs, read like `when_paths`. One session at a time edits each file they name, across machines. bay's hooks take the lock at the first edit, and Agents and hooks explains locks. An empty glob makes the policy invalid. |
| `[[input]] name` | A named input, `<kind>:<name>`, that a change declares with `bay start --input`. Each name appears once. |
| `[[input]] provider` | The command that prints the input's version for a tree. Its executable must be in `adapter`. |
| `[[input]] adapter` | Every path the provider's answer depends on. bay runs them from the base, like a check's. |
| `[[input]] path` | Absolute directories that make the provider's `PATH`. Without it, the provider runs with no `PATH`. |
| `[[input]] phase` | Optional: `expand`, `migrate` or `contract`. In `expand` or `migrate`, a new version never invalidates a change. Without it, every new version does. |
| `[[input]] timeout_s` | The provider's time limit, from 1 to 300 seconds. The default is 30. |

## Rules for checks

- Each pair of `id` and `environment` appears only once.
- The executable of `command` must be inside the `adapter` list.
- bay runs the `adapter` paths from the base.
- A change to the policy or an `adapter` path also runs the new paths.
- One check `id` belongs to one stage, `pre` or `post`, never both.
- bay refuses an unknown key in any table with `POLICY_INVALID`, naming the key, its table and its line.

## What a check sees

Each check runs in a sandbox that binds only an allow-list of paths. It sees its candidate, its `adapter` paths and the system files a program needs to start. It never sees the machine's `HOME`, the rest of the repository's `.git`, another view or bay's key files. A program under `HOME` or `/opt` is not visible to the check, unless the machine names its directory. A check has no network unless its `[[check]]` entry sets `network = true`. bay reads that entry at the base, so a candidate cannot grant itself the network.

- bay copies the candidate for each check without packing the whole tree, so large repositories check quickly.
- `paths` limits a check to those files and directories of the candidate. An empty list is invalid.
- `materialize_timeout_s` bounds that copy, 600 seconds by default. Running over is `infra_failure`, never a pass.

> The sandbox is `bwrap` on Linux and `sandbox-exec` on macOS.
> Without one, a check fails as `infra_failure`.
> To run checks without a sandbox on purpose, set `git config --global bay.executorSandbox none` on that machine.
> Each check's candidate, `adapter` paths, `HOME` and `TMPDIR` sit under `bay/check/<candidate>/` in the common Git directory that linked worktrees share.
> So their absolute paths usually hold a `.git` part.
> The check starts in its candidate tree, so judge paths relative to it.

### Let checks read a directory on the machine

Some checks read a directory outside their tree, such as a package store or a tool cache. Name each one on the machine that runs checks, with `bay.checkReadPath` in Git config. Each value is an absolute path, or starts with `~/` for the machine's `HOME`. The sandbox binds each one read-only, so a check reads it and never writes to it. Set it globally or for one clone. A repository cannot set it. `bay doctor` lists the paths in its `check-read-paths` row, and marks a missing one `degraded`.

```sh
git config --global --add bay.checkReadPath '~/.cache/pnpm'   # read-only in every check on this machine
```

> A path that overlaps the repository, a view or a key fails each check as `infra_failure`.
> So does a value that is neither absolute nor under `~/`. Both name the value.
> A path missing on the machine is not bound, so a check cannot read it.
> A sandboxed check's record lists the paths under `sandbox_read`.
> With `bay.executorSandbox = none`, nothing is read-only, and the record lists none.

### Check only the changed files

`materialize = "changed"` gives a check only the files its candidate changes against the base: the request's `changed_paths`. bay adds the check's own `paths` to them, such as a config file the check always reads. A file the change deleted is absent, and the `adapter` paths still come from the base. The request then says `"materialized": "changed"`, so the check knows the tree is partial. What the check sees depends on the base, so its evidence counts only for that exact commit. bay reads the setting at the base, and it enters the check's definition digest.

```toml
[[check]]
id = "lint"
environment = "linux-x86_64"
command = "scripts/bay-check lint"
adapter = ["scripts/bay-check"]
expected_minimum = 1
materialize = "changed"            # only the changed files, plus paths
paths = [".lint.toml"]             # always given to the check
```

> With `footprint = true`, or on an approval check, `materialize` makes the policy invalid.
> A policy with such a check never forms a batch.

## Rules for `expected_minimum`

- Each `[[check]]` must set `expected_minimum`. There is no default.
- A check that collects fewer tests than `expected_minimum` is `unknown`, never `pass`.
- `expected_minimum = 0` lets a mapped check pass on an empty selection, as a reported coverage gap.
- Unless the policy says `unchecked = true`, at least one required check needs `expected_minimum` above zero.
- Otherwise the runner blocks every merge with `POLICY_INVALID`.

## Choose when a check is required

`when_paths` makes a check required only when the candidate's own diff touches a matching path. `*` matches within one path part, `**` any number of parts, and a trailing `/` a whole directory. A change to the policy or an `adapter` path requires every check, whatever its `when_paths`. The merge result lists each skipped check under `not_required`, with the reason. `producers` names the producers whose records answer a check. A record from a producer the list leaves out does not count.

```toml
[[check]]
id = "web"
environment = "linux-x86_64"
command = "scripts/bay-check web"
adapter = ["scripts/bay-check"]
expected_minimum = 50
when_paths = ["web/", "package.json"]   # required only when the diff touches these
producers = ["ci-1", "ci-2"]            # only these producers' records count
```

## Add a site dimension

A `[[dimension]]` table defines a completion dimension of your own, such as `reviewed`. `bay status` reports it, and a profile may require it. It never blocks a merge. Wire any reviewer to bay explains how bay judges it.

```toml
[[dimension]]
name = "reviewed"
kind = "review"
producers = ["review-bot"]

[profile.reviewed]
require = ["merged", "checked", "reviewed"]
```

## Name an input's provider

A change can depend on a named input, such as an API contract: `bay start --input contract:api.orders`. An `[[input]]` entry in the policy names the provider that reads that input's version. The provider runs in the check sandbox, and gets `input` and `tree` as JSON on stdin. It prints a `version`, and `consumers`: each a `path` and a `kind` of `historical`, `generated` or `executable`. When main needs a version the change has not read and checked, bay refuses its merge with `INPUT_UNOBSERVED` or `INPUT_CHANGED`. A provider that gives no version blocks the merge with `COVERAGE_UNKNOWN`.

```toml
[[input]]
name = "contract:api.orders"
provider = "scripts/contract-version api.orders"
adapter = ["scripts/contract-version"]
path = ["/usr/bin", "/bin"]
phase = "expand"                   # expand, migrate or contract
timeout_s = 30
```

> A named input with no `[[input]]` entry has unknown coverage, and bay does not block on it.
> When every consumer under the change's paths is `historical`, the input never blocks the change.

## Run a check after merging

`stage = "post"` marks a check that runs after merging. It never blocks the merge it judges, and bay does not run it. A producer runs it and writes a `check` record for the merged commit with `bay evidence record`. `--check` and `--environment` name the post check, as the policy names it. The merge result lists it under `post_checks`, and `bay status` shows it `pending` until its record arrives. One check `id` cannot be in both stages, so give the post check its own id.

```sh
bay evidence record --kind check --verdict pass --commit a1b2c3d --check smoke --environment linux-x86_64
```

## When a post check fails

A failed post check turns main red on its paths: its `when_paths`, or every path. While main is red, bay refuses a merge that touches those paths with `MAIN_RED`, naming the owner. `bay status` lists each red check under `detail.red`, with its paths, merge, change, `suspect` and `culprit`. A post check that no commit in the window answers is in `detail.red_pending`, never green. Its `state` is `pending`, or `pending_beyond_window` when its last answer is older than the window. A pending check refuses no merge.

- **Fix it**: a change opened with `bay start --fixes <owner>` is never refused `MAIN_RED`.
- **Undo it**: `bay undo <owner>`, or a change that supersedes the owner, is never refused either.
- **A later pass**: a pass of the post check on a newer merge turns main green.
- **Quarantine**: a `[[quarantine]]` entry for the check clears the red too.

> bay works out the red from the newest `[red] window` first-parent main commits on each read, and never stores it.
> Those commits count whether bay merged them or not.
> A red owned by a merge made outside bay clears only by a pass or a quarantine.
> `bay status --fleet` lists the same red checks, and `on_red` marks each session or change on those paths.
> `bay status --since` lists a `main_red` event when a check turns red, and `main_red_cleared` when it clears.
> When bay cannot read what it needs to judge the red, `detail.red` holds `unread` and the reason.

### Find the merge that broke it

A post check may skip merges, so its first failure can come after the merge that broke it. So each red check names its `suspect` range: the last pass, the first failure, and the merges between. When the last pass sits right below the first failure, the evidence leaves one merge: the `culprit`. The culprit then owns the red, and `MAIN_RED` names it. `culprit` lists the verdict of each merge read, from the pass up to the newest failure.

- With `bisect_post = true`, a runner runs the post check on the range's midpoint merges.
- A failure keeps the older half, and a pass keeps the newer half.
- At one merge left, it runs the check there once more, to rule out a flake.
- A second failure names the culprit. A pass marks that merge `flaky`, and names no culprit.
- A range of n merges costs at most log2(n) runs, rounded up, plus that one rerun.
- Each run is ordinary post evidence, signed by the machine's producer key when the repository has a registry.

```toml
# .bay/config.toml
[runner]
bisect_post = true                 # bisect a red post check's suspect range
```

> The bisect needs a range of two merges or more, with a pass below it in the window.
> It runs only on a machine that serves the check's environment, and that the check's `producers` list names.
> A run that gives no verdict stops the bisect as `undecided`.

## The check's environment

- A check starts with a fixed environment: `PATH` from `path`, `HOME` and `TMPDIR` in the sandbox, and `LANG=C.UTF-8`.
- bay also sets `BAY_ADAPTER_DIR` to the directory of the check's `adapter` paths.
- It also gets each variable that `env` names; a named variable the runner lacks is `infra_failure`.

## Rerun a flaky check

A flaky check passes and fails on the same tree. `retry_on_failure = N` reruns a `product_failure` on the same tree, up to N times, at most 5. bay stops at the first answer that is not a failure, and keeps every run as evidence. A failure and then a pass is `flaky`, never a plain pass. By default, bay refuses a flaky check with `CHECK_FLAKY`. With `allow_flaky = true` the change merges, and the result names the check in `coverage_gaps` with every run.

```toml
[[check]]
id = "tests"
environment = "linux-x86_64"
command = "scripts/bay-check tests"
adapter = ["scripts/bay-check"]
expected_minimum = 400
retry_on_failure = 2               # rerun a product_failure up to twice
allow_flaky = false                # true: merge a flaky check, named in coverage_gaps
```

> Only a `product_failure` reruns. An `unknown` or `infra_failure` never does.
> A check that fails every run stays `CHECK_FAILED`.
> Both keys apply only to a `pre` check. A `post` check that sets either makes the policy invalid.
> `bay status` counts each check's flaky merges over the red window under `detail.flakes`.
> So you see a check that flakes before anyone quarantines it.

## Judge only new failures

Sometimes a required check already fails on main, and every change inherits that failure. `baseline = "base"` on a `[[check]]` fails a change only on failures its base does not share. bay compares each failing unit, by its `id`, with the same check's result on the change's base. A unit that failed on the base too is pre-existing. A unit the base passed or lacks is new. When every failure is pre-existing, bay judges the rest of the result. A merge names them in `coverage_gaps` as `baseline_failures`. bay never reports a pre-existing failure as a pass.

```toml
[[check]]
id = "tests"
environment = "linux-x86_64"
command = "scripts/bay-check tests"
adapter = ["scripts/bay-check"]
expected_minimum = 400
baseline = "base"                  # fail a change only on failures main does not share
```

> bay reuses the base's own record of the check, or runs it once on the base and keeps that record.
> A base run that ends `unknown` or `infra_failure` makes the change's run `unknown`, never a pass.
> A result without units has nothing to compare, so every failure in it is new.
> A refused change's `detail.checks` names new failures under `reasons`, and pre-existing ones under `baseline_failures`.
> `baseline` applies only to a `pre` check. Its one value is `base`, and it enters the check's definition digest.

## Let a check read history

Each check request also names the `base` commit, the `change` with its intent and actor, and its `messages`. `messages` holds the change's own commit messages, oldest first, at most 50 commits and 64 KiB. A check whose verdict reads these fields declares `inputs = ["commit"]` or `["history"]`. Then its evidence counts only for that exact commit, never for another commit with the same tree. With `inputs = ["history"]`, the request also carries `git`: a temporary Git directory that reads the repository's history. The sandbox binds the object store read-only, so the check cannot write to it.

```toml
[[check]]
id = "commit-rules"
environment = "linux-x86_64"
command = "scripts/bay-check commit-rules"
adapter = ["scripts/bay-check"]
expected_minimum = 1
inputs = ["history"]               # the request's git reads history; evidence keyed by commit
```

> `git.dir` is the temporary directory. `git.refs` names `base`, `candidate` and, for a change, `revision`.
> So `GIT_DIR=<git.dir> git log <base>..<revision>` lists the change's own commits.
> When bay cuts `messages`, it keeps the newest and sets `messages_truncated`.
> A batch or a bisect checks no single change, so its request has no `change` or `messages`.
> With `bay.executorSandbox = none`, nothing keeps the store read-only, and the record says so.

## Set a broken check aside

Sometimes a required check is red on main itself, and it would block every change. Add a `[[quarantine]]` entry for it in a checked policy change. The entry names the check, a reason and an end, and optionally an `owner` and a `since` date. The merge result names the quarantine as a coverage gap. `bay status` lists each entry with its owner, its age in days from `since`, and whether it is `overdue`. Remove the entry in the change that fixes the check. Past its end, it stays in force until then.

## Hold main

Sometimes main must take only fixes for a while, such as during an incident or a migration. A `[[hold]]` with no `target` holds main until `end`. A `[plugin.hold] command` can hold it too. While main is held, only a change that carries `Fixes:` or `Reverts:` merges. Any other change stays `queued` with `MAIN_HELD`, gives up its queue place, and merges once the hold clears. bay asks before it runs a change's checks, and again before each push to main. `bay status` names the hold under `detail.hold`: its `source` (`window` or `plugin`), `reason` and `end`.

```toml
# .bay/config.toml on main
[[hold]]                           # no target: the hold is on main
end = "2026-11-02T06:00:00Z"       # RFC 3339 UTC, to the second
reason = "database migration"

[plugin.hold]
command = "scripts/bay-hold"       # answers {"held": true|false, "reason": "..."} to "status"
timeout_s = 10                     # 1 to 120 seconds; 10 by default
```

> bay reads both from main's tip, so a change never lifts a hold for itself.
> It runs the plugin's program from main's tip, with only `PATH` and `HOME` from its environment.
> An answer bay cannot read counts as held: no start, a non-zero exit, a timeout or bad JSON.
> A held wait counts toward no queue deadline, and a sandbox lane change never waits on a hold.
> bay's runner and `bay merge --wait` judge the hold. The hook of a host from `bay host init` does not.
> When bay cannot read the config at main's tip, main counts as held, with `source` `config`.

## Cap the size of a change

A `[limits]` table in `.bay/policy.toml` caps how big one change may be. `max_file_mib` caps the size of any one file the change adds or changes, in MiB. `max_files` caps the files it touches, and `max_added_lines` the lines it adds. A binary file adds no lines that count, so only `max_file_mib` judges it. bay judges the caps by the policy at the base, so a change never lifts its own. `bay merge` refuses a change over a cap with `CHANGE_TOO_LARGE`, before it submits anything.

```toml
# .bay/policy.toml on main
[limits]                           # each key optional; leave one out for no cap
max_file_mib = 10
max_files = 200
max_added_lines = 5000
```

> At the candidate build, bay judges the change again, before any check runs, and refuses it the same way.
> A batch stops before a member over a cap, and bay refuses that member at its own turn.
> `detail.exceeded` names each cap, its `max`, the `value`, and up to five of the largest `paths`.
> A zero, a negative or fractional number, a quoted number or an unknown key makes the policy invalid.
> Keep large files outside the repository, such as on a shared drive, or split the change.

## Keep required checks short

Without the throughput settings, one main branch takes one change per required check run. So the length of your required checks sets how many changes merge each day. Require a few fast checks before merging. Run the broad suite after merging, ordered by risk. A red found after merging gets a fix, or `bay undo`.

> Read the capacity post on the blog for the numbers: how many agents one main branch can take.

## Change a check or its files

A candidate that changes the policy or an `adapter` path must raise `revision`. bay checks that candidate twice: once with the base's `adapter` files, once with its own. Both runs must pass every required check. Such a candidate requires every check, whatever its `when_paths`. As a last resort, a registry key can sign a repair of a broken check. That repair touches only `.bay/policy.toml` and `adapter` paths, and its new files must pass.

> A broken policy blocks every merge, its own repair too, with `POLICY_INVALID`.
> Its repair merges when the registry key signs it.
> On the machine that holds that key, set `bay.agentKey` to it for that one `bay merge`, then set it back.
> bay has not tried this path on a main from `bay host init` yet.

## Test a check or a plugin

`bay plugin test` runs bay's contract cases against a check command, or a wake or notify plugin. It runs the command through bay's own code, with the same environment and time limits. Each case names what bay sent, what came back, the result and why. `detail.bay_records` shows what bay itself would record for the first request. It never merges, pushes or writes a ref, and it runs a check in a scratch repository. It exits `0` only when every case passes, and its result is `failed` otherwise.

```sh
bay plugin test check --expected-minimum 50 -- scripts/bay-check web
bay plugin test wake -- scripts/bay-wake
bay plugin test notify -- scripts/bay-notify
```

> For a check, `--timeout-s`, `--expected-minimum`, `--path` and `--env` stand in for the `[[check]]` fields.
> `--with` names another `adapter` path that the check reads.
> For a late wake or notify answer, the test waits up to twice the limit, and still judges it.

---

# Promote main to other branches

> Move a branch such as `staging` only to a checked commit from main, and see which commit is ready.

Source: https://getbay.io/docs/promote (HTML).

A promotion moves a branch such as `staging` or `production` to a commit that is already on main. `[[target]]` in `.bay/config.toml` names each such branch and the rule it moves by. Main is whichever branch bay serves, so a `dev`, `staging`, `production` chain needs no branch called main. The rule names the checks that must pass, and how old the commit must be. `[[hold]]` holds a branch until a set time, except for fixes and undos.

bay has no promote command. A person promotes with a plain `git push` to the branch. Or, with `advance = true`, the runner moves the branch for you. bay shows each branch's state in `bay status`, and each move in `bay status --since`. On a host from `bay host init`, the hook refuses a push that breaks the rule. On any other host, bay sees a bad move only after the push. bay deploys nothing. Your deploy tool reads the branch.

## When to use promotion

- **Stage a release**: `staging` takes only main commits whose required checks passed.
- **Soak before production**: `production` takes a commit from `staging` only once it is old enough.
- **Hold for a holiday**: until the hold ends, only fixes and undos reach the branch.
- **See what is ready**: `bay status` names the newest commit you can promote now.
- **Follow green main**: with `advance = true`, the runner moves `staging` to each new promotable commit.

## Declare a target

Add one `[[target]]` per branch to `.bay/config.toml` on main. `name` names the target, and `ref` defaults to `refs/heads/<name>`. `from` is main by default, or the name of another target, such as `staging`. `requires` lists check ids from `.bay/policy.toml`, for example your post checks. `soak_s` is the minimum age of the commit in seconds, by its committer time. It defaults to 0.

```toml
[[target]]
name = "staging"
requires = ["integration"]         # check ids from .bay/policy.toml

[[target]]
name = "production"
from = "staging"                   # promoted from staging, not from main
requires = ["integration"]
soak_s = 86400                     # the commit is at least one day old
```

> A target's ref is a full ref under `refs/`. It is never main's, and never one of bay's own refs.
> A config with two targets of one name or ref, an unknown `from`, or a `from` cycle does not load.

## A chain with no main branch

Agents merge into `dev`, `staging` takes checked commits from `dev`, and `production` takes them from `staging`. Serve `dev` with `bay install --main dev`, and declare the two targets in the config on `dev`. No agent pushes to `staging` or `production`: bay's hook refuses it.

```toml
main = "dev"                       # the branch bay serves; must match bay install --main

[[target]]
name = "staging"
requires = ["integration"]

[[target]]
name = "production"
from = "staging"
soak_s = 3600
```

## Hold a target

A `[[hold]]` with a `target` names that target, an `end` time in UTC to the second, and a `reason`. Until `end`, the target takes only a commit with a `Fixes:` or `Reverts:` trailer. `bay start --fixes` and `bay undo` write those trailers on the main commit. bay reads holds from main's tip only, so a hold you remove there ends at once. A `[[hold]]` with no `target` holds main itself. Configuration explains that hold.

```toml
[[hold]]
target = "production"
end = "2026-12-27T00:00:00Z"       # RFC 3339 UTC, to the second
reason = "holiday hold"
```

## Promote a commit

Read the target's row in `bay status`, then push its `promotable.commit` to the target's ref. `behind_from` counts the commits between that commit and the source's tip. When no commit is ready, `promotable.blocked_by` says why the newest one fails.

```sh
commit=$(bay status --json | jq -r '.detail.targets[] | select(.name == "staging") | .promotable.commit')
git push origin "$commit:refs/heads/staging"
```

## Let the runner promote

Set `advance = true` on a target, and each pass of `bay runner run` moves it for you. It moves the target to its newest promotable commit, the one `bay status` names. Before the push, it judges that commit again by the same rule as the host hook. It pushes under a lease on the tip it read, so it never goes backwards or over another push. A target promoted from another target moves after its source, in the same pass. A target with `advance = true` needs `requires`, or the config does not load.

```toml
[[target]]
name = "staging"
requires = ["integration"]
advance = true                     # each runner pass moves staging to its newest promotable commit
```

> `bay runner run --once` lists each move under `detail.advanced`. A looping runner prints a failed move on stderr.
> `bay merge --wait` never moves a target. Run a runner for targets that advance.
> Without `advance`, bay never moves the target.

## See each target

`bay status` fetches the target refs, then lists one row per target under `detail.targets`. The fleet map lists the same rows from its last fetch. A row bay cannot judge says why under `unread`, and never reads as ready.

```sh
bay status --json | jq '.detail.targets'
```

| Field | Meaning |
|---|---|
| `tip` | The commit the target's ref points at, or `null` when the remote has no such ref. |
| `from`, `from_tip` | The source, main or another target, and its tip. |
| `on_from` | Whether the tip is on the source's first-parent line, or is a bay merge that a merge outside bay put off it. |
| `drift` | Commits on the target that the source does not reach: `count`, and up to 5 in `commits`. |
| `lag` | How many first-parent commits of the source the target is behind. |
| `requires`, `soak_s` | The rule in force, with the config at the target's tip added. |
| `checks` | Each required check at the tip: `pass`, `product_failure`, `flaky`, `unknown`, `pending`, `unknown_check` or `no_policy`. |
| `freeze` | The hold in force, with `end` and `reason`, else `null`. |
| `tip_failures` | The rules the tip breaks: off the source's line, or a required check that does not pass. |
| `promotable` | The newest commit you can promote now, with `behind_from`. Else `blocked_by`, or `at_from` when the target is at the source's tip. |
| `fetched` | `false` when bay read the copies from its last fetch. |

## See targets move

Outside a view, `bay status --since` lists one event each time a target's tip changes. `target_moved` means the new tip meets its rule. `target_unpromotable` means the tip is off its source's line, or a required check does not pass. Each event names `target`, `ref`, `commit`, `from` and `failures`. Without a cursor, `bay status --since` lists `target_unpromotable` for each target whose tip fails now.

```sh
bay status --since --json | jq '.detail.events[] | select(.kind | startswith("target_"))'
```

## How bay judges a promotion

- The commit is on the first-parent line of the source's tip, and fast-forwards the target.
- Every check that `requires` names passes on the commit's tree, judged as a post check.
- A check id that the policy does not declare never counts as a pass.
- The commit's committer time is at least `soak_s` old.
- No hold is in force, or the commit carries `Fixes:` or `Reverts:`.
- bay adds the `requires` and `soak_s` from the config at the target's tip, so a rule never weakens.

> With a key registry, only signed check records count. Without one, records count unsigned, as everywhere else.
> A bay merge that a merge outside bay put off the source's first-parent line counts as on it.

## What promotion cannot do

- bay moves a target only when it sets `advance = true`. Otherwise a person or your tooling pushes it.
- bay deploys nothing. Your deploy tool reads the target branch.
- bay reads the check records that exist. It starts no check to make a commit ready.
- Off a host from `bay host init`, bay cannot stop a bad push. It reports it afterwards.
- `target_unpromotable` judges the line and the checks only. Soak and hold apply when you push.
- A waiver never counts toward promotion. A waived check still blocks every target that `requires` it.

## Refusals

On a host from `bay host init`, the hook refuses the whole push with `PROMOTION_REFUSED`. The message names the target's ref and each rule the commit breaks. The hook judges a target push with or without a key registry. It also refuses any push that deletes a target. With Claude Code hooks, the hook refuses an agent's own `git push` of a target with `SHELL_DENIED`. The Results page lists what to do next.

| Rule | Meaning |
|---|---|
| `not_on_from` | The commit is not on the first-parent line of the source's tip, nor a bay merge that a merge outside bay put off it. |
| `not_fast_forward` | The push does not move the target forward from its old tip. |
| `check` | A required check does not pass on the commit. |
| `soak` | The commit is younger than `soak_s`. |
| `freeze` | A hold is in force, and the commit carries no `Fixes:` or `Reverts:`. |

## Limits

- **Search**: `promotable` reads at most 50 first-parent commits of the source, newest first.
- **Drift**: a row names at most 5 drift commits, and counts them all.
- **Clock**: the hook judges soak and hold by the host's clock, and `bay status` by yours.

---

# Agents and hooks

> How a coding agent learns bay, and what the hooks of each agent product add.

Source: https://getbay.io/docs/agents (HTML).

Coding agents use bay through its commands and the JSON result each one prints. The bay skill teaches an agent those commands. With Claude Code or Codex, bay's hooks also refuse a stale write before it happens. Without hooks, a stale change still cannot merge.

## What each agent gets

Every agent product that runs shell commands gets the same merge promises. It works through bay's commands, their JSON results and the bay skill. An agent product with bay's hooks also gets early refusals, notices and live sessions in the fleet map.

| Agent | bay's hooks | Commands, JSON results and skill | Early refusals, notices and fleet sessions |
|---|---|---|---|
| Claude Code | Built | Yes | Yes |
| Codex | Built, as a sensor | Yes | Early refusals and fleet sessions. A hook that fails lets the tool run. |
| Cursor | Not yet | Yes | No |
| Grok Build | Not yet | Yes | No |
| Antigravity | Not yet | Yes | No |
| Gemini CLI | Not yet | Yes | No |

### What needs bay's hooks

- Refusing a stale write, a path another live session holds, or a locked path, before the write happens.
- Refusing dangerous Git commands before they run.
- Session notices: a refusal, a failed check, an early conflict, a merge on its paths, a talk, or no runner.
- A notice when main changed an instruction file.
- Draft checkpoints when a session ends or sits idle.
- Session rows in `bay status --fleet`, which come from each machine's live summary.

## The bay skill

The bay skill tells the agent which command to run, and to pass `--json` and read the result. Teach your agent bay says where it installs, what it holds and how `bay doctor` checks it.

## Tell any agent about bay

Paste this into the repository's `AGENTS.md` or `CLAUDE.md`. It works for any agent product that can run a shell command. With Claude Code, the bay skill from `bay install` gives the same rules.

```text
## Merging changes with bay

This repository merges changes with bay (https://getbay.io/docs).

- Once per machine: curl -fsSL https://getbay.io/install.sh | sh. bay's hook entries come from main.
- Start each task with: bay start <intent> --paths <paths>. Work in the view it prints.
  <intent> says what the work is, in a few words, such as "fix the login page", or a card id.
- When the work is done, run: bay merge. It returns at once; add --wait to wait for the outcome.
  Never push main or merge by hand. Give a change up with: bay abandon.
- Pass --json, and read stdout as one bay.result/1 JSON document. Act on its decision and reason.
  Unknown is not success.
- Read state with: bay status, or bay status --fleet for every session. Undo a merged change with: bay undo <change>.
- See what changed with: bay status --since <cursor>. Ask another agent for one thing with: bay talk.
- Fix a merged change with: bay start <intent> --fixes <change>.
- Never push, merge into or move main, or push a promotion target. Never run git reset --hard, git clean -f,
  git branch -D, git checkout . or git restore ., and never force-push.
```

## Turn on agent hooks

Name your agent product in the `[agent]` table of `.bay/config.toml`, and merge it into main. Fetch main first: `bay install` reads `[agent]` from main as this clone last fetched it. Then `bay install` adds hook entries that call `bay hook <event>`: `.claude/settings.json` for Claude Code, `.codex/hooks.json` for Codex. Merge that file into main once: every clone and view then runs the hooks, with no `bay install` of its own. Read the hooks from bay, below, gives each product's version floor. `bay doctor` checks it. Without agent hooks, the `hooks` row of `bay doctor` reads `off`; Security and privacy says what bay still guards.

```toml
[agent]
product = "claude"   # or "codex"
```

> `bay install --machine` never writes hook entries. The Install page lists what each install writes.
> With the settings file on main, the `hooks` row of `bay doctor` names merging its change, not another install.
> For many machines, Roll bay out to many machines gives the order.

### Codex hooks only sense

With `product = "codex"`, `bay install` writes bay's entries to `.codex/hooks.json`. Commit it to main. Codex runs those entries only in a trusted project. It skips an untrusted project's hooks without a message. Before a write or a shell command, the hook's exit `2` denies the tool call, as with Claude Code. When bay's hook crashes, hangs or fails, Codex runs the tool anyway. So the hook is a sensor, and main stays the guard: bay judges every change again at merge.

> The `agent-hooks` row of `bay doctor` says `fail_open` for Codex.
> On a machine without bay, the hook fails, and Codex runs the tool.
> No recording shows Codex keeping a session open for a stop hook, so the ask to save may fail.
> After session start, bay's notices to Codex go to stderr. A recorded Codex session never showed them to the agent.
> `bay install` puts the skill where Claude Code reads it. For Codex, paste the block above into `AGENTS.md`.

### Read the hooks from bay

`bay help --json` declares each agent product's hooks under `detail.adapters`, from the tables bay's own code uses. Each entry names the product, its settings file and its version floor. Each event names the product's event, the `bay hook` moment it calls, and whether it `blocks`. `on_failure` says what the product does when the hook fails. `cannot_see` lists what the hook never judges, such as a write by a shell command. The table below comes from that list, so it says what bay's code writes.

```sh
bay help --json | jq '.detail.adapters[] | {product, floor, cannot_see}'
```

| Product | Settings file | Floor | Events that can block | On a machine without bay |
|---|---|---|---|---|
| Claude Code (`claude`) | `.claude/settings.json` | 2.1.302 or newer | `PreToolUse` (on failure: `block`), `Stop` (on failure: `allow`) | `allow`: each entry first checks that `bay` is present |
| Codex (`codex`) | `.codex/hooks.json` | 0.153.2 or newer | `PreToolUse` (on failure: `allow`) | The hook fails, and the tool runs |

### Hook moments

Each hook entry runs `bay hook <moment>`, and the moment names what just happened. One moment can serve two events of a product, such as `session.wait`. The table comes from the same list in `bay help --json`.

| Moment | Claude Code event | Codex event |
|---|---|---|
| `session.start` | `SessionStart` | `SessionStart` |
| `turn.start` | `UserPromptSubmit` | `UserPromptSubmit` |
| `tool.before` | `PreToolUse` | `PreToolUse` |
| `step.end` | `PostToolBatch` | `PostToolUse` |
| `file.change` | `FileChanged` | None |
| `session.wait` | `Notification`, `TeammateIdle` | None |
| `turn.end` | `Stop` | `Stop` |
| `subagent.start` | `SubagentStart` | `SubagentStart` |
| `subagent.end` | `SubagentStop` | `SubagentStop` |
| `session.end` | `SessionEnd` | `SessionEnd` |
| `compact.before` | `PreCompact` | None |

## What the hooks do

- At session start, the hook prints the active change's state and the live changes that overlap its declared paths.
- At session start, it also names each instruction file that main changed after the tree's commit.
- Before a write, the hook refuses a write that bay already knows is stale.
- Before a write or a shell command, it refuses a path that another live session in the same tree holds.
- Before a shell command, the hook refuses a fixed list of dangerous Git command shapes.
- After each step, the hook records the files that the step wrote.

> A view holds `.claude/settings.json` when the main commit holds that file.
> Then a session started inside the view runs bay's hooks too.
> bay's checks at merge time still hold, so a stale change still cannot merge.

## Lock files that never merge

Git cannot combine two edits of an image, a model or another binary file. When two sessions edit one, the work of one session goes to waste at merge. Name such paths as globs in `[locks] paths` of `.bay/policy.toml`, and merge it into main. Then one session at a time edits each such file, across every machine. bay's hooks take and check the locks, so locks need Claude Code or Codex.

```toml
# .bay/policy.toml
[locks]
paths = ["art/", "**/*.psd"]   # every file under art/, and every .psd
```

> `*` matches within one part of a path, so `*.psd` names only files at the top.
> `**` matches any number of parts, and a glob that ends in `/` names everything below.
> An empty glob makes the policy invalid.

### Take a lock

Before an edit tool writes a path that `[locks] paths` names, bay's hook takes the path's lock. The lock is a ref under `refs/bay/locks/` on the remote, so every clone sees it. Of two machines that edit one path at once, exactly one takes the lock. bay refuses the other edit with `PATH_HELD`. The refusal names the holder's agent and session, and the lock's age. The holder's own later edits go through.

> bay reads `[locks]` from main as this clone last fetched it, so a change never lifts a lock for itself.
> Every other session's edit meets the lock: in this tree, in another view or on another machine.

### When a lock ends

A lock ends when its agent stops at the end of a turn, or its session ends. A stop that bay refuses, to ask the agent to save its edits, keeps the lock. Each step of the session renews its locks once they are half the takeover window old. A lock that no step renews within the window goes to the next session that edits its path. The takeover window is `[runner] takeover_s` in `.bay/config.toml`: twice `lease_s`, so 120 seconds by default. A lock that names no session, or one bay cannot read, goes to the next edit too.

### When the remote does not answer

Without an answer from the remote, bay cannot tell whether another session holds the lock. So bay refuses an edit of a lockable path with `REMOTE_UNREACHABLE`, and lets other edits through. Edit another path, or try again once the remote answers.

### What a lock cannot do

- Without bay's hooks, nothing takes a lock or meets one.
- Claude Code refuses the edit. Codex refuses it too, but runs it when bay's hook fails or hangs.
- Only an edit tool that names its file takes a lock: `Edit` or `Write` in Claude Code, `apply_patch` in Codex.
- A shell command's write takes no lock and meets none, so a script can still overwrite a locked file.
- A merge does not check locks: a change to a lockable path merges after its checks, like any change.

## What the hooks tell you and save

- When a merge of yours is refused, or its check fails or is unknown, the hook tells you once.
- The hooks fetch talks at most every 30 seconds per clone, and name each new one once.
- Before a session stops with unsaved edits in its view, the hook asks it once to save them.
- In a view, the hook saves a draft checkpoint when the session ends.
- It also saves one at the first hook event `[hooks] checkpoint_idle_s` after the last checkpoint, 600 seconds by default.
- So a session killed without a stop loses at most one interval of edits.

> The instruction files are `AGENTS.md` and `CLAUDE.md` unless `[agent] instructions` in `.bay/config.toml` names others.
> That notice comes once per session for each main commit.
> While your change waits in the queue, the hook names each early merge conflict once, with its paths.
> When another change merges on paths your change declared, the hook names it once. Refresh, then re-read those paths.
> An overlap or an early conflict refuses nothing. It says what the work may meet.

## Blocked shell commands

The hook refuses these command shapes, also behind `env`, `xargs`, `sudo`, `command` and `exec`. It reads the script of `bash -c`, `sh -c`, `zsh -c`, `dash -c` and `ksh -c`, two shells deep. It refuses a deeper script that mentions Git. It matches shapes and never runs the shell, so another spelling of the same command can get past it. A normal push of a `refs/bay/*` ref stays allowed.

```text
git reset --hard
git clean -f
git clean -fd
git branch -D
git checkout .
git restore .
git push -f, or a cluster of short flags that holds f, such as -fu or -vf
git push --force, git push --force-with-lease, git push --force-if-includes
git push +<ref>
git push --all, git push --mirror
git push of refs/heads/<main>
git push of a [[target]] ref, such as refs/heads/staging
git push of anything but refs/bay/*, when bay cannot read main
```

> A refused command is `SHELL_DENIED` in the session's record.
> The agent reads that code, why bay denied it, and the next step on stderr.
> The record names the shape and its wrapper, never the command text.
> With `[[lane]]` set, the hook also refuses copying a lane ref's work into a view of no lane.
> Those shapes are `git merge`, `git cherry-pick`, `git checkout <lane ref> --` and `git restore --source <lane ref>`.
> With `[[target]]` set, the hook also refuses a push of a target's ref, in any refspec form.

## When the hook cannot judge

`bay install` gives the `tool.before` hook a 30-second timeout. In Claude Code, a hung hook then denies the call. bay denies a `tool.before` call whose payload it cannot read, with `HOOK_INPUT_INVALID`. Then `bay doctor` checks the hook entries, and `bay update` installs a newer bay. The Claude Code hook command exits `0` on a machine without bay, so a committed settings file blocks nothing there. Where bay is installed, its deny still blocks. The deny also holds when Claude Code runs with `bypassPermissions`.

> bay also denies a tool call that reaches `bay hook` under a moment name it does not know.
> It also denies a `tool.before` call under another moment's name, such as `step.end`.
> Both get `HOOK_INPUT_INVALID` with `detail.misnamed`, and the fix is `bay install`, which rewrites the entries.
> With Claude Code, `bay doctor` lists such entries under `old_names` in its `hooks` row.

## Name each agent

bay names the agent from `BAY_ACTOR`, else from your user name, else `agent`. `actor_source` in results and session rows says which: `env`, `user-default` or `default`. Without `BAY_ACTOR`, every session on one account gets the same name. To require a name, set `require_actor = true` in the `[agent]` table of `.bay/policy.toml`. Then `bay start` refuses `ACTOR_UNNAMED` until `BAY_ACTOR` is set. With a key registry that signs everything, set `BAY_ACTOR` to the agent's enrolled name.

```toml
# .bay/policy.toml
[agent]
require_actor = true
```

> Two live sessions with one name cannot share a change.
> A second `bay start` of that intent refuses `CHANGE_HELD`.
> Retry when the first session ends, or its heartbeat is one takeover window old.

### Tell agents on one account apart

Agents on one OS account can all read one file agent key, so its signature cannot tell them apart. Set `require_actor_proof = true` in the `[agent]` table of `.bay/policy.toml` to refuse that. Then `bay merge` refuses `ACTOR_UNPROVEN` while several agents share an agent key on this machine. bay also refuses a machine where it cannot judge the key, such as Windows with a file key. The fix is a hardware agent key, or one OS account for each agent. It is off by default. The `key-custody` row of `bay doctor` shows it, and whether bay refuses this machine.

```toml
# .bay/policy.toml
[agent]
require_actor_proof = true
```

### Limit each agent's live changes

Set `max_live_changes` in the `[agent]` table of `.bay/policy.toml` to cap the live changes of one agent. Then `bay start` refuses `WIP_LIMIT` while the agent already holds that many. A change counts until it merges or ends, such as by `bay abandon`. A lane change counts too. `detail.live_changes` names each one. Merge or abandon one, then start again. bay counts the change records this clone holds, and reads the limit from main. Adopting your own change adds none. Without the key, bay sets no limit.

```toml
# .bay/policy.toml
[agent]
max_live_changes = 3
```

## What a session records

Hook events add rows to the session's work record on this machine. A refused tool call shows as `blocked(<code>)`, with an Agent Hooks `deny` verdict. Codes include `SHELL_DENIED`, `PATH_HELD` and `HOOK_INPUT_INVALID`. Each row names the session's `permission_mode` and `actor_source`. `bay status --fleet` shows both for every session.

## Hooks are an extra entry point

bay's own merges work the same without hooks, so a stale write still cannot merge. Without hooks, an agent's own `git push` can reach main around bay, unless main is protected. Security and privacy says what bay guards, and when. A session with hooks off shows as untracked in `bay status --fleet`. `bay doctor` is red with `HOOKS_MISSING` when `.claude/settings.local.json` or your user settings sets `disableAllHooks` or `allowManagedHooksOnly`. Another tool's hooks for bay's events run beside bay's, so doctor lists them but never turns red for them.

## Not built yet

- Hooks for Cursor, Grok Build, Antigravity and Gemini CLI.
- `bay doctor` naming each product's hook support and the interfaces it lacks.
- Worktree ownership through the agent's own worktree events.

---

# Teach your agent bay

> Install the bay skill, so any coding agent knows how to merge changes with bay.

Source: https://getbay.io/docs/skill (HTML).

The bay skill is one `SKILL.md` file that teaches a coding agent bay. It names the command for each step, and tells the agent to act on the result bay prints.

The skill holds instructions only. The bay program installs on its own, with the install script. The skill grants no key and cannot move main.

## When to use the skill

- **Agents share a repository that uses bay to merge**: each agent learns the same loop and the same rules.
- **A new machine or a new agent**: one command gives it bay's workflow before its first change.
- **An agent without a skill system**: paste the skill, or the `AGENTS.md` lines on Agents and hooks, into its instructions.
- **Set bay up for a team**: the agent does each step for you, from the install to each approver's key.

## Install it with the skills CLI

The skills CLI installs a skill from its GitHub repository for many agent products. `-g` installs it for every project. Leave it out to install it in this project only.

```sh
npx skills add gudjon/bay --skill bay -g
```

> The skills CLI copies the skill from bay's main branch, which can differ from your bay version.
> Then `bay doctor` is red with `SKILL_MISMATCH` until `bay install` writes the matching copy.

## Install it with bay

The install script runs `bay install`, which copies the skill that matches the installed bay version. It writes the skill into every Claude config directory it finds. `bay update` does the same. Install lists where it looks.

```sh
curl -fsSL https://getbay.io/install.sh | sh
```

> Name a config directory bay cannot find with `git config --global --add bay.claudeConfigDirs <dir>`.

## What the agent can do

- Open a change with `bay start`, or take in an outside branch with `--from`, and edit only inside its view.
- Pick a change up again with `bay start <change>`. After a merge, `bay start <intent>` opens the next change.
- Fix a merged change with `bay start --fixes`, or wait for other changes to merge first with `--after`.
- Submit with `bay merge`, wait with `bay merge --wait`, or give up with `bay abandon`.
- Read state with `bay status`, see every session with `bay status --fleet`, and reverse a merge with `bay undo`.
- Read what changed with `bay status --since`, and ask or answer another agent with `bay talk`.

> The intent says what the work is, in a few words, or gives a card id.
> The agent passes `--json` and reads stdout as one `bay.result/1` document.
> Unknown is not success.

## People who are not developers

The skill also tells an agent how to work with a person who has never used Git. It names four plain words for such a person: save, waiting, approve and undo. It also names the Git words such a person never hears. Results and refusals lists them. Its section "When the person is not a developer" holds the rules; the Source below links it.

## How the two copies fit

1. **Both write one folder** The skills CLI with `-g` for Claude Code and `bay install` use the same skill folder.

2. **Doctor compares** `bay doctor` compares the copy in each Claude config directory with the skill of the installed bay version.

3. **Install restores** A different copy is red with `SKILL_MISMATCH`. `bay install` writes the matching one back.

## What the skill tells an agent never to do

- Push, merge into or move main by hand, or push a target such as `staging`.
- Run `git reset --hard`, `git clean -f`, `git branch -D`, `git checkout .` or `git restore .`.
- Force-push, or push with `--all` or `--mirror`.
- Grant a key, widen the policy, or quarantine a check to get a change through.
- Run `bay install` to merge work.
- Report a change as merged before the result says `merged`.

> With agent hooks on, bay's hook refuses those Git commands, also inside `bash -c`.

## Source

The skill lives in the bay repository at `skills/bay/SKILL.md`, the one copy for every agent and organisation. The copy for this release: https://github.com/gudjon/bay/blob/v0.3.7/skills/bay/SKILL.md.

---

# See what your fleet is doing

> See every machine, agent and session on a repository, and read what changed since you last looked.

Source: https://getbay.io/docs/fleet (HTML).

`bay status --fleet` shows what each agent on each machine works on. `bay status --since` lists what changed since you last looked: merges, events and invalidations. Both read Git, so you need no server and no SSH between machines.

The fleet map shows your last fetch, not a live feed. It never contacts the remote itself, and it says how old its fetch is. A notice is a convenience. The runner's log and main stay the record.

## When to use the fleet map

- **Before you start**: see who holds a change, and on which machine.
- **Find a stalled session**: see each session's state and the age of its last report.
- **Catch up after a break**: run `bay status --since` with your last cursor and read what merged.
- **Spot a red main**: see which post checks are red, and who works on their paths.
- **Feed a chat or a dashboard**: point the notify plugin at it, and fill gaps with `bay status --since`.
- **Follow open talks**: see every open talk, and how many each session must answer.

## Show the fleet map

Run `bay status` first. It fetches main, then copies each machine's live ref into `refs/bay/fleet/*`. Then run `bay status --fleet`. It reads that copy and fetches nothing.

```sh
bay status           # fetch main and the live refs
bay status --fleet   # read the map from that fetch
```

## Where the map comes from

1. **Record** Each agent hook event writes the session's state to a local work record.

2. **Summarise** A hook event starts a background `bay live push`. It pushes at most once per heartbeat. It replaces `refs/bay/live/<machine>` with one commit that holds `live.json`, one row per live session. With a key registry, the agent key of that machine signs the commit. `bay doctor` also writes and pushes this summary, with its verdict.

3. **Fetch** `bay status` fetches every `refs/bay/live/*` into `refs/bay/fleet/*`. It records when that fetch first brought each summary, by this machine's clock.

4. **Read** `bay status --fleet` joins those summaries with this machine's own records. With a key registry, a summary that its named agent did not sign lists no sessions.

## What the map shows

| Field | Meaning |
|---|---|
| `machines` | Each machine in the fetch, with its age, its number of fresh sessions and its last doctor verdict. An unreadable summary shows `readable: false` and why. |
| `machines[].doctor` | The machine's last `bay doctor` verdict: `at`, `bay_version`, `decision`, `reason`, `red` (the red rows' names), `hooks`, `sandbox`, `role` and `age_s`. `null` when it never ran doctor. |
| `ready` | The fleet's count: `machines`, `green`, `red`, `never_ran_doctor`, `unreadable`, and `lowest_bay_version`, the lowest bay a verdict names. |
| `actors` | Each actor the cache knows, the machines it is on, and its freshest age. The senders and recipients of open talks count too. An actor known only as the recipient of an unacked talk has `age_s` `null`. |
| `sessions` | One entry per fresh session: machine, actor, agent, change, state, last event, context use, last write, age and `permission_mode`. |
| `sessions[].behind` | Whether main moved past the base of that session's change. `null` when this cache does not hold the change. |
| `sessions[].behind_commits` | How many main commits the change's base is behind main, in this cache. `null` when `behind` is. |
| `sessions[].session_age_s` | How long ago the session started, without comparing two machines' clocks. `null` when the row does not say. |
| `sessions[].actor_source` | Where the agent's name came from: `env` for `BAY_ACTOR`, else `user-default` or `default`. |
| `default_actor_sessions` | How many fresh sessions took their agent's name from a default, not `BAY_ACTOR`. |
| `behind` | Whether the fetch is behind the remote: `true` once the fetch is one takeover window old, else `null`. Never `false`. |
| `fetched_at`, `fetch_age_s` | When the fetch ran, and its age in seconds. |
| `machines_truncated` | `true` when the map left machines out. |
| `red` | The post checks main is red on, as `bay status` lists them, from the evidence in this cache. An object with `unread` and the reason when bay cannot read what it needs to judge them. |
| `sessions[].on_red` | The red checks whose paths cover the change's declared paths or the files its session last wrote. A held change with no session carries it too. `[]` when none. |
| `red[].suspect` | Where a red post check could have come from: `last_passed`, `first_failed`, and the `merges` between them. `null` when bay finds no range. |
| `red[].culprit` | The one merge the evidence pins as the cause, with each merge tested on the way. `null` until the evidence pins one merge: a bisect, or a pass right below the first failure. |
| `red_pending` | Post checks that no merge in the red window answers yet, each with `state` `pending` or `pending_beyond_window`. Never read as clear. |
| `runners` | Each runner's presence from the same fetch: `machine`, `bay`, `environments`, `last_pass`, `holding`, `queue_depth`, `age_s` and `stale`. An unreadable one shows `readable: false` and why. |
| `targets` | Each promotion target, as `bay status` lists it under `detail.targets`. Promote main to other branches explains targets. |
| `unattributed` | Main commits in this cache that did not merge through bay, as `bay status` lists them. `null` when the cache holds no main. |
| `unattributed.merges[].lane_leak` | With `[[lane]]` in the config, the sandbox lane commits that a merge outside bay brought onto main. Keep experiments in sandbox lanes explains lanes. |
| `talks` | Each open talk this clone holds, oldest first, with the fields `bay status` lists for a talk. An object with `unread` and the reason when bay cannot read talks. |
| `sessions[].owes_talks`, `owed_talks` | How many open talks the session's agent must answer, and how many it sent that another agent owes. `null` when bay cannot read talks. |

## Untracked and unknown

A held change with no fresh session gets an entry of its own. Its view may be on this machine with no session record here that names it. Then its state is `untracked`. That change had no hooked session, for example one with hooks off. bay never shows it as idle. Its view may be on another machine. Then bay cannot judge it from here, and its state is `unknown`. A change whose hooked session here ended gets no entry.

## Ages and clocks

- bay never compares the clocks of two machines.
- A row's age starts with the summary's age on your clock, from the fetch that first brought it.
- bay adds the row's age inside the summary, by the clock of the machine that wrote it.
- A row one takeover window old drops out of the map.
- This machine's own sessions come from its local records, not from the remote's copy.

## See each runner

`bay status` fetches each runner's presence with the live refs, and the map lists them under `runners`. A runner is `stale` after three of its own intervals or one takeover window, whichever is longer. While it holds an operation, the progress deadline adds to that, since it writes nothing mid-operation. A stale runner is a warning, never a refusal. Runners says what a presence holds.

> A presence's age follows the clock rules above, so a wrong clock can only make it look older.
> With a key registry, a presence that its runner's key did not sign shows `readable: false`.

## See each machine's readiness

Each `bay doctor` pushes its verdict to this machine's live ref, so the fleet map needs no SSH. It pushes only in a repository set up for bay, and only when its `remote` row is green. The verdict holds names, states and versions, never a path or a secret. `hooks` says `on`, `off` or `missing`, and `sandbox` gives the state of doctor's `sandbox` row. `ready` counts a machine green when its last doctor decided `ok`, and red for any other verdict. Run `bay status` to fetch every verdict, then `bay status --fleet` to read them.

```sh
bay doctor           # on each machine: check it, and push its verdict
bay status           # on any machine: fetch every live ref
bay status --fleet   # each machine's doctor block, and ready
```

> A verdict's age follows the clock rules above.
> A failed push never changes doctor's result. The next doctor's `live` row shows it.
> A machine that never ran doctor shows `doctor` as `null`, and counts under `never_ran_doctor`.
> With a key registry, the machine's agent key signs the verdict, as it signs every live summary.

## See the flow of work

`bay status` names the flow of work under `detail.flow`. It counts the live changes, and each agent's live changes beside its limit. It counts the merges of the last 7 days, and times the merges of the last 30. It names the oldest live change, and the changes that wait for a person's approval. bay stores none of it. It reads the change records and runner logs this clone holds, and fetches nothing more.

```sh
bay status --json | jq '.detail.flow'
```

| Field | Meaning |
|---|---|
| `live` | How many changes are live: not merged, abandoned or superseded. A lane change counts. |
| `by_actor` | Each agent with a live change: `actor`, `live`, and `limit` from `[agent] max_live_changes`, or `null`. |
| `merged` | How many changes merged in the last 7 days. |
| `cycle_time_s` | `p50` and `p90` seconds from start to merge, and `merged`, how many changes merged in the last 30 days. `null` when none did. |
| `queue_wait_s` | `p50` and `p90` seconds those changes waited in the queue until a runner took them up as its head, and `merged`, how many carry a wait. `null` when none does. |
| `check_time_s` | `p50` and `p90` seconds of one check run, over the check runs of those changes, and `runs`, how many. An unchecked merge ran no check, so it adds none. `null` when none is timed. |
| `oldest_live` | The live change that started first, and its `age_s`. `null` when none is live. |
| `waiting_approval` | How many live changes wait for an approval (`count`), the `oldest`, and its `age_s` since its merge request. |

> Each time is the committer time of the commit that recorded the step, by that machine's clock.
> When bay cannot read the records, `flow` holds `unread` and the reason.

## Read what changed

`bay status --since` fetches main and the runner logs, then lists what happened after a cursor. It lists merges into main, `bay.event/1` events from the runner logs, and new invalidations of your change. It also lists a `main_red` event when a post check turns main red, and `main_red_cleared` when it clears. In a view, it lists only the red checks whose paths cover the change's declared paths. It returns `detail.cursor`. Pass it to `--since` next time to go on from there. The cursor is opaque. Use it as bay returned it.

```sh
bay status --since           # the current state of this view's change
bay status --since <cursor>  # what happened after that cursor
```

> When bay cannot read the key registry, it judges no red, and `detail.red_unread` gives the reason.
> The cursor then keeps the red checks it held, so the next `--since` judges them again.
> When bay cannot read talks, `detail.talks_unread` gives the reason, instead of an empty list.

## What it lists

- In a change's view, it lists only events that touch that change's inputs, and your talks.
- Those are events about the change itself, about the policy, or about an input it declared.
- Outside a view, it lists every event and judges no change.
- Without a cursor, a view gets its state first: merges since its base, its events and its invalidations.
- Offline, it reads the local cache, and `freshness` says so.
- `bay status --since` writes no change record and sends no notice. Like `bay status`, it accepts and acks your talks.

### Talks since a cursor

`bay status --since` also follows each open talk you sent or must answer, through its cursor. It lists `talk_arrived` the first time it sees an open talk to you. It lists `talk_answered` when a talk gets a new reply, such as an answer. It lists `talk_lost` or `talk_stale` when a talk turns lost or stale. It lists `talk_closed` once when a talk closes, with `state` set to `answered`, `superseded` or `expired`. Each event carries the talk's row from `bay status`, and `talk_kind` names the talk's kind.

> A talk that opens and closes between two cursors is in neither answer.
> `bay status --since --merged` lists no talk events.

### Targets and lanes since a cursor

Outside a view, `bay status --since` lists `target_moved` when a promotion target moves to a commit its rule allows. It lists `target_unpromotable` when a target moves to a commit that fails its rule. Without a cursor, it lists `target_unpromotable` for each target whose tip fails now. An `outside_merge` event also names `lane` and `lane_leak` for lane commits that a merge outside bay brought. `detail.red_pending` lists the post checks that no merge in the window answers yet. Promote main to other branches and Keep experiments in sandbox lanes explain both.

## Find what turned main red

A red post check names its `suspect` range: the last pass, the first failure, and the merges between. With `[runner] bisect_post = true`, a runner narrows that range to one `culprit` merge. Configuration explains the range, the bisect and the red window, under "When a post check fails".

```toml
[red]
window = 200          # main commits a red judgement reads; 50 by default

[runner]
bisect_post = true    # narrow a red post check to one culprit merge
```

## Read merges only

`bay status --since --merged` lists only the merges after a cursor, with no events and no invalidations. Each merge names its base, its agent and its commit time. A commit that merged outside bay stays in the list with `outside_bay: true`, and `merged_as` says `merge` or `outside`. `off_first_parent` counts the commits reached only through a merge, and the list leaves them out. One exception: the list holds a bay merge that a merge outside bay put off that line, with `via`.

```sh
bay status --since <cursor> --merged
```

## Merges outside bay

People can merge a pull request or push to main without bay. `bay status` names each such main commit under `detail.unattributed`, and the fleet map under `unattributed`. It reads the newest `[red] window` first-parent main commits, 50 by default, after the commit that added `.bay/config.toml`. A commit is a bay merge when it carries one `Change-Id` and, with a key registry, verifies as one. Outside a view, `bay status --since` also lists an `outside_merge` event for each such merge it lists. A bay merge that such a merge put off the first-parent line still counts as a bay merge.

| Field | Meaning |
|---|---|
| `window` | The most first-parent main commits bay reads: `[red] window`, 50 by default. A young main may hold fewer. |
| `count` | How many commits in the window did not merge through bay. |
| `by_writer` | The same commits counted per committer, as Git records it (`Name <email>`), the most first. |
| `through_bay` | How many commits in the window merged through bay. `bay doctor` reads both counts. |
| `merges[]` | Each such commit, newest first, with its `commit`, `base`, `at` and `subject`. |
| `merges[].merged_as` | `merge` for a commit with more than one parent, else `outside`. |
| `merges[].merged`, `merged_count` | Up to 20 commits that a merge brought in, and how many there are. |
| `off_first_parent` | The sum of `merged_count` over every merge listed. |
| `merges[].merges_off_first_parent` | How many bay merges this merge put off the first-parent line. `landings_off_first_parent_capped` says bay stopped reading at 500 commits. |
| `merges_off_first_parent` | The sum of `merges[].merges_off_first_parent` over every merge listed. |
| `merges[].lane`, `merges[].lane_leak`, `lane_leaks` | With `[[lane]]` in the config: the first lane a merge brought commits from, those commits, and how many merges name a lane. |
| `unread` | Present instead when bay cannot read main, with the reason. |

> On a main branch from `bay host init` with a key registry, the host refuses such a push.
> On a forge, bay names these commits but cannot stop them.
> `bay status` also names `detail.guard.hook`: `on` when the config on main names an agent product, else `off`.
> Security and privacy says what bay guards, and when.

### When a merge outside bay hides bay merges

On a forge, someone can push a merge whose first parent is an older main commit. Then bay's merges sit behind that merge's other parent, off main's first-parent line. bay still finds them by ancestry, and places each one at the merge that brought it in. Red judgements, culprits and promotion targets find them there; `bay undo` finds such a merge by ancestry. `bay status` warns `main_not_linear` under `detail.warnings` for each such merge in the `[red] window`. `bay status --since` names each new such merge once, never with `--merged`; in a view, only when it touches the change.

> Each warning names the merge, its `parents`, how many bay merges it put off the line, and a hint.
> Require linear history for main on your host, so only fast-forward pushes reach it.
> bay reads at most 500 commits behind one such merge, and `capped` says when it stopped.

## Find stranded work

`bay status --heads` lists work that exists only on this disk under `detail.stranded`. It names each local branch with commits that no remote-tracking ref holds. It also names each worktree record whose folder is gone. It reads local refs for that part, and never prunes or deletes.

```sh
bay status --heads --json | jq '.detail.stranded'
```

## Clean up merged branches

`bay status --heads` lists every branch on the remote, each with one class. The census costs one `ls-remote` and one fetch, and deletes nothing. `bay prune` lists the archive refs, then archives and deletes only branches shown on main by `ancestor` or `patch`. Before each delete, it archives the tip under `refs/bay/archive/heads/` on the remote, and logs the push that restores it. It deletes under a lease on the tip it judged, so a branch that moved since stays. A branch that only carries a merged change's `Change-Id` stays, because it may hold other work.

```sh
bay status --heads                   # every branch on the remote, with its class; deletes nothing
bay prune                            # delete the merged ones, each archived first
bay status --heads --stale-days 14   # stale after 14 days, not 30
```

| Class | Meaning |
|---|---|
| `main` | Main itself. Never deleted. |
| `target` | The ref of a `[[target]]`. Never deleted. |
| `lane` | A ref that a `[[lane]]` pattern matches. Never deleted. |
| `merged` | Everything it holds is on main. `proof` says how: `ancestor`, `patch`, or `change` (a merged `Change-Id`). `bay prune` deletes it, unless the proof is `change`. |
| `stale` | It holds commits main lacks, and its tip commit is older than `--stale-days`, 30 by default. Never deleted. |
| `unique` | It holds commits main lacks. Never deleted. |

> `detail.pruned` names each deleted branch with its `restore` command, and `detail.skipped` says why others stayed.
> The restore lines also go to `bay/pruned-heads.log` in the repository's common Git directory.
> With a key registry, a `bay host init` host takes an archive ref once, and refuses to move or delete it.

### Run a step after each prune

A deleted branch can leave work elsewhere, such as an open pull request on your host. `[plugin.prune] command` in main's config names a program that bay runs after each branch `bay prune` deletes. bay runs it as `<command> pruned <branch> <sha> <archive ref>`, from main's tip, never from your work tree. It gets only `PATH` and `HOME`, and 30 seconds by default, which `timeout_s` changes. Each `detail.pruned` row records `on_prune`: `ok`, and `why` when the plugin failed or did not run. A failing plugin never undoes the delete, and bay prunes the next branch as usual.

```toml
# .bay/config.toml
[plugin.prune]
command = "scripts/bay-on-prune"   # runs as: scripts/bay-on-prune pruned <branch> <sha> <archive ref>
timeout_s = 30                     # 1 to 600 seconds; 30 by default
```

> bay itself never closes a pull request or calls your host. The plugin is where you do that.
> It runs on the machine that ran `bay prune`, with no sandbox.

## Get notices

Set `[plugin.notify] command` in main's `.bay/config.toml` to a program in the repository. The runner runs it once after each event it writes, with the event's JSON on stdin. Exit `0` means delivered. Any other exit, or no exit within `timeout_s`, 5 seconds by default, means lost. bay never retries a lost notice. `bay status --since` still lists the event. Each event carries an id, so the plugin can drop duplicates. The plugin gets only `PATH` and `HOME` from the runner's environment.

```toml
[plugin.notify]
command = "scripts/notify"   # gets one bay.event/1 on stdin, exits 0 when delivered
```

> Test the plugin with `bay plugin test notify -- scripts/notify` before you merge it.

## What the map cannot do

- It cannot ask the remote. It is as fresh as your last `bay status`.
- It cannot see inside a session with hooks off. It shows that session's change as `untracked`.
- It reads at most 256 machine summaries.

## Refusals

`bay status --fleet` fails closed with `REMOTE_NOT_FETCHED` until `bay status` has fetched the live refs. A fetch from another remote URL does not count. The result is `blocked`, exits `10`, and lists no machines, actors or sessions.

| Code | Meaning |
|---|---|
| `REMOTE_NOT_FETCHED` | The local cache has no recorded fetch of the configured remote's live refs, so it is not the fleet. |

> A cursor that bay did not return is a usage error.
> When this repository lacks the cursor's main commit, `bay status --since` fails. Run it again without `--since`.

## Not built yet

bay has the map, talks in it and in `bay status --since`, and the notify plugin. It does not have this part yet:

- Waking an idle session when another change supersedes its change, or its inputs move.

## Limits

- **Machines**: the map reads 256 summaries at most. With a registry, a `bay host init` host holds at most 256 live refs.
- **Summary**: `live.json` is at most 32,768 bytes and keeps the newest 64 rows.
- **Push rate**: one summary push and one runner presence per heartbeat, 10 seconds by default.
- **Freshness**: a row drops out after one takeover window, 120 seconds by default.
- **Since a cursor**: one answer lists at most 500 merges and 500 events. `more` says when more follow.
- **Notices**: the notify plugin has 5 seconds per event by default, and hook notices 2,048 bytes a session an hour.

---

# Talk to other agents

> Ask an agent on another account, machine or agent product for one change, decision or check.

Source: https://getbay.io/docs/talk (HTML).

`bay talk` lets one agent ask another for one thing. The other agent can run under a different user account, on a different machine, or in a different agent product. A Claude Code agent can ask a Codex agent, and a Grok Build agent can answer it. Most agent messaging works only inside one account or one team plan. A talk travels through the Git remote you already share, signed by each agent's own key.

A talk is a signed record in Git, not a chat. It carries one ask, one target and one follow-up time. The recipient's own key acks it, so you know it arrived. Any agent that can run a shell command can send, accept and answer a talk.

## When to use talk

- **Ask across accounts and products**: the agent you need runs under another account, or in another agent product.
- **Ask for a change**: your work needs a change or a file that another agent owns.
- **Ask for a decision**: you need someone to decide one question. A person with an agent key can answer too.
- **Ask for a check**: you need a named check to pass before you rely on it.
- **Hand over across machines**: the other agent works on a different machine, behind its own firewall.

## Send a talk

Name the agent, say what you need, pick a kind, and name exactly one target. Every talk also states when the reply is owed, as a UTC time such as `2026-10-20T09:00:00Z`. A talk needs the repository's key registry. Without one, or under `[policy] signing = "approvals"`, bay refuses it with `TALK_REFUSED`.

```sh
bay talk api-agent "Add a tenant_id field to the orders API" \
  --kind delivery --change c7k2m4qa --follow-up 2026-10-20T09:00:00Z
```

## Ask whoever merged a change

`--to-change` sends the talk to the agent that merged a change. Name the change id or its main commit, and give only the action as the argument. bay reads that agent from its own merge record, never from a commit trailer. For a main commit that bay did not merge, bay refuses with `OUTSIDE_MERGE`. For a change or commit not on main, bay refuses with `CHANGE_NOT_MERGED`.

```sh
bay talk --to-change c7k2m4qa "Add a test for tenant_id" \
  --kind delivery --change c7k2m4qa --follow-up 2026-10-21T09:00:00Z
```

## Kinds and targets

Each kind takes exactly one target field. A second target, or a list in one field, is a bundled ask, and bay refuses it.

| Kind | Asks for | Target |
|---|---|---|
| `delivery` | One change or one file | `--change <change>` or `--artifact <path>` |
| `discovery` | One decision or one answer | `--decision <decision>` |
| `reliability` | One named check | `--check <check>` |

## Optional fields

| Flag | What it adds |
|---|---|
| `--decides <agent>` | Who decides when the two agents disagree. A person with an agent key can decide too. |
| `--context <text>` | Your reading of the facts. It stays apart from the evidence. |
| `--evidence <commit\|path\|change>` | A pointer to evidence that is already in Git. |
| `--target`, `--forecast` or `--commitment` | The result you intend, the result you expect, or the result promised. Use at most one. |
| `--owns`, `--promises`, `--needs`, `--promise-evidence` | What each side owns, promises and needs, on the same talk. |
| `--supersedes <id>` | Replace an open talk that you sent or that names you in `--decides`. |
| `--expires <time>` | The time after which the ask is no longer useful, no later than `--follow-up`. A talk acked after it closes as `expired`. |
| `--urgent` | Mark the talk as urgent. |
| `--dry-run` | Check the talk and print it, without sending it. |

## Answer a talk

Answer a discovery talk with one value. Only the recipient, or the agent named in `--decides`, can answer. To change your answer, pass the standing answer's commit in `--supersedes`. The same value again changes nothing, and `state` says `already_answered`.

```sh
bay talk --answer k3q7m2xv5tabe4wd --value "use tenant_id"
```

## How a talk arrives

1. **Send** `bay talk` commits the talk and pushes all its refs to the shared remote, or none. A push is not delivery. When the push fails, the result's `state` is `not_pushed`. Run the same `bay talk` again to push it.

2. **Accept** `bay status` or `bay status --since` on the recipient's machine accepts the talk after it fetches. With Claude Code hooks, a live session also fetches talks on a prompt or after a tool. Every session on one clone shares one limit: at most one fetch every 30 seconds. Accept checks both agents' keys, the required fields, and the sender's signature. `detail.talk_accept` lists the talks it `acked`, `closed` and `refused`.

3. **Ack** The recipient's bay writes an ack with the recipient's own key. An ack means the talk arrived. It does not mean the work is understood or done. With a `[plugin.wake] command`, bay asks the wake plugin once to wake the session, and records its answer on the ack.

4. **Notice** With Claude Code hooks, the recipient's session hears of the acked talk once, at its next step. The notice names only the talk's id and kind, never its text. A talk acked after its `expires` is named with `EXPIRED` first, and owes no reply.

5. **See the ack** You see the ack, and the wake plugin's answer, after your next fetch. Until then, the talk is not on the recipient yet.

## How a talk closes

- A talk closes when its sender, or its decider, supersedes it.
- A discovery talk also closes when it is answered.
- A delivery talk about a change closes as `answered` when a fix, undo or supersede of that change merges.
- A talk acked after its `expires` closes as `expired`, and owes no reply.
- The recipient's bay writes each close at its next `bay status`, `bay status --since` or hook fetch.
- An ack alone never closes a talk.

## See your open talks

`bay status` lists each open talk you sent or must answer, oldest first, under `detail.talks`. Its fields are `talk`, `kind`, `from`, `who`, `owes`, `what`, `change`, `sent`, `age_s`, `ack_age_s`, `reply_age_s`, `follow_up`, `wake`, `expires` and `standing`. A lost, stale or expired talk stays listed until it closes. When bay cannot read talks, `detail.talks` is an object with `unread` and the reason. `change` names the change a delivery talk is about, else `null`. `bay status --fleet` lists every open talk this clone holds, and `bay status --since` lists talk events.

```sh
bay status --json | jq '.detail.talks'
```

| Standing | Meaning |
|---|---|
| `not_on_recipient` | No ack yet, within 24 hours of `sent` and before `expires`. |
| `lost` | No ack within 24 hours of `sent`, and `expires` has not passed. |
| `arrived` | Acked, with no backbrief yet, before `follow_up`. |
| `reply_owed` | A backbrief came, and the talk is still open, before `follow_up`. |
| `stale` | Acked, and the `follow_up` it owes has passed. |
| `expired` | Acked after `expires`, judged by the ack's commit time. Or not acked, and `expires` has passed by the reader's clock. `owes` is `null`, and the talk is never `lost` or `stale`. |

## See talks on a change you merge

`bay merge` and `bay undo` list open talks to you about that change under `detail.open_talks`. Each entry names `talk`, `kind`, `from`, `what`, `sent`, `follow_up`, and `read`, true once it has an ack. Only a delivery talk whose `--change` names the change counts, and only with a key registry. Both commands fetch your talk refs first but write no ack, so an unread talk stays unread. A talk never blocks a merge or an undo, and with no such talk the field is absent.

## Wake the recipient

Set `[plugin.wake] command` in main's `.bay/config.toml` to name a wake plugin. When the recipient's machine accepts a talk, bay runs it once, with a `bay.wake/1` request on stdin. It answers `bay.wake-result/1` on stdout, with `result` `woken`, `skipped` or `uncertain`, and an optional `reason` code. bay records that answer on the ack as `wake`, so the sender sees it after a fetch. A non-zero exit, a bad answer, or no answer within `timeout_s`, 5 seconds by default, is `uncertain` with `WAKE_ADAPTER_FAILED`. bay never calls it again for a talk it has acked.

```toml
[plugin.wake]
command = "scripts/bay-wake"
```

> The request names `cause` (`talk`), `talk`, the recipient's `actor`, `machine`, `urgent`, and `line`: the first 200 characters of `what`.
> bay splits the command on spaces and runs it from the work tree's root, with no shell.
> The plugin gets only `PATH` and `HOME` from bay's environment.
> Without `[plugin.wake] command`, the ack records `not_attempted` with `NO_WAKE_ADAPTER`.
> If writing the ack fails, the next accept asks the plugin again.
> Test the plugin with `bay plugin test wake -- scripts/bay-wake` before you merge it.

### Wake the author of a change

The same `[plugin.wake] command` also wakes a change's author once, for an outcome they must act on. A runner asks it when it merges the change, or blocks it on a step that is the author's. A block that bay retries on its own wakes no one. An outcome that your own `bay merge --wait` returns wakes nothing: your session already has it. The request says `cause` `outcome`, and names the `change`, `op_id`, `decision` and `reason`. It also carries the `actor`, `machine`, `urgent` and `line` fields of a talk's request.

> bay records the answer as `wake` on the outcome's event in `bay status --since`. A merge gets a `wake` event.
> bay never asks twice for one outcome, also when writing the event fails.
> An approval wait wakes the author, not the approver: a wake goes to a session.
> `bay plugin test wake` also sends an outcome request, as its `outcome` case.

## Wait for a talk while idle

`bay hook talk-wait` waits for a talk while a session is idle. Run it from an asynchronous hook of your agent product. `bay install` does not add it. It fetches talks every 30 seconds, and doubles the wait after each 10 empty fetches, up to 300 seconds. It exits `2` when it accepts a talk, and names that talk on stderr. It exits `0` after an hour with no talk.

```sh
bay hook talk-wait
```

## What a talk cannot do

- It cannot change keys or policy. A talk is data only.
- It cannot run a command on the other machine.
- Only an agent's own key can send a talk, with one exception.
- A reviewer key can send a `delivery` talk about a change whose merge it holds a `review` record on.
- A runner or a monitor cannot send a talk.

## Refusals

Each refusal is `blocked` and exits `10`. bay refuses a talk it will not send with `TALK_REFUSED`, and `detail.why` says why. The talk may be invalid or closed, reuse an id, supersede without the right, or answer twice. Or its sender is no agent, or holds too many open talks. With `--to-change`, it can refuse `OUTSIDE_MERGE` or `CHANGE_NOT_MERGED`. Results and refusals explains each code and what to do next.

## Not built yet

Sending, answering, accepting, acking, waking, and talks in `bay status`, `bay status --since` and the fleet map are built. These parts are not built yet:

- A backbrief: the recipient's reply with the outcome heard, the first step, and what is unclear.
- Closing an artifact or check talk when the evidence it asked for is in Git.
- `--urgent` reaches the wake plugin, but bay itself does not act on it.

## Limits

- **One ask per talk**: one action, one target and one agent who owes the reply.
- **Size**: the body is at most 8,192 bytes.
- **Open talks**: one sender holds at most 20 open talks to one recipient, and a reviewer one per change.
- **Clock**: a `bay host init` host refuses a `sent` over 300 seconds ahead of its clock. Standings use your clock.
- **Retries**: sending the same talk again is a no-op. A different body under the same id is refused.
- **Entries**: a `bay host init` host takes at most 64 entries on one talk, its ack and answers included.

> A talk with no ack within 24 hours of `sent` is `lost`.
> The hooks on one clone fetch talks at most once every 30 seconds.
> The wake plugin has 5 seconds by default.

---

# Keep experiments in sandbox lanes

> Let agents try work on lane branches that run checks against main but never merge into it.

Source: https://getbay.io/docs/lanes (HTML).

A lane is a set of refs, such as `refs/heads/research/*`, for work that must never reach main. A change you open in a lane runs its checks against main like any change. Then bay hands back the verdicts, and never merges it. Agents can try ideas and see the check results, and main stays untouched.

bay also keeps lane work out of other changes. It refuses a change whose history reaches lane commits. The agent hook denies Git commands that copy from a lane ref. bay cannot see bytes copied by hand, such as an editor paste.

## When to use lanes

- **Research spikes**: an agent tries an approach and learns whether the checks pass.
- **Prototypes**: work you plan to rewrite before it reaches main.
- **Risky experiments**: work you want checked against today's main, but never merged.
- **Keep it apart**: bay refuses to merge another change that carries lane commits.

## Declare a lane

Add one `[[lane]]` per lane to `.bay/config.toml` on main. `name` is 1 to 64 characters of `a-z`, `0-9` and `-`. `refs` lists full ref names under `refs/`, each with at most one `*`. A `*` matches any run of characters, slashes too. A pattern never matches main, and never lies under `refs/bay/`. bay also reads a branch pattern under its remote-tracking name, such as `refs/remotes/origin/research/*`.

```toml
[[lane]]
name = "research"
refs = ["refs/heads/research/*"]
window_s = 3600                    # keep an ended lane change's view for one hour
```

## Start a lane change

`bay start --lane <name>` opens a change in that lane. The lane must be one that main's `.bay/config.toml` declares, or bay creates nothing. The result names the lane in `detail.lane`, and `bay status` shows it on the change.

```sh
bay start CARD-17 --lane research --paths src/search/
```

## Run its checks

Run `bay merge` as for any change. bay builds the candidate on the current main and runs its checks. A lane change takes no place in the queue, and a red main does not hold it. Then the result is `ok`, with each check's verdict in `detail.checks`, and nothing merges. `detail.check_refusal` names the code the checks gave, such as `CHECK_FAILED`, else `null`. A conflict, or a check that no worker could run, comes back as its own refusal instead. `bay merge` again with nothing changed runs nothing, and says `ok` with `detail.unchanged`.

```sh
bay merge --wait --json | jq '.detail.checks'
```

## Keep lane work out of other changes

At `bay merge`, bay fetches the lane refs and reads the history of a change outside any lane. It reads the frozen draft, the view's `HEAD`, and any merge or cherry-pick under way there. When that history reaches lane commits that are not on main, bay refuses with `LANE_LEAK`. `detail.leaks` names each lane ref and up to 20 of its commits. To repair, reset the view to the change's base and redo the work, or start it in the lane.

## Hook denials

In the view of a change outside any lane, the agent hook denies Git commands that copy lane work. Each denial is `SHELL_DENIED`, and names the shape it matched. The hook reads a lane ref as a full name, or as Git completes a short one. It also reads commands inside a shell wrapper such as `bash -c`.

| Denied | Example |
|---|---|
| `git merge` of a lane ref | `git merge origin/research/x` |
| `git cherry-pick` of a lane ref | `git cherry-pick main..research/x` |
| `git checkout <lane ref> -- <paths>` | `git checkout research/x -- src/a.rs` |
| `git restore --source <lane ref>` | `git restore --source research/x src/a.rs` |

> A plain `git checkout` of a lane branch runs, but it moves the view's `HEAD`, so `bay merge` refuses.
> The hook does not judge `git show <ref>:<path>`, a patch, or an editor paste.

## Lane work merged outside bay

A forge merge can still bring lane commits onto main. `bay status` names that merge under `detail.unattributed`, with `lane` and `lane_leak` on its row. `lane_leaks` counts the merges that name a lane, and the fleet map lists the same. Outside a view, `bay status --since` adds `lane` and `lane_leak` to that merge's `outside_merge` event. bay names only merge commits this way. A fast-forward or a cherry-pick shows as a plain `outside` row.

## When a lane view goes

A lane change ends once its checks ran and `bay merge` returned `ok`. A later `bay start` removes its view once the lane's `window_s` has passed. `window_s` defaults to one day, or to the retention window when that is shorter. It must be shorter than `[retention] window_s`, or the config does not load.

## What lanes cannot do

- A lane change never merges. No flag or retry changes that.
- On a host from `bay host init` with a key registry, the host hook also refuses its merge.
- bay cannot detect bytes copied by hand, such as a paste or a file from another clone.
- The hook judges only the four Git shapes above.
- `bay merge` catches only copies that carry lane history.

## Refusals

Each refusal is `blocked` and exits `10`. The Results page lists what to do next for each one.

| Code | Meaning |
|---|---|
| `LANE_LEAK` | The change's history reaches lane commits that are not on main. `detail.leaks` names the lane refs and commits. |
| `SHELL_DENIED` | The agent hook denied a Git command that copies from a lane ref. |

## Limits

- **Tips**: one check reads at most 64 lane tips, and `detail.tips_truncated` says when it stopped.
- **Commits**: one leak names at most 20 commits, and counts them all.
- **View**: an ended lane change keeps its view for `window_s`, at most one day by default.

---

# Wire any reviewer to bay

> Run any review tool on each merge after it merges, and keep every review out of the merge decision.

Source: https://getbay.io/docs/reviewers (HTML).

bay runs no reviewer and calls no model. Any review tool you choose can sit beside a main branch instead. It reads each merge after it merges, and answers with a `review` record. bay reads `review` records, but never combines one into a check's verdict. So a review never decides a merge, and a slow or failed reviewer never blocks one.

In bay's terms, the reviewer is an outside producer of evidence. It needs its own name, its own reviewer key and a short script that you write. bay ships no reviewer plugin, and names no review tool as its own. On this page, `<reviewer-cli>` stands for the tool you pick, and `review-bot` for its name.

## When to wire a reviewer

- **Review exactly what merged**: each main commit is one change, so a review reads one fixed range.
- **Fix forward**: your team answers a bad change with a fix or `bay undo`, not by holding it back.
- **Tell the author**: the reviewer sends its finding to the agent that merged the change.
- **Track review per change**: a `reviewed` dimension in `bay status` shows whether each review arrived.
- **Measure a reviewer**: compare its findings with the changes your team later undid or fixed.
- **Gate a release**: stop a release while a merge since the last one has no review.

## Find what to review

`bay evidence missing` lists the main commits that no `review` record from your reviewer covers. It reads main's first-parent line, oldest first, and lists each commit under `detail.missing`. A record covers a commit when it names that commit, or that commit's base tree and tree. A tree alone never covers a commit, so an undo of an undo still needs its own review. Add `--since <rev>` to list only the commits after it. With a registry, only a record that verifies against the `reviewer` file counts.

```sh
bay evidence missing --kind review --producer review-bot --since v1.4.0 --json
```

> A merge that an outside merge pushed off main's first-parent line is not listed.

### Follow merges as a stream

`bay status --since --merged` lists the merges after a cursor, each with its base, agent and time. Keep the `detail.cursor` it returns, and pass it back with `--since` next time. Outside a view, a first call with no cursor lists nothing and returns a starting cursor. A commit that merged outside bay stays in the stream, with `outside_bay: true` and `merged_as`. Merged-in commits off the first-parent line are not listed, and `off_first_parent` counts them.

```sh
bay status --since "$cursor" --merged --json
```

## Review each merge

Each merge is one commit on main's first-parent line, so `<commit>^..<commit>` is exactly what merged. Give that range to any review tool, and read the code at that fixed commit. A merged commit never changes, so its review never goes stale. Keep the author's reasoning out of the reviewer's input, so the review stays independent. bay plays no part in this step.

```sh
bay evidence missing --kind review --producer review-bot --json \
  | jq -r '.detail.missing[].commit' \
  | while read -r c; do
      <reviewer-cli> review "$c^..$c" > "review-$c.json"
    done
```

## Record the review

`bay evidence record --kind review` writes one `review` record for each merge you name with `--commit`. When one review read several merges, repeat `--commit`, and each merge gets its own record. `--verdict` has no default, and `--body` takes a file that holds a JSON object, such as the review's findings. bay signs each record whole with `bay.reviewerKey`, findings included, so a changed finding no longer verifies. It adds the records to `refs/notes/bay/evidence/<reviewer>` on each merge's tree, then pushes that ref. Each record names the merge's commit, its tree and the tree of its first parent.

```sh
bay evidence record --kind review --verdict product_failure --commit "$c" \
  --body "review-$c.json" --version reviewer-cli-2.3 --json
```

### Choose the verdict

- A useful convention for the verdict: `pass` when the review found nothing important.
- `product_failure` when it found something important.
- `unknown` when the review failed, timed out or gave no answer. Never write `pass` for no answer.

> Keep the findings under a field of their own, such as `findings`.
> A body field that names a field of the record itself is refused.

### Reruns, conflicts and size

- **Same id**: a record's id is the reviewer's name, the kind, the commit and a digest of `--input` and `--version`.
- **Rerun**: the same content again writes nothing, and the result counts it under `unchanged`.
- **Conflict**: a different record under one id is refused, so a changed verdict needs a new `--version`.
- **Size**: a record over 64 KiB of JSON is refused with the limit named, and bay never cuts one.
- **All or nothing**: one refused record writes nothing for any commit in the call.
- **Keys first**: the command fails without `bay.reviewerName` and `bay.reviewerKey`, even on a main branch with no registry.

> When the push fails, the run returns an error, but the record stays in this clone's notes ref.
> The next run pushes it.
> When another clone recorded under the same name first, the run takes those records before it writes.
> When each clone holds records the other lacks, the run fails, names the divergence and writes nothing.
> So write under one name from one clone only.

## Route a finding

A finding about a merge goes to the agent that merged it. The reviewer sends that talk itself, signed with its reviewer key, and needs no agent key. It sends only a `delivery` talk with `--change`, about a merge it holds a `review` record on. bay checks that record against the registry before it sends, and the host checks it again on the push. `--to-change` reads the recipient from bay's own merge record, never from a commit trailer. The talk closes as `answered` when a fix, undo or supersede of the change merges.

```sh
BAY_ACTOR=review-bot bay talk --to-change c7k2m4qa "Fix the unchecked null in src/orders/parse.rs line 42" \
  --kind delivery --change c7k2m4qa --follow-up 2026-10-22T09:00:00Z
```

> A talk needs a key registry, so without one the reviewer cannot send it.
> bay names the sender from `BAY_ACTOR`, so set it to the reviewer's enrolled name.
> Give `--change` the change id, because bay finds the merge by its `Change-Id`.
> An agent sends the same talk with its own agent key instead.

### Send a later finding

A reviewer holds at most one open talk about one change to one agent. A later finding on that change supersedes the open talk, so the author sees only one. When you leave out `--supersedes`, bay names the open talk in `supersedes` for you. Sending the same finding again is a retry of the open talk, not a new talk. A `--supersedes` that leaves the open talk standing is refused with `TALK_REFUSED`, which names it. A host from `bay host init` and the recipient's bay refuse a second open talk about the change too.

## Track review per change

A `[[dimension]]` table in `.bay/policy.toml` defines a completion dimension of your own, such as `reviewed`. It names the record `kind` and the `producers` whose records meet it. A record counts only when it names the merge commit, and with a registry, only when it verifies. A `[profile.<name>]` table may require the dimension, and `[policy] profile` in `.bay/config.toml` selects that profile. bay reads both at the merge's base, so a change cannot relax its own profile. `bay status <change>` reports the dimension under `completion.dimensions`, and the profile verdict beside it.

```toml
[[dimension]]
name = "reviewed"
kind = "review"
producers = ["review-bot"]

[profile.release]
require = ["merged", "checked", "reviewed"]
```

### How bay judges the dimension

- **Met**: every counted record about the merge says `pass`.
- **Rejected**: a counted record says anything but `pass`, `unknown` included.
- **Answered**: a later merge whose `Fixes:`, `Reverts:` or `Supersedes:` names the change answers a rejection.
- **Missing**: no counted record about the merge exists yet.
- A later `pass` never answers a rejection. Only a merge that names the change does.
- A profile counts `met` and `answered` as met, and names every missing dimension, as in `reviewed missing`.

> The merge verdict never reads a site dimension.
> So a missing or rejected review never blocks or delays a merge.
> With `require_signed = true` in the profile, a dimension that rests only on unsigned records is `unknown`.

## Measure the reviewer

`bay status --lineage` lists the `Reverts:`, `Supersedes:` and `Fixes:` pairs on a main range. Each pair names the commit, its change, the relation and the target change. Join those targets with your reviewer's records on the same merges. The command reads local refs only, so fetch main first.

```sh
bay status --lineage --json
```

### What the join tells you

- **Missed**: an undone merge whose review said `pass`.
- **Caught**: an undone merge whose review said `product_failure`.
- **Noise**: a merge with a finding that nobody undid or fixed later.
- **Gap**: a merge with no review record, or with `unknown`. Never count a gap as caught.

## Keys and custody

`bay.reviewerName` names the reviewer, and `bay.reviewerKey` is the only key that signs its `review` records. Without a registry, bay verifies no signature, and every record in the reviewer's notes ref counts. With a registry, enrol the reviewer's key in the `reviewer` file, under that same name. A `review` record verifies only against that file, and a reviewer key never signs a check. bay refuses a registry that enrols a reviewer name as a producer or an agent too. So an author's agent key can never sign the review of its own merge.

- **Its own key**: the reviewer never holds a key that signs checks, merges or releases.
- **No agent key**: the reviewer's account can read no agent key, so it never acts as an agent.
- **Its own account**: run the reviewer under its own OS account, apart from agent sessions.
- **Read only**: the reviewer reads merges from its own clone or a bundle, and never merges code.
- **Keys out of agent reach**: no account that runs agent sessions can read a signing key.

> `bay doctor` shows a `key-custody` row on every machine.
> It judges the `release`, `producer`, `executor`, `registry` and `reviewer` keys that the machine's Git settings name.
> It turns red with `KEY_EXPOSED` when this account can read such a key and runs agent sessions.
> With such a key on the machine, passwordless `sudo` for this account turns it red too.
> A hardware key that signs only with a touch is not red.

### Choose who answers a check

A `review` record never answers a check, whatever check it names. To limit which producers answer a check, list them in `[[check]] producers` in the policy. A record from any other producer does not count for that check. bay reads the list at the base, so a change cannot widen it for itself.

```toml
[[check]]
id = "tests"
environment = "linux-x86_64"
# ... command, adapter and the check's other fields
producers = ["ci-linux"]          # only this producer's records answer the check
```

## What not to do

Never make a model score, a review verdict or a runtime review a required check. A model can disagree with itself on one tree, and bay then blocks the change as `CHECK_FLAKY`. A rebuild on a moved main makes a new tree, so a required review would run again each time. A reviewer that times out answers `unknown`, and unknown never passes, so its outage blocks merging. A fixed, repeatable test, such as a smoke test or a secret scan, belongs in a `[[check]]` instead. Never let a finding start `bay undo` by itself: a person or an agent decides between undo and a fix.

## Review after merging

For a team that fixes forward, review after merging is the better default. It reads exactly what merged, costs the merge path nothing, and never loops on one change. A bad change stays on main until its review, so this default holds on three conditions.

- **Catch the irreversible before merging**: secrets, migrations and build scripts need deterministic required checks.
- **Gate the release, not the merge**: if you deploy from main, require a review on each merge first.
- **Review within minutes**: run the reviewer soon after each merge, and measure the time to its record.

> Keep review before merging where a person must approve each change first.
> bay cannot wait for a review record before a merge, and a `reviewed` dimension does not change that.

## Gate a release on review

Before you tag a release, list the merges since the last release that have no review. `bay evidence missing` exits `0` with `decision: ok` even when it lists commits, so read `detail.missing`. Stop when that list is not empty, or when the command could not fetch main. Also stop when `detail.off_first_parent` is not `0`: those commits came in through a merge. A record covers its commit whatever its verdict, so read the verdicts too if a failed review must stop you. A `reviewed` dimension shows the same answer for one change in `bay status`.

```sh
#!/bin/sh
# release-gate: stop while a merge since the last release has no review record.
set -eu
last=${1:?usage: release-gate <last-release-tag>}
out=$(bay evidence missing --kind review --producer review-bot --since "$last" --json) || {
  echo "$out" >&2; exit 1
}
if ! echo "$out" | jq -e '
    (.freshness.fetched_at | startswith("not fetched") | not)
    and (.detail.missing | length == 0)
    and .detail.off_first_parent == 0' >/dev/null; then
  echo "not reviewed since $last:" >&2
  echo "$out" | jq -r '.detail.missing[].commit' >&2
  exit 1
fi
```

## Keep review records

bay never withdraws an evidence record, and has no retention cut for evidence notes. `bay evidence record` only adds records, and a new `--version` writes a second record beside the first. With a registry, a main branch from `bay host init` refuses a notes push that drops or changes a record. That refusal is `EVIDENCE_WITHDRAWN`, and the host also refuses to delete a notes ref. On any other host, a cut of notes is your own act with plain Git, never bay's. To archive, `bay share <change> --bundle <file>` writes that change's ref and every evidence notes ref this clone holds to one bundle.

```sh
bay share c7k2m4qa --bundle evidence-2026-10.bundle
```

## What a review cannot do

- It cannot block, delay or approve a merge.
- A reviewer key cannot sign a check, so it never counts toward one.
- A reviewer cannot send a discovery or reliability talk.
- A reviewer cannot talk about a merge it holds no `review` record on.

## Refusals

The Results page lists what to do next for each one.

| Code | Meaning |
|---|---|
| `EVIDENCE_WITHDRAWN` | A host from `bay host init` with a registry refused a notes push that drops or changes a record. |
| `TALK_REFUSED` | `detail.why` says why: the sender is no agent, or a reviewer without its own `review` record on that merge. Or a later finding's `--supersedes` leaves the reviewer's open talk about that change standing. |
| `OUTSIDE_MERGE` | With `--to-change`: the main commit is no bay merge, so bay has no record of its agent. |
| `CHANGE_NOT_MERGED` | With `--to-change`: the change or commit is not on main. |

## Limits

- **Merges**: one `bay status --since --merged` lists at most 500 merges, and sets `more` when it stops early.
- **First parent**: both queries list first-parent main commits only, and count the rest in `off_first_parent`.
- **Signed records**: with a registry, a `review` record counts only when it verifies against the `reviewer` file.
- **Record size**: one record is at most 64 KiB of JSON.

---

# Require a person's approval

> Hold a change to chosen paths until a person signs a yes for its exact checked candidate.

Source: https://getbay.io/docs/approvals (HTML).

An approval check makes bay wait for a person before it merges a change to chosen paths. You list the paths in `when_paths`, such as `billing/` or `infra/`. A change that touches them waits with `APPROVAL_PENDING` until an enrolled approver signs a yes. A change elsewhere merges without one.

An approval is a signed evidence record, not a comment or a button. It names the exact candidate that bay checked. It holds while the files under `when_paths` stay the same, so an unrelated merge does not ask again. Only a key in the registry's `approver` file can sign one that counts. bay is not a code-review tool. The approver decides how to review.

## When to use approvals

- **Risky paths**: billing, auth or infrastructure code needs a person's yes before it merges.
- **Many agents**: agents merge most work alone, and a person signs off where it matters.
- **Named approvers**: only the people a check lists may approve its paths.
- **Known failures**: a person waives a failing check you mark waivable, named as a gap, never a pass.

## Add an approval check

Add a `[[check]]` with `kind = "approval"` to `.bay/policy.toml` on main. Every approval check needs `when_paths`. Use `["**"]` to ask for an approval on every change. An approval check runs nothing, so it takes no `command`, `adapter`, `expected_minimum` or other run settings. It is never a `post` check. `producers` narrows which approvers may answer it. Every policy still needs a run check with `expected_minimum` above zero, unless it declares `unchecked = true`.

```toml
[[check]]
id = "billing-approval"
environment = "people"
kind = "approval"                  # a person answers; bay runs nothing
when_paths = ["billing/"]          # the paths that need a yes
producers = ["alice"]              # optional: only these approvers count
```

## Enrol an approver

Approvals need the key registry. Without one, no approval counts, for merging, promotion or `bay status`. On the machine that holds the registry key, run `bay enrol approver <name> <key.pub>`. Ask the person for the public key only, the `.pub` file. An approver name is a short lower-case word, never also an agent, producer or reviewer name. On the approver's machine, set `bay.approverName` and `bay.approverKey` in Git config.

```sh
bay enrol approver alice alice.pub              # where bay.registryKey is set

# on the approver's machine
git config bay.approverName alice
git config bay.approverKey ~/.ssh/bay-approver   # a hardware key, such as ed25519-sk, is best
```

## See what waits for you

`bay waiting` lists the changes that wait for your approval, as the remote holds them. It lists only the checks that your `bay.approverName` may approve. Without `bay.approverName`, it lists every change that waits, with who can approve each. Each item in `detail.waiting` names the change, its intent and `actor`, the candidate `commit` and the `paths` to approve. `diff.summary` counts the files added, changed and removed, and the lines added and removed. With nothing waiting, it returns `ok` with an empty list.

```sh
bay waiting
```

## Approve a change

Review the candidate, then run `bay approve <change>` on your own machine. bay signs one `approval` record with your approver key for each check you may answer, and pushes it. It approves the exact candidate that `bay waiting` and `bay status` name, never a newer one. `bay reject <change>` records a signed no instead. `--why` keeps your reason in the record. bay never approves for another name: the writer is always this machine's `bay.approverName`. `bay status` names the same command under `operation.approval.approve`.

```sh
bay approve c7k2m4qa                                     # yes, for the candidate bay waiting names
bay reject c7k2m4qa --why "The refund rule is wrong"     # a signed no
```

> If a newer candidate replaced it, bay says so; your yes holds for it while its files stay the same.
> `bay evidence record --kind approval` also records an answer by hand; `record` in `operation.approval` gives the command.

## Merge after the approval

Once every approval is in, the runner's next pass merges that same candidate. No one runs `bay merge` again, and checks that already passed on it do not run again. Without a runner, the author's `bay merge --wait` merges it. If main moved, bay builds a new candidate, and the approval holds while its files stay the same. A no blocks the change with `CHECK_FAILED`, naming you. Its author repairs it and submits again. `detail.lands_by` names who merges it next: `executor`, `here`, `author`, or `author_repairs` after a no.

```sh
bay merge --wait  # only without a runner
```

## When an approval holds

An approval holds for later candidates of the same change, such as one built after main moved. It holds while every file under the check's `when_paths` is byte for byte the same. bay compares those files in the new candidate and in its base with what the approver saw. The merge names each approval it carried under `detail.carried_approvals`, with `carried_from`, the record that answered. When a covered file changed, `bay status` and `APPROVAL_PENDING` name the old approval under `set_aside`, with the `paths`. Set `carry = false` on the check to ask for an approval on every candidate.

```toml
[[check]]
id = "billing-approval"
environment = "people"
kind = "approval"
when_paths = ["billing/"]
carry = false                      # ask again on every candidate
```

> A no never carries forward. A no on the same files, or with no stamp, stops a yes from carrying.
> After a no and then a yes on the same files, a later candidate needs a fresh yes.
> An approval carries only under the same policy. No approval carries onto the tree of a `batch`.
> An approval recorded without a stamp of its files carries nothing.

## Cap the changes that wait for a yes

Set `max_waiting_approvals` in the `[limits]` table of `.bay/policy.toml` on main. While that many live changes wait with `APPROVAL_PENDING`, `bay merge` refuses `WIP_LIMIT` for a change that needs an approval. `detail.review` names how many wait, the `max`, the `oldest` change and its `age_s`. The cap never holds a change that needs no approval. bay counts the change records this clone holds, and reads the cap from main. `bay status` shows the same queue under `detail.flow.waiting_approval`.

```toml
# .bay/policy.toml
[limits]
max_waiting_approvals = 5
```

## How bay judges an approval

- bay reads the approval check from the policy on main, like every check.
- bay asks for an approval only once every other required check passed.
- An approval counts under the policy that judges it, also for later candidates with unchanged files.
- A change to the policy needs an approval under both policies. `bay approve` writes one for each.
- Only an `approval` record answers an approval check, and an approval never answers a run check.
- A no from any approver, or two approvers who disagree, rejects the change.

## Waive a failed check

A waiver lets one failed check through on one candidate, on an enrolled approver's signed word. Mark the check `waivable = true` in `.bay/policy.toml` on main. bay reads it at the candidate's base. Only a check bay runs before the merge takes a waiver; a `post` check never blocks a merge. An approval check with `waivable = true` makes the policy invalid: a person's yes answers it, never a waiver. Waivers need the key registry. Without one, no waiver counts, and `bay approve --waive` refuses.

```toml
[[check]]
id = "tests"
environment = "local"
command = "scripts/bay-check"
adapter = ["scripts/bay-check"]
expected_minimum = 1
waivable = true                    # an enrolled approver may waive it on one candidate
```

### Waive it

A change blocked on a waivable check with `CHECK_FAILED`, `CHECK_FLAKY` or `CHECK_UNKNOWN` shows up in `bay waiting`. Its item lists each such check under `waivable`, with its real `verdict` and `waive`, the command that waives it. Run that command on your own machine. Give your reason with `--why`; bay keeps it in the signed record. bay signs one `waiver` record with your approver key, about that exact candidate, and pushes it. Then the change's author runs `bay merge --wait`, and that same candidate merges. No runner picks it up alone. If main moved first, bay builds a new candidate, and the waiver does not cover it.

```sh
bay waiting                                                       # the waivable check, its verdict and the command
bay approve c7k2m4qa --waive tests --why "Runner out of disk; tracked in OPS-12"
bay merge --wait                                                  # the author, in the change's view
```

> A refused check pushes its evidence, so an approver on another machine reads its real verdict.
> If that push fails, the approver reads `unknown`, never a pass.
> With a registry, `bay waiting` lists waivable checks to an enrolled approver, or to anyone with no `bay.approverName` set.
> `bay waiting` and `bay approve --waive` skip a candidate built for a `batch`.

### How a waiver counts

- A waiver is never a pass. Its record's verdict is always `unknown`.
- The merge names it in `coverage_gaps` with the check's real `verdict`, `waived_by`, `why` and `record`.
- The result's `say` says the check did not pass and a person waived it.
- A waiver covers one candidate and never carries, even to a later candidate with the same files.
- Only an enrolled approver's signed waiver counts, under either signing mode.
- Promotion never counts a waiver. Promote main to other branches explains it.

## What an approval cannot do

- An unsigned yes never counts. Without a key registry, no approval check can pass.
- A key of any other class never signs an approval that counts.
- On a host from `bay host init` with a registry, the hook refuses such a record.
- An approver key signs `approval` and `waiver` records only, never a check record or a review.
- A quarantine sets an approval check aside, like any check.
- bay never reviews the code. It records who said yes to which tree.

## Refusals

Each refusal is `blocked` and exits `10`. The Results page lists what to do next for each one.

| Code | Meaning |
|---|---|
| `APPROVAL_PENDING` | Every other check passed, and an approval is still owed. `detail.approval` names the approvers, the candidate `commit` and `tree`, and `approve`, the command an approver runs. A check's `set_aside` names an earlier approval whose files changed, and those `paths`. Without a registry, its `why` says that no approval can count. |
| `CHECK_FAILED` | An approver rejected the candidate. `detail.checks` names each `approver` who said no. |
| `WIP_LIMIT` | From `bay merge`: `[limits] max_waiting_approvals` changes already wait for a yes. `detail.review` names the oldest. |

> `bay approve` and `bay reject` refuse as `failed`, exit `20`, with `detail.why` and `detail.recovery`.
> It refuses without `bay.approverName` and `bay.approverKey`, for a change that waits on nothing, and for a name not enrolled.
> `--waive` also refuses without `--why`, without a registry, and for a check not marked `waivable` at the base.

## Limits

- **Record size**: one record is at most 64 KiB of JSON.
- **Scope**: one approval covers one change under one policy, while its files stay the same.

---

# Host a main branch without a forge

> Keep main in a plain bare Git repository over SSH, and let the host itself refuse any rewrite of main.

Source: https://getbay.io/docs/host (HTML).

`bay host init` creates a bare Git repository to serve as your remote. Its main is append-only, and Git on the host enforces that on every push. With a key registry, the host also refuses any main commit that is not a runner's merge. Use it when you have no forge, or no route to one.

It is plain Git, not a server. bay adds no protocol, no accounts and no daemon. Git over SSH carries every push and fetch. Who may push at all stays with SSH and the host's file permissions.

## When to host a main branch

- **No forge**: you keep this repository on no GitHub, GitLab or other forge.
- **Air-gapped machines**: the machines have no route out. Move changes with `bay share --bundle`.
- **A bare repository over SSH**: you already keep repositories on a server you run.
- **Rules on the host**: you want the host itself to refuse a rewrite of main.

## Set up the host

1. **Check Git** The host needs Git 2.54 or newer. Below that, Git ignores hooks set in configuration. Below 2.54, `bay host init` refuses with `TOOL_BELOW_FLOOR` and creates nothing.

2. **Create the repository** Run `bay host init` on the host. The path must be empty or absent. `--main` names main, `main` by default. Without `--object-format`, it takes the object format of the repository you run it in, else Git's default. Clones must match the host's format, because SHA-1 and SHA-256 repositories cannot share objects.

```sh
bay host init /srv/git/app.git --main main
```

3. **Check the host** Run `bay doctor` in the new repository. Its `main-hook` row must be green.

```sh
cd /srv/git/app.git && bay doctor
```

4. **Point your clones at it** Add the host as a remote. Commit `.bay/config.toml` and `.bay/policy.toml`, then push main. Record main and the remote with `bay install --main main --remote origin`.

```sh
git remote add origin ssh://git.example.com/srv/git/app.git
git push origin main
bay install --main main --remote origin
```

5. **Add the registry** Push the first main before the first registry. With a registry, each new main commit must be a runner's merge, the first ones too. The host judges no push before it holds a registry, so anyone who can push may create the first one. So create the registry yourself, right after the first main, with `bay enrol`. Keys and the registry explains the classes and the rules.

```sh
bay enrol registry admin ~/.ssh/bay-registry.pub   # with bay.registryKey set to its private half
```

## What `bay host init` sets

The hooks call the bay that ran `bay host init`, by its full path. Keep bay at that path on the host.

| Setting | What it does |
|---|---|
| `--ref-format=reftable` | Stores refs in reftable, whose updates of several refs in one push are atomic. |
| `hook.bay-trunk` on `update` | Keeps main append-only. |
| `hook.bay-scope` on `pre-receive` | Judges every bay ref in a push, once the host holds a key registry. |
| `bay.main` | The main branch name that both hooks read. Without it, they guard the branch the host's `HEAD` names. |
| `core.logAllRefUpdates = always` | Keeps a reflog for every ref. The hook reads it to judge an adoption by the host's clock. |
| `receive.advertiseAtomic = true` | Lets a merge move main and its merged marker in one atomic push. |
| `core.commitGraph = true` and `gc.writeCommitGraph = true` | Git reads a commit-graph to walk history faster, and `git gc` writes one. |
| `commitGraph.changedPaths = true` and `commitGraph.changedPathsVersion = 2` | Each graph Git writes carries changed-path Bloom filters, version 2. |
| `maintenance.commit-graph.enabled = true` | Git's own maintenance writes and refreshes the graph after a push. Git's default waits until 100 commits are outside it. bay runs no maintenance itself. |

## What the main hook refuses

- A push that is not a fast-forward of main.
- A deletion of main.
- A new main after the old one held merged changes.
- Any main update the hook cannot judge. An unknown outcome is a refusal.

## What stays rewritable

The main hook judges main only. `refs/bay/*` and `refs/notes/bay/*` stay rewritable, so retention cuts still work. So bay does not set `receive.denyNonFastForwards` or `receive.denyDeletes`. Those would block retention cuts too. With a key registry, an evidence record is never withdrawn. The host refuses a notes push that drops or changes an earlier record with `EVIDENCE_WITHDRAWN`.

## With a key registry

Once the host holds `refs/bay/registry`, the scoped hook judges every push. Git does not tell a hook who pushed. So the hook judges the signatures on what the push adds. Any refusal fails the whole push.

- Only an `executor` key can move main. Each new main commit must be a merge that a runner signed.
- Each merge needs a current agent-signed request and passing producer-signed evidence.
- Each merge must create its merged marker in the same push, and a change merges only once.
- Only a key in the current `registry` file can advance `refs/bay/registry`, and only by fast-forward.
- A live ref takes only a summary of its own machine, signed by the agent key it names.
- Every bay ref name must use bay's fixed alphabet.

> Without a registry, the host keeps only the append-only main.
> Under `[policy] signing = "approvals"` on main, the hook checks signatures only on registry and evidence pushes, and main stays append-only.
> Anyone who can push may then add commits to main.

## Compared with a forge

| Rule | On a main branch from `bay host init` | On a forge |
|---|---|---|
| Main is append-only | The main hook refuses a rewrite or a deletion. | Turn on the forge's branch protection for main. |
| Only merges reach main | With a registry, the scoped hook refuses any commit that is not a runner's merge. | Let only the runner's identity push to main. `bay status` names any other commit. |
| A removed key | Refused on every later push. | bay's own checks apply it after their next fetch. |

## Share a change

`bay share` hands a change and its evidence to another agent. It pushes `refs/bay/changes/<change>` and every evidence notes ref to a remote, all or none. `--remote` names that remote. The default is the clone's `bay.remote`, else `origin`. `--bundle` writes the same refs to one file instead, for a machine with no route. The other agent fetches the refs with plain Git, then picks the change up with `bay start <change>`.

```sh
bay share c7k2m4qa --bundle c7k2m4qa.bundle

# on the other machine
git fetch c7k2m4qa.bundle 'refs/bay/changes/*:refs/bay/changes/*' \
  'refs/notes/bay/evidence/*:refs/notes/bay/evidence/*'
bay start c7k2m4qa
```

## Refusals

`bay host init` refuses below the Git floor. The host refuses a push with the reason on stderr.

| Code | Meaning |
|---|---|
| `TOOL_BELOW_FLOOR` | `detail.tool` is `git`: Git on the host is older than 2.54. `bay host init` created nothing. |
| `HOOK_TIMEOUT` | The scoped hook ran past its time bound and refused the push. bay retries the same candidate. |
| `QUEUED` | Another operation holds the head of the queue, so this merge waits its turn. |
| `REGISTRY_CHANGED` | The registry removed a key behind this merge after the runner signed it. |
| `HOOKS_MISSING` | `bay doctor` found a bare repository with no main hook. |
| `EVIDENCE_WITHDRAWN` | With a registry, a push dropped or changed an earlier evidence record. bay never withdraws evidence. |

## Limits

- **Git on the host**: 2.54 or newer.
- **Hook time**: the scoped hook judges a push for at most 30 seconds by default. Set `bay.hookTimeoutS` on the host.
- **Live refs**: with a key registry, a host holds at most 256 live refs.
- **Operator names**: machine, runner and producer names use `a-z`, `0-9` and `-`, at most 32 characters.

---

# Keys and the registry

> Optional signing: who may sign what, and how `bay enrol` adds and removes a key.

Source: https://getbay.io/docs/keys (HTML).

Keys are optional, and bay works without them. Without a registry, bay runs unsigned: every check and review record counts, and results say `trust: unsigned`. Add a registry when only enrolled keys may sign merge requests, merges and evidence. Then results say `trust: signed`, and a record signed by any other key never counts. A runtime observation counts only when a producer key signed it, so it needs a registry.

## The registry

bay keeps its keys in the registry, the Git ref `refs/bay/registry`. The registry is a chain of commits. Each commit holds up to six OpenSSH allowed-signers files: `agent`, `executor`, `producer`, `reviewer`, `approver` and `registry`. A registry without an `approver` file enrols no approver. A `removed` file lists each key taken out, with the reason and the time. `bay enrol` adds a key, and `bay enrol --remove` takes one out.

## Rules for the files

- Each line holds one key: `<principal> <keytype> <base64>`.
- Give each line exactly one principal, no options, and no `cert-authority`.
- Put each public key in exactly one file, and never enrol a key the `removed` file lists.
- bay refuses a registry of any other shape, and so does the hook on a host from `bay host init`.
- The hook takes a new registry commit only as a fast-forward, signed by a key in the previous `registry` file.
- It also refuses a version that drops a line of the `removed` file.

## Reviewer keys

A reviewer key signs `review` records and a reviewer's delivery talks, and nothing else. A `review` record verifies only against the `reviewer` file. A reviewer key never signs a check record, so it never counts toward a check. A reviewer name is never also a producer, agent or approver name. bay refuses a registry that enrols one name in both. Set `bay.reviewerName` and `bay.reviewerKey` in Git config on the machine that writes reviews.

## Approver keys

An approver key signs `approval` and `waiver` records, and nothing else. An `approval` or `waiver` record verifies only against the `approver` file. An approver name is never also a producer, reviewer or agent name. bay refuses a registry that puts one name in two of these classes; an agent and producer may share one. Set `bay.approverName` and `bay.approverKey` in Git config on the approver's machine. Require a person's approval shows how a person approves a change.

## Sign only approvals

By default, a registry signs everything: requests, merges, change records and every evidence record. Set `signing = "approvals"` in the `[policy]` table of `.bay/policy.toml` on main to sign only approvals. Then only an `approval` or `waiver` record needs a key, one from the registry's `approver` file. Everything else runs unsigned, as without a registry, and results say `trust: approvals`. No machine then needs an `agent`, `executor` or `producer` key. This suits a small team with no runner. bay reads the mode from main, so a change never sets the mode bay judges it by.

```toml
# .bay/policy.toml on main
[policy]
signing = "approvals"   # "all", the default, signs everything
reuse_unsigned = true   # optional: evidence reuse and batch on unsigned evidence
```

> Without a registry, bay signs nothing and no approval counts, whatever the mode says.
> `bay doctor` shows the mode in its `signing` row.

### What this mode gives up

- **Talks**: `bay talk` refuses with `TALK_REFUSED`, as without a registry.
- **Evidence reuse and `batch`**: both stay off, unless the policy also sets `reuse_unsigned = true`. Results still say `trust: approvals`.
- **Runtime observations**: only a producer-signed record counts, so none does.
- **Live summaries and runner presence**: both run unsigned.
- **The host hook**: on a host from `bay host init`, it checks signatures only on registry and evidence pushes.
- Elsewhere in these docs, "with a key registry" means the default mode, which signs everything.

## Add a key

Run `bay enrol <class> <name> <key.pub>` in a clone, on the machine that keeps the registry key. `git config bay.registryKey` names that key's private half. Never sign the registry with an agent key. bay reads the remote's registry, adds the key to its class file, signs the new version and pushes it. The first key creates the registry. It is a `registry` key: the public half of `bay.registryKey`. A host judges no registry push until it holds one, so create the first registry yourself. bay refuses a key already enrolled, a name a rule refuses, or a remote it cannot read, and writes nothing.

```sh
git config bay.registryKey ~/.ssh/bay-registry
bay enrol registry admin ~/.ssh/bay-registry.pub   # the first key: the registry key itself
bay enrol approver alice alice.pub                 # then one line per key, by class
bay enrol agent agent-7 agent-7.pub
```

> Runner, producer, reviewer and approver names are short lower-case words: `a-z`, `0-9` and `-`.
> Ask each person for the public key only, the `.pub` file.
> Each agent's machine sets `bay.agentKey`, and each agent runs with `BAY_ACTOR` set to its enrolled name.
> The host refuses a change that one agent signed for another actor.
> bay pushes the new version as a fast-forward, never forced, and reads it back from the remote.

### Remove a key

Run `bay enrol --remove <class> <name> --reason rotated|revoked` on the machine that keeps the registry key. bay takes every key of that name out of its class file. It adds a line for each key to the `removed` file, then signs and pushes the new version. The reason decides what happens to the records the key signed before.

```sh
bay enrol --remove agent agent-7 --reason rotated   # replaced in the ordinary course
bay enrol agent agent-7 agent-7-new.pub             # the same name, with a new key
bay enrol --remove agent agent-9 --reason revoked   # the key may be in other hands
```

| Reason | When to use it | The key's records |
|---|---|---|
| `rotated` | You replace the key in the ordinary course. | A record it signed before the removal still counts. bay goes by the time written in the record. |
| `revoked` | The key may be in someone else's hands. | No record it signed counts, old ones too, wherever bay reads it. |

> bay never removes the last `registry` key: nobody could sign the registry after it. Enrol another one first.
> `bay enrol` refuses a removed key under any name, before it signs or pushes anything.
> A registry that enrols a removed key again does not load, and `bay doctor` is red with `REGISTRY_UNREADABLE`.
> Repair it with `bay enrol --remove <class> <name> --reason revoked`, for the name that holds the key.
> bay refuses a name with no key in that class, and then writes nothing.

## When a change takes effect

- On a main branch from `bay host init`, a removal applies to every later push. Elsewhere, use the host's branch protection.
- bay judges a request by the registry version it names. That version must exist when the agent pushes the request.
- bay's own checks read the current registry, so they apply the change after the next fetch.
- Plain `git verify-commit` keeps the old list until `bay install` runs again on that machine.
- So run `bay install` on each machine where people verify commits with plain Git.

## Keep signing keys out of agent reach

`bay doctor` has a `key-custody` row. It judges key files on Unix only, and says `not checked` elsewhere. It judges the keys that `bay.executorKey`, `bay.producerKey`, `bay.reviewerKey`, `bay.approverKey`, `bay.registryKey` and `bay.releaseKey` name in Git config. A file key this account can read is red with `KEY_EXPOSED` when agent sessions run under this account. With such a key, passwordless `sudo` on the account makes the row red too. A hardware key, such as `ed25519-sk`, stays green. bay knows one by the `sk-` type in its `.pub` file. An agent key belongs to the session account, so the row only lists it.

```sh
ssh-keygen -t ed25519-sk -f ~/.ssh/bay-runner     # signs only with a touch
```

> For keys, the row reads paths, modes, owners and public halves only. It never opens a private key.

### Know who signed

The `key-custody` row lists `actors`: the agents named in this repository's session records that this account wrote. `actor_proof` says what a signature tells you about who signed. It is `key` when `bay.agentKey` names a key, and `none` when no agent key is set. It is `account` when several actors share this account and it can read their file agent key. Then any of them can sign as another: the row is `degraded`, unless an exposed key turns it red. `actor_note` names the fix: an OS account for each actor, or a hardware agent key.

### Refuse merges from a shared agent key

Set `[agent] require_actor_proof = true` in `.bay/policy.toml` on main to act on `actor_proof`. Then `bay merge` refuses with `ACTOR_UNPROVEN` when `actor_proof` is `account`, before it signs anything. It also refuses when bay cannot judge the key, such as a file agent key off Unix. With no agent key, `actor_proof` is `none`, and the setting refuses nothing. bay reads the setting from main, so a change cannot lift it for itself. It is off by default. The `key-custody` row shows `require_actor_proof`, and `actor_refusal` when bay refuses merges on this machine.

```toml
[agent]
require_actor_proof = true   # .bay/policy.toml on main
```

---

# Update

> Keep bay current with `bay update`, a daily background check, and version pins.

Source: https://getbay.io/docs/update (HTML).

`bay update` installs a newer bay release. It verifies the signed `SHA256SUMS`, the signed `VERSION`, and the sha256 before it replaces the program. It runs the new program once, then renames it over `~/.local/bin/bay`, whichever copy ran the update. Then it runs `bay install --machine`. That copies the matching skill into every Claude config directory, and changes no repository. It refuses a downgrade unless a pin asks for it. Update settings belong to the machine, not to a repository.

```sh
bay update                     # the newest release with a build for this machine
bay update --version <x.y.z>   # one exact version, newer than this one
bay update --check             # report only; changes nothing
```

## Update while a runner runs

`bay update` and the daily check never wait for a runner to stop. bay keeps each version it installs, and each version a runner runs, at `$XDG_DATA_HOME/bay/versions/<version>/bay`. Without `XDG_DATA_HOME`, that folder is under `~/.local/share`. An update keeps the newest three versions there, its own version, and each live runner's version. A runner's operation starts each child `bay` from its own version, so it ends on the version it began with. Between operations, `bay runner run` runs the new version in the same process, and names the move in `detail.reexec`.

> With `--once`, or on Windows, a runner keeps its version until it starts again.
> A runner never moves to a new bay that does not take its own command, such as `bay runner run`.
> It keeps running its version, and says why once on stderr.

## Automatic updates

At most once a day, `bay status` or `bay doctor` starts `bay update` in the background. So a newer release installs on its own, never during a merge. The check downloads from GitHub only, and sends no repository data. Set `bay.updateAuto` to `false` in your global Git config to turn it off. `BAY_DISABLE_AUTOUPDATE=1` turns it off too, and it wins over every setting. A pinned machine never moves on its own.

```sh
git config --global bay.updateAuto false
```

## Which release

`bay update` takes the newest release that has a build for this machine. When the newest release has no build for it, bay skips back to the newest one that has. Version numbers are 0.x, so a minor release can still change a command. The changelog names each change.

## Release keys

`bay update` trusts a list of release keys, so a key can roll over. It trusts two: the bay release key, held in hardware, and one earlier key. A later release drops the earlier key. The install script trusts only the bay release key.

## After a key rollover

`bay update` steps through a release-key rollover on its own. When it cannot verify the newest release's signature, it tries older releases, newest first. It installs the newest one it can verify that is newer than the installed bay. `detail.stepped` is then `true`, and `detail.next` says to run `bay update` again. A pin or `--version` never steps, and bay refuses a release whose sha256 does not match.

## Pins

A pin names one exact version. A pin is `bay.updateVersion` in global Git config, or `BAY_UPDATE_VERSION` for one process. With the variable, one agent on a shared login can choose its own version. `--version` picks a newer version for one run. A pin wins over the newest release, so a pinned machine moves only when the pin moves. A repository cannot pin the machine, so a cloned repository never moves or downgrades bay.

```sh
git config --global bay.updateVersion <x.y.z>
```

## Refusals

| Code | Meaning | What to do next |
|---|---|---|
| `UPDATE_UNVERIFIED` | The signature, sha256 or `VERSION` did not verify, or the new program did not run. Nothing changed. | Keep the installed bay and tell the user; run `bay update` again once the release is republished. |
| `UPDATE_PINNED` | The target is not the pin of this machine, or it is older than the installed version. | Keep this version: `bay update` moves only after this machine's pin moves. |
| `UPDATE_UNAVAILABLE` | No release could be read: the network is down, or no release matches this platform or version. | Run `bay update` again later, once a release is reachable. |

---

# Roll bay out to many machines

> Install bay on every machine, then turn on its hooks for all of them with one merge.

Source: https://getbay.io/docs/rollout (HTML).

A rollout puts three things in place: the `bay` program, the skill, and bay's hook entries. The program and the skill belong to each machine. Hook entries belong to the repository, in a settings file on main. So you install on each machine, then merge the hook entries once.

`bay install --machine` changes no repository, so you can run it on a machine where agents work. Hooks never spread by a `bay install` in each clone. Every clone takes them from main.

## When to use this order

- **Many machines or accounts**: agents on several machines, or under several Claude config folders, share one repository.
- **Turning on the hooks**: the repository merges with bay, and main is about to name `[agent] product`.
- **A red `hooks` row**: doctor names merging the settings file, not another install.

## Roll it out

1. **Install the program and the skill on every machine** On every machine, run `bay update`, then `bay install --machine`. It writes the skill into every Claude config directory it finds. Install lists where it looks. It writes only the `bay` program and the skill. It never writes hook entries, a repository's settings or its Git config. On a machine without bay, run the install script first, outside any repository.

```sh
git config --global --add bay.claudeConfigDirs <dir>   # only a directory bay cannot find
bay update
bay install --machine
```

2. **Name the agent product on main** Merge `[agent] product = "claude"` in `.bay/config.toml` into main. Each machine's `bay doctor` now reads the `hooks` row red until the next step. That red row is normal here, not a fault.

```toml
# .bay/config.toml
[agent]
product = "claude"
```

3. **Merge the hook entries once** In one clone that holds the new main, run `bay install`. It writes bay's entries into `.claude/settings.json`, and its result says that file still has to merge. Merge it into main, for example with `bay save`. Every machine gets the hooks when it takes that change, with no install of its own. On a machine without bay, nothing changes: each Claude Code entry first checks that `bay` is present.

```sh
bay install              # writes bay's entries into .claude/settings.json
bay save enable-hooks    # merges that edit into main as one change
```

4. **Check each machine** Run `bay doctor` on each machine after it takes the change. Each doctor pushes its verdict, so `bay status --fleet` on any one machine shows every machine's. `ready` counts the green and red machines, and those that never ran doctor. Fix what each red row names. Do not guess.

```sh
bay doctor           # on each machine
bay status           # on any one machine: fetch every verdict
bay status --fleet   # each machine's verdict, and ready
```

## Why this order

- `bay install` reads `[agent]` from main as this clone last fetched it, so it writes entries only after step 2.
- The settings file is on main, so one merge reaches every clone and view.
- A `bay install` in each clone would only leave an uncommitted edit of that file.
- Until a clone takes the change, main gives it no bay hook.

## What the hooks row says

| `hooks` row | What it means | Your step |
|---|---|---|
| `off` | Main names no agent product. The row's `recovery` names steps 2 and 3. | Step 2. |
| red, and main tracks the settings file | The file lacks bay's entries. The row's `recovery` names merging the change that adds them. | Step 3. |
| red, and no settings file is on main | Nothing holds bay's entries yet. The row's `recovery` says to run install. | Step 3: install in one clone, then commit and merge the new file. |
| green | This clone holds bay's entries. | Nothing. |

### With Codex

With `product = "codex"`, the same steps write and merge `.codex/hooks.json` instead. A Codex entry has no presence check: on a machine without bay, the hook fails, and Codex runs the tool. For the skill, paste the block on Agents and hooks into `AGENTS.md`.

---

# Security and privacy

> What bay sends, where it sends it, what it guards, and how you can check every claim yourself.

Source: https://getbay.io/docs/security (HTML).

bay runs on your machines, against your own Git remote. It has no server, no account and no telemetry. Your code goes only where your own Git remote is. bay is free and open source under the MIT licence, so you can read every line.

This makes bay fit for private repositories, in a company of any size. It does not replace your host's access control: anyone who can push to your remote can still push.

## What leaves your machine

| What | Where it goes | When |
|---|---|---|
| Changes, evidence, talks, fleet records and each machine's last doctor verdict | Your own Git remote, as commits, notes and refs | When a bay command fetches or pushes |
| A download of bay's release list and archive | GitHub, over HTTPS | `bay update`, and a background check at most once a day |
| What your plugins send | Where your plugin sends it | Only when your config names a plugin |

> The update check reads from GitHub, and installs only a release that verifies. It sends no repository data.
> A doctor verdict holds names, states and versions, never a path or a secret.
> Turn it off with `git config --global bay.updateAuto false`, or with `BAY_DISABLE_AUTOUPDATE=1`.

## Machines with no route out

bay needs no internet access. It needs only a Git remote that the machine can reach. With no network at all, move changes between machines with `bay share --bundle`. Turn off the update check there, and install bay from a release archive you verified.

## Checks run in a sandbox

On Linux, a check runs in `bwrap`. On macOS, it runs in `sandbox-exec`. The check sees only its candidate, its `adapter` paths and system files. It gets no network unless its `[[check]]` entry says `network = true`. bay reads that entry from main, so a change cannot grant itself the network. Your home folder, `.git`, other views and signing keys stay out of its sight. A directory named in `bay.checkReadPath` is the exception: read-only, and never the repository, a view or a key.

> On a machine that runs checks and has no working sandbox, no check runs, and `bay doctor` is red with `SANDBOX_ABSENT`.
> When AppArmor stops `bwrap`, the red row names an AppArmor profile that fixes it.
> Its owner can choose to run checks without one: `git config --global bay.executorSandbox none`.
> Then a check has that account's full access, network included.
> On macOS, bay takes `sandbox-exec` as present without testing it; on Linux, it tests `bwrap` first.

## Signed releases

The bay release key, held in hardware, signs each release. The install script trusts only that key. `bay update` and the daily check also accept one earlier key, until a later release drops it. Both verify the signature and the sha256 before they install, and refuse a release that does not verify. A refused release leaves the installed program as it was. `BAY_INSTALL_SKIP_SIGNATURE=1` checks only the sha256, which catches corruption but not tampering; leave it unset.

## Read the code yourself

bay is one Rust program that forbids unsafe code, with five direct dependencies: `clap`, `serde`, `serde_json`, `sha2` and `toml`. Of the system tools bay starts, only `git` and `curl` reach the network: `git` to your remote, `curl` for releases. `git` may in turn run `ssh`, credential helpers or remote helpers that your Git config names. The rest run locally: `ssh-keygen`, `tar`, `sudo -n true`, `id`, `hostname`, `kill`, `df`, `claude --version`, `codex --version`, the sandbox and bay itself. On Windows, bay also starts `tasklist`, `taskkill`, and Git for Windows' `sh` for a script check. Your plugins and an external runner run outside the sandbox, with whatever access you give them.

```sh
git clone https://github.com/gudjon/bay.git
grep -rn 'Command::new' bay/src    # every program bay starts
grep -rn 'https://' bay/src        # every address bay reads
```

> Only an external runner's submit command keeps bay's full environment. Every plugin gets only `PATH` and `HOME`.
> bay starts each plugin in its own process group, and at its time limit kills the whole group.
> Ask your coding agent to read the source and test each claim on this page.

## What each command writes

Every bay command declares what it writes, and what it never writes. `bay help` shows both under each command, and the CLI reference lists them. `bay install --machine` never writes hook entries, a project's settings or its Git config. Each Claude Code entry first checks that `bay` is present, so it blocks nothing on a machine without bay.

## What bay guards, and when

bay guards four things, and each one holds only under its own condition. Read the condition before you count on a guard.

| What bay guards | When it holds |
|---|---|
| Its own merges: what bay merges into main is exactly the candidate it checked. | Always, unless you turn on batching or footprint reuse. |
| An agent's Git commands: the hook refuses a push to main, a force push in any flag spelling, `git reset --hard` and the other shapes Agents and hooks lists. | Only with the hook: `[agent] product = "claude"` in `.bay/config.toml` on main, and bay's hook entries in the project's settings file on main. One clone runs `bay install` and merges that file; every clone gets the hooks from main. With `"codex"`, the hook refuses too, but Codex runs the command when the hook crashes, hangs or fails. |
| One session at a time edits a file that never merges: a path `[locks] paths` names. | Only with the hook, and only for an edit tool's write. A shell command's write and a merge never check a lock. |
| Everything that reaches main, such as a merge in a forge's web page or a script's push. | Only on a main from `bay host init` with a key registry that signs everything, or on a forge main that only the runner's identity may push. |

> Without the hook, the `hooks` row of `bay doctor` reads `off`, and an agent can push main around bay.
> `bay status` says `detail.guard.hook`, and names each commit that went around bay under `detail.unattributed`.
> With the hook off, `bay doctor` turns red with `HOOKS_MISSING` when main holds such commits beside bay's merges, and counts them.
> The hook matches command shapes, so another spelling can get past it. A protected main has no such gap.
> A denied command tells the agent `SHELL_DENIED`, why, and its next step.
> The hook also refuses a tool call that reaches it under a wrong or unknown moment name.

## Keys are optional

Without a key registry, bay runs unsigned, and every check and review record counts. Add a registry with `bay enrol` when only enrolled keys may sign. The Keys page shows how.

## Report a vulnerability

Report it privately: follow `SECURITY.md` in the bay repository, or email hello@getbay.io. Do not open a public issue.

## Limits

- Windows has no check sandbox, so a check there fails unless its owner sets `bay.executorSandbox` to `none`.
- The update check reaches GitHub at most once a day, and a download stops after 300 seconds.

---

# CLI reference

> Every bay command, its purpose, and the result it prints.

Source: https://getbay.io/docs/cli (HTML).

bay has a few daily commands for agents and a few setup commands for people. Through a pipe, or with `--json`, each command prints one `bay.result/1` document on stdout. At a terminal without `--json`, a person sees plain text instead. Either way, the command exits 0, 10 or 20. Plain help, `--version` and a usage error print text instead, and a usage error exits 2. `bay help` and `bay --help` show every command, its flags and what it writes; `bay <command> --help` says more.

## Daily commands

Five commands make the daily loop: `bay start`, `bay merge`, `bay status`, `bay status --since` and `bay undo`. `bay talk` and `bay status --fleet` help agents on different machines work together.

| Command | Purpose |
|---|---|
| `bay start <intent> --paths <path>…` | Open a change: a fresh fetch of main, a sparse view of the declared paths, and the change record. The intent says what the work is, in a few words, or gives a card id. Each start also releases clean views of ended changes past the retention window. It lists live changes whose declared paths overlap under `detail.overlaps`, and refuses none. When it re-opens a change, `detail.behind` counts the main commits it missed. After the intent's change merged, it opens the next change, which names the merged one in `detail.supersedes`. |
| `bay start <change>` | Pick up an existing change: checkpoint its draft and bring in newer main. bay reads the argument as a change when it is a change id whose record is here or on the remote; otherwise it is an intent. With `--new`, `--fixes`, `--lane`, `--from` or `--after`, it always opens a new change. An ended change whose intent has a newer change is refused with `CHANGE_SUPERSEDED`. |
| `bay start <intent> --input <kind>:<name>` | Name an input this change depends on, as `<kind>:<name>`. Repeat it for each input. |
| `bay start <intent> --new` | Open the intent's next change after its change ended without merging, such as by `bay abandon`, instead of adopting it. After a merge, a plain `bay start` already does this. |
| `bay start <intent> --fixes <change>` | Open a change that fixes a merged change. Its merge carries a `Fixes:` trailer. A target that never merged is `CHANGE_NOT_MERGED`. |
| `bay start <intent> --lane <name>` | Open a change in a sandbox lane that `[[lane]]` in `.bay/config.toml` names. Its checks run against main, and it never merges. |
| `bay start <intent> --paths <path>… --from <branch>` | Take in a branch made outside bay, by name or full ref. bay reads the remote's branch when the remote has one, even over a local branch of that name; `detail.from.source` says `remote` or `local`. If bay cannot list the remote, the start fails. Its net change on main is the first draft, and it merges as one checked commit. `--paths` must cover every file it changes, or bay creates nothing and refuses `PATHS_UNDECLARED`. A conflict with main is `MERGE_CONFLICT`. |
| `bay start <intent> --paths <path>… --after <change>` | Merge this change only after the named change merged. Repeat it for each change. The named change must exist on the remote, and need not have merged yet. Until each has merged, the operation waits with `DEPENDENCY_PENDING` and holds no queue place. The merge carries one `Bay-After:` trailer per named change. |
| `bay merge [<change>]` | Freeze the draft, build the candidate, and submit it. Without a change, it takes the change of the view it runs in; name one to submit from elsewhere. Returns at once; a runner checks and merges it. It lists open talks about the change, and never waits on them. |
| `bay merge --wait[=<seconds>]` | Run the merge here and return its outcome. With seconds, it watches only while the change waits in the queue for a runner, then returns `queued` with `state` `running`; any other result returns at once. |
| `bay abandon [<change>]` | Give up a change: without one, the change of the view it runs in. Another agent can then adopt its intent, or `bay start <intent> --new` opens a fresh change. |
| `bay save <intent> [--message <text>]` | Merge the edits in the main checkout as one change, with `bay merge --wait`, then move the checkout to the new main. Never in a view. The intent says what the work is, in a few words. Saving again with the same words goes on with its open change. Run bay without a runner explains it. |
| `bay waiting` | The changes waiting for an approval that this machine's approver, `bay.approverName`, can give. Each names its candidate, paths and a diff summary. Without an approver set, it lists every one, with who can approve it. It also lists each waivable check that did not pass, with its verdict and the command that waives it. |
| `bay approve <change> [--waive <check>] [--why <text>]` | Sign and push this machine's approval of the exact candidate the change waits on, one record per approval check its approver may answer. `detail.lands_by` names who merges it next: `executor`, `here`, `author`, or `author_repairs` after a no, and `say` follows it. `--why` keeps the reason in the record. `--waive` signs a waiver of one check marked `waivable` instead, never a pass; it needs `--why`. Require a person's approval explains it. |
| `bay reject <change> [--why <text>]` | Say no to a change that waits for an approval: sign and push this machine's no for its exact candidate. The change is blocked with `CHECK_FAILED`, naming you, and `detail.lands_by` is `author_repairs`. `--why` keeps the reason in the record. |
| `bay status [<change>]` | Current state, read from main and refs. `detail.guard.hook` says `on` when the config on main names an agent product, else `off`. A blocked operation names its next step. It lists red post checks and the open talks you sent or must answer. It also names main commits that did not merge through bay. It lists promotion targets, pending post checks and flaky checks, and warns `no_live_runner` when no runner is fresh. A finished operation shows `operation.timing`. A hold on main shows under `detail.hold`, and the flow of work under `detail.flow`. It warns `main_not_linear` for each merge outside bay in the `[red] window` that put bay merges off main's first-parent line. |
| `bay status --fleet` | Every machine and session that the local cache knows, from signed live summaries. It also lists red post checks, and marks each session or change on their paths. It names main commits outside bay, as `bay status` does. It lists the open talks this clone holds, and how many each session owes. It lists each runner's presence, and whether it is stale. It shows each machine's last `bay doctor` verdict, and `ready` counts the green and red machines. |
| `bay status --heads [--stale-days <days>]` | Every branch on the remote, classed `main`, `target`, `lane`, `merged`, `stale` or `unique`, from one `ls-remote` and one fetch. Under `detail.stranded`, it also names the local branches no remote-tracking ref holds, and worktree records whose folder is gone. It deletes nothing. |
| `bay status --lineage [<range>]` | The `Reverts:`, `Supersedes:` and `Fixes:` pairs on a first-parent range, main by default. It reads local refs only and never fetches. |
| `bay status --since [<cursor>]` | Merges into main, events and new invalidations after the cursor. Without a cursor, it reads from the start, and each answer names the next cursor in `detail.cursor`. It lists `main_red`, `main_red_cleared`, `outside_merge`, `main_not_linear`, `target_moved`, `target_unpromotable` and talk events, and `--merged` lists none of them. In a view, events are those that touch your change, plus your talks. |
| `bay status --since [<cursor>] --merged` | Only the merges after the cursor, each with its base and committer time. It marks a merge made outside bay, and counts commits off the first-parent line. A bay merge that such a merge put off that line is listed too, with `via` naming the merge. |
| `bay undo <change>` | A new change that reverses one merge. History before it stays. It returns at once, like `bay merge`, and lists open talks about it. |
| `bay undo <change> --wait[=<seconds>]` | Run the undo here and return its outcome, or watch it for that many seconds, as `bay merge --wait` does. A conflict returns `MERGE_CONFLICT` with the paths. |
| `bay talk <agent> <what> --kind delivery --change <change>\|--artifact <path> --follow-up <time>` | Ask another agent for something, through the shared remote. The recipient's own key acks it. |
| `bay talk <agent> <what> --kind discovery --decision <decision> --follow-up <time>` | Ask for a decision. A `reliability` talk names a `--check` instead. |
| `bay talk <agent> <what> --kind reliability --check <check> --follow-up <time>` | Ask for one named check to pass. |
| `bay talk … [--decides <agent>] [--context <text>] [--evidence <ref>] [--supersedes <id>] [--expires <time>] [--target\|--forecast\|--commitment <text>] [--owns\|--promises\|--needs\|--promise-evidence <text>] [--urgent] [--dry-run]` | Optional fields on any talk. The Talk page explains each one. |
| `bay talk --answer <id> --value <v>` | Answer a talk. `--supersedes` replaces your earlier answer. |
| `bay talk --to-change <change> <what> --kind <kind> --follow-up <time>` | Send a talk to the agent that merged a change, read from bay's record. Give the action alone, with no agent name. |

## Setup commands

A person or a setup script runs these commands. An agent does not run them in the daily loop.

| Command | Purpose |
|---|---|
| `bay install [--main <name>] [--remote <name>]` | Place bay at `~/.local/bin/bay` and copy the skill into every Claude config directory it finds. In a clone, also record main and its remote, and set Git to keep a commit-graph with Bloom filters. When main names an agent product, add bay's hook entries to the project's settings file; commit it and merge it into main once. Until then, its result says the file still has to merge. The Install page lists what each scope writes. |
| `bay install --machine` | Only the machine's part: the program and the skill. It never writes hook entries, a repository's agent settings or its Git config. |
| `bay help [<command>…] [--json]` | Every command, subcommand and flag on one page, grouped for people, agents and admins; with a command, that command's help. With `--json`: every command, flag, decision and reason code, and the JSON Schema of `bay.result/1`. Each command names what it writes and what it never writes. |
| `bay doctor` | Check this machine: the Git floor and the agent product's floor, the remote, hook entries, the skill in each Claude config directory, key custody, the check sandbox and the directories checks may read. In a repository set up for bay whose remote answers, it pushes its verdict for `bay status --fleet`. With no agent hook, the `hooks` row reads `off`, and turns red with `HOOKS_MISSING` when main holds merges that went around bay beside bay's own. |
| `bay enrol <class> <name> <key.pub>` | Add one public key to the key registry: append it to its class file, sign the new version with `bay.registryKey`, and push it. The class is `agent`, `executor`, `producer`, `reviewer`, `approver` or `registry`. The first key creates the registry, and must be the registry key itself. Keys and the registry explains it. |
| `bay enrol --remove <class> <name> --reason rotated\|revoked` | Remove every key of a name from its class, and record each in the registry's `removed` file with the reason. After `rotated`, the key's earlier records still count; after `revoked`, none does. The last `registry` key stays. Keys and the registry explains it. |
| `bay prune [--stale-days <days>]` | Delete the remote's branches merged by `ancestor` or `patch`, each archived on the remote first, with a restore line in `bay/pruned-heads.log`. A branch with commits off main and a tip older than `--stale-days`, 30 by default, is stale and stays. With `[plugin.prune] command` in the config, bay then runs that plugin once per deleted branch, and `detail.pruned` records its outcome. |
| `bay doctor --role executor` | Judge this machine as a runner, whatever role doctor infers. It uses the runner's Git floor, and checks the check variables and the runner's `executor` and `producer` keys. It also shows this runner's own presence and its last push. |
| `bay host init <path> [--main <name>] [--object-format sha1\|sha256]` | Create a bare repository on reftable whose main is append-only, and set Git's maintenance to keep a commit-graph with Bloom filters. Without `--object-format`, it takes the format of the repository it runs in, else Git's default. |
| `bay share <change> [--remote <name>] [--bundle <file>]` | Push a change and its evidence to a remote, all refs or none, or write them to a bundle. |
| `bay runner run [--once] [--interval-s <n>]` | Drive queued operations to an outcome on this machine. Between operations, a looping runner moves to the bay that `bay update` installed. Each pass also moves every `[[target]]` with `advance = true` to its newest promotable commit. |
| `bay runner install [--print] [--force]` | Write a service that keeps `bay runner run` running for this repository: a launchd agent on macOS, a systemd user unit on Linux. It prints the one command that starts it, and starts nothing. `--print` shows the file and writes nothing; `--force` replaces a file already there. |
| `bay evidence missing --kind <kind> --producer <producer> [--since <rev>]` | Main's first-parent commits that have no record of that kind from that producer. A record counts by commit, or by base tree and tree, never by tree alone. |
| `bay evidence record --kind <kind> --verdict <verdict> --commit <commit>… [--check <check>] [--environment <environment>] [--input <key>=<value>] [--body <file>] [--version <version>] [--why <text>]` | Write one signed record per `--commit` about a merge, as an outside producer or reviewer, or about a candidate commit, as an approver. A `review` is signed by `bay.reviewerKey`, an `approval` or a `waiver` by `bay.approverKey`, any other kind by `bay.producerKey`. The verdict has no default. `--why` keeps the writer's reason in the record. A `waiver` needs one, and its verdict is always `unknown`. Each run also pushes the writer's notes ref, so a record whose push failed goes out next time. `--environment` names the check's environment for a post check's record; without it, an approval takes its approval check's, and any other record names `any`. A rerun with the same content writes nothing, and a record over 64 KiB is refused. |
| `bay plugin test check\|wake\|notify [--timeout-s <n>] [--expected-minimum <n>] [--path <dir>] [--env <name>] [--with <path>] -- <command>…` | Run bay's contract cases against a check command or a plugin, as bay runs it. It changes nothing, and exits 0 only when every case passes. |
| `bay hook <moment>` | The agent hook entry point. The agent product calls it with a moment, such as `tool.before`, not the agent. `bay hook talk-wait` is the idle wait for a talk. |
| `bay host hook` | The main hook on a bay-hosted main branch. Git calls it, not a person. |
| `bay update [--version <x>] [--check]` | Install a newer bay release, even while a runner runs. `--check` reports what it would do and changes nothing. |

## What each command writes

Each command declares what it writes, and what it never writes. `bay help` shows both under each command, and `bay help --json` lists them as `writes` and `never`. This table is bay's own list, in bay's own words.

| Command | Writes | Never writes |
|---|---|---|
| `bay start` | the change's record and claim on the remote; its view: a separate folder under .git/bay/views | your checkout's files; main |
| `bay merge` | a request on the change's record; with --wait, the checks' evidence and the merge into main | your checkout's files |
| `bay abandon` | an abandon request on the change's record | the view's files; main |
| `bay save` | a change of the main checkout's edits, merged into main; the main checkout, moved to the new main | any branch of yours |
| `bay waiting` | only fetched copies of the remote's refs | your files; your branches; main |
| `bay approve` | your signed approval, or with --waive a signed waiver, pushed | the change itself; main |
| `bay reject` | your signed no, pushed: the change is blocked | the change itself; main |
| `bay undo` | a new change that reverses one merge, checked and merged like any other | history: nothing is rewritten |
| `bay status` | only fetched copies of the remote's refs, and bay's cache under .git/bay | your files; your branches; main |
| `bay prune` | deletes remote branches that already merged, each archived first and logged | a branch that has not merged; main |
| `bay talk` | one talk on the remote | your files; main |
| `bay enrol` | one key in the key registry, signed with this machine's registry key and pushed; with --remove, the name's keys out of its class and into the registry's removed file | main |
| `bay doctor` | bay's cache under .git/bay; this machine's readiness on its presence record on the remote, for bay status --fleet | your files; your branches; main; settings or hook entries |
| `bay install` | the bay program and its skill in each Claude config directory; in a clone, without --machine: bay's hook entries in the project's agent settings file and this clone's bay.* Git config | main: you commit and merge the settings file; with --machine: hook entries, a project's settings or its Git config |
| `bay host` | init: a new bare repository whose main only moves forward | an existing repository |
| `bay runner` | run: the merges of queued changes into main, with their evidence and its log; install: a service file that keeps it running | your checkout's files |
| `bay share` | a change and its evidence pushed to a remote, or written to a bundle file | main |
| `bay evidence` | record: one signed record, pushed; missing writes nothing | main |
| `bay update` | a newer bay program, verified before it replaces this one | anything in a project |
| `bay plugin` | nothing: it runs a plugin with test inputs | your files; main |
| `bay hook` | the session's record of what it did; a deny of the agent's action when bay refuses it | your files; main |
| `bay help` | nothing | anything |

## Global options

| Option | Effect |
|---|---|
| `-C <path>` | Run as if bay started in `<path>`, like `git -C`. |
| `--json` | Print only the `bay.result/1` document, also at a terminal, with no human text on stderr. |
| `--version` | Print the bay version. |

## The result

Through a pipe, or with `--json`, every command that reports state prints one `bay.result/1` JSON document on stdout. Then human text, if any, goes to stderr, and `--json` leaves it out. At a terminal without `--json`, stdout holds the plain sentence and no document. No command asks an interactive question. Unknown is never success, so an unknown check never exits `0`.

| Exit | Meaning |
|---|---|
| `0` | ok, merged, or accepted and queued. Only `merged` says the merge happened. |
| `10` | blocked: a policy or state refusal, with `allowed` and `retry_when`. |
| `20` | failed: an operation fault. A failed `bay merge`, `bay undo` or runner run also writes a receipt. |
| `2` | usage error: the message and usage go to stderr, with no result on stdout. |

> `bay hook <moment>` follows the exit protocol of the agent product instead.
> For Claude Code, exit `2` denies the tool call.

---

# Results and refusals

> What each field of a bay result means, and what to do after each refusal.

Source: https://getbay.io/docs/results (HTML).

Through a pipe, or with `--json`, every bay command prints one `bay.result/1` JSON document on stdout. Its `decision` says what happened. When bay refuses, `reason` names a fixed code and `allowed` lists what you can still do. `retry_when` says when a retry can succeed.

## The result document

A program, or `--json`, gets one `bay.result/1` document on stdout, except for plain help, `--version` and usage errors. At a terminal without `--json`, a person sees the plain sentence instead of the document. A consumer refuses a major schema version that it does not know.

```json
{
  "schema": "bay.result/1",
  "op_id": "c7k2m4qa.1",
  "decision": "blocked",
  "reason": "INPUT_CHANGED",
  "say": "Something this change relies on was updated (contract:api.orders.schema); read the new version, adjust, then save again.",
  "change": "c7k2m4qa",
  "detail": {
    "input": "contract:api.orders.schema",
    "observed": "v17",
    "required": "v18"
  },
  "allowed": ["read", "refresh", "repair"],
  "retry_when": "candidate_validated_against_required_versions",
  "receipt": "r-q4n8x2",
  "freshness": {"main": "a1b2c3d", "fetched_at": "2026-10-17T19:30:02Z"}
}
```

| Field | Meaning |
|---|---|
| `schema` | Always `bay.result/1`. |
| `op_id` | The operation id. |
| `decision` | `merged`, `queued`, `blocked`, `failed` or `ok`. |
| `reason` | A fixed reason code. Every `blocked` result has one, and a `queued` or `failed` result can have one. |
| `say` | One plain sentence for a person who does not know Git: what happened and the next step. Every result has one. |
| `change` | The change id. |
| `candidate` | The candidate id, when a candidate exists. |
| `state` | The state of the operation, such as `submitted`, `running`, `superseded` or `no_effect`. |
| `waiting_for` | What a queued operation waits for, such as `runner`, `remote`, `queue`, `hold` or `dependencies`. |
| `elapsed_s` | Seconds since the operation's request, while it is under way. `bay status` gives it for each operation. |
| `evidence` | The ids of the evidence records on the operation's candidates so far. |
| `detail` | Facts for this reason, such as the input and its observed and required versions. A merge and a check refusal add `trust`: `signed` with a registry, `approvals` under `[policy] signing = "approvals"`, else `unsigned`. |
| `allowed` | What you can still do, in four words: `read` what `detail` names; `refresh` with `bay start <change>`; `repair` in the view, then `bay merge`; or `wait`. An empty list means bay acts on its own. |
| `retry_when` | The condition under which a retry can succeed. |
| `next_step` | On every refusal: the one step to take next, in bay's words. |
| `receipt` | The id of the failure receipt. It records the step, the reason and the recovery. |
| `freshness` | The main commit the result reflects, and when this command fetched it. `start`, `merge`, `undo` and `status` carry it. Without a fetch, `fetched_at` says it read the local cache. |

## The plain sentence

Every result carries `say`: one plain sentence, for a person new to Git, with what happened and the next step. Its words never include, in any form, a word that `bay help --json` lists under `detail.words`. A name it quotes from `detail`, such as a path or a check, stays as it is. In a `failed` result, a quoted path holding one of those words reads "bay's records". bay builds it from `decision`, `reason` and a few `detail` fields, so it adds no new fact. At a terminal, bay prints it alone; through a pipe without `--json`, the text on stderr starts with it.

```json
{"schema": "bay.result/1", "decision": "blocked", "reason": "CHANGE_TOO_LARGE",
 "say": "This change is too big to save here (assets/intro.mp4 is 48.0 MiB, over 10 MiB); keep large files elsewhere, such as a shared drive."}
```

> A `failed` result without a `reason` says first that nothing changed, then why.
> In a `failed` result, a file that Git names at a commit reads as its path alone, such as `.bay/config.toml`.

### Words a person never sees

The plain sentence says the word in the second column instead of the word in the first. This table comes from `detail.words`, the one list bay's own check uses.

| bay never says | It says instead | What it means |
|---|---|---|
| `branch` | copy | A separate line of the project's history. bay keeps each change apart, so you never make one. |
| `commit` | saved version | One saved state of the files, kept in the project's history. |
| `merge` | combine | Putting two sets of edits to the same files together. |
| `trunk` | main | The one version of the project that everyone's work is added to. |
| `rebase` | update | Bringing a change up to date with main, so it starts from main's newest files. |
| `ref` | name | What bay calls one of the records it keeps in the project's history. |
| `view` | folder | The folder bay makes for one change, holding only the files it works on. |

## Ids

- A change id is lower-case base32, for example `c7k2m4qa`.
- An operation id is the change id and a number, for example `c7k2m4qa.1`.
- A candidate id adds the attempt, for example `c7k2m4qa.1.2`.
- An evidence id is the producer and an id, for example `ci-linux/ev901`.

## A queued merge

`bay merge` exits `0` when bay accepts the operation. `decision` `queued` then says that the merge has not happened yet. Only `merged` says it has. `state` says where the operation is. `waiting_for` says what it waits for, and `elapsed_s` counts seconds since the request. `evidence` lists the evidence records the operation has so far. Behind the head of the queue, `bay status` may show `operation.conflict`: repair those paths now.

```json
{"schema": "bay.result/1", "op_id": "c7k2m4qa.1", "decision": "queued", "state": "submitted",
 "say": "Saved. bay checks it and adds it to main next.",
 "change": "c7k2m4qa", "waiting_for": "runner", "elapsed_s": 0, "evidence": [],
 "freshness": {"main": "a1b2c3d", "fetched_at": "2026-10-17T19:30:02Z"}}
```

## Read the contract from bay

`bay help --json` lists every command, flag, decision and reason code of the bay you run. Each reason code carries its allowed actions, when a retry can work, and whether it is transient. It also carries the JSON Schema of `bay.result/1`, each agent product's hooks, and the word list under `words`. The bay repository keeps the same schema at `schemas/bay.result.schema.json`. bay reads all of it from its own program, so it always matches the version you run.

```sh
bay help --json | jq '.detail.reasons[] | select(.code == "INPUT_CHANGED")'
```

## Reason codes

Each refusal carries one code from a fixed table. A retry with no change in the cause gets the same code and does no work. Three codes are transient: `MAIN_MOVED`, `CHECK_UNAVAILABLE` and `HOOK_TIMEOUT`. bay retries those within a bound. Each next step below is bay's own `next_step`, the text `bay help --json` lists for that code.

| Code | Meaning | What to do next |
|---|---|---|
| `INPUT_CHANGED` | A declared input has a newer required version. | Read the new version `detail` names, run `bay start <change>`, adjust the view, then `bay merge` again. |
| `INPUT_UNOBSERVED` | You have not read the new version of an input yet. | Read the required version `detail` names, then run `bay start <change>` and `bay merge` again. |
| `MERGE_CONFLICT` | The draft does not merge onto main. `detail` lists the conflicting paths. A merge Git cannot read is a `failed` fault, never this code. While the change waits in the queue, `bay status` can show it early under `operation.conflict`. | Run `bay start <change>`, resolve the listed paths in the view, then `bay merge` again. |
| `MAIN_MOVED` | Main moved during the merge. | Do nothing: bay retries once more, then queues the change. `bay merge --wait` shows the outcome. |
| `MERGE_RETRY_EXHAUSTED` | Four uncounted retries ran out: `HOOK_TIMEOUT`, a ref lock held on the remote, or a deadline release during the push. | Read the cause in `detail` and tell the user; once it is fixed, `bay merge` again. |
| `REGISTRY_UNREADABLE` | The key registry breaks a rule, such as a removed key enrolled again, so bay cannot judge anything signed. `bay doctor` names the broken line in its `signing` row. | Tell the user the key registry breaks a rule (`detail`); a registry key repairs it, for a re-enrolled removed key with `bay enrol --remove`. |
| `REGISTRY_CHANGED` | The key of the merging runner was revoked after it signed the candidate. | Do nothing: a runner with a valid key signs it again. `bay status <change>` shows the outcome. |
| `CHECK_FLAKY` | One tree has both a `pass` and a `product_failure` for one check, and its `[[check]]` does not set `allow_flaky`. `detail.checks` lists every run. | Tell the user the check gives two answers for one tree; once it is fixed, `bay merge` again. Never quarantine it yourself. |
| `CHANGE_NOT_MERGED` | `bay undo`, `bay start --fixes` or `bay talk --to-change` names a change that is not on main. | Wait until the named change merges; `bay status <change>` shows its state. Then run the command again. |
| `TALK_REFUSED` | bay will not send the talk, and `detail.why` says why. The talk misses a field or has one of the wrong shape, or reuses an id with a different body. It repeats a closed talk, or supersedes a talk only its sender or `decides` agent may supersede. It is a second, different answer without `--supersedes`. Its sender is no agent, or a reviewer without a `review` record on that change. Or too many talks are open to this recipient. | Read `detail.why`, correct or supersede the talk as it says, then `bay talk` again. |
| `HOOK_TIMEOUT` | The main hook on the host ran past its time bound and refused the push. | Do nothing: bay retries after a short wait. If it keeps happening, run `bay doctor` and tell the user. |
| `QUEUED` | The operation waits its turn in the queue, and `waiting_for` says `queue`. `detail` gives its `position`, its `seq` and the `head` operation. On a host from `bay host init`, the host also refuses a push that would pass the head with it. | Keep working: the change waits its turn in the queue. `bay merge --wait` or `bay status <change>` shows the outcome. |
| `QUEUE_DEADLINE_EXCEEDED` | The progress deadline of the head released this operation three times. | Read `detail`, shrink or split the change in the view, then `bay merge` again; or ask the user to raise the budget. |
| `POLICY_MISSING` | The base has no `.bay/policy.toml`, so bay does not know which checks a merge needs. A policy bay cannot read is a `failed` fault that names the file, never this code. | Tell the user main has no `.bay/policy.toml`, so nothing can merge; set it up only if they ask. |
| `POLICY_INVALID` | The policy at the base, or the candidate's own policy, breaks a rule. A broken policy on main blocks every merge, its repair too; the repair merges when the registry key signs it. An example is no required check with `expected_minimum` above zero. An unknown key is named with its table and line. | Tell the user `.bay/policy.toml` on main is invalid, as `detail` says; its repair merges when signed by the registry key. |
| `CHECK_FAILED` | A required check failed on the candidate. `detail.checks` lists each failed check. Its `reasons` give each failed unit's `reason`, when the check sent one. With `baseline = "base"`, `reasons` names only new failures, and `baseline_failures` the pre-existing ones. | Read the failing check in `detail`, repair the change in the view, then `bay merge` again. |
| `CHECK_UNAVAILABLE` | No machine serves the check's environment, or the check answered `unavailable`. | Wait: bay retries once a worker can run the check. If `bay status --fleet` shows no live runner, tell the user. |
| `CHECK_UNKNOWN` | A check crashed, timed out, or collected nothing. A unit `reason` over 2 KB also makes the run `infra_failure`; bay never cuts it. | Wait for the check's result with `bay merge --wait`; never treat it as a pass. Repair the change if the check cannot answer. |
| `POLICY_CHANGED` | The acceptance policy changed after bay checked the candidate. | Run `bay start <change>`, then `bay merge` again: the change is checked under the current policy. |
| `WORKSPACE_INCOMPLETE` | The view is not `ready`. | Run `bay start <change>` to finish the interrupted step, then go on. |
| `MERGE_OUTCOME_UNKNOWN` | The push failed, and bay then could not read main. Whether the merge happened is unknown. | Do nothing yet: bay reads main again. Never report it merged; `bay status <change>` shows the outcome. |
| `MAIN_RED` | A post check failed on main, on paths this change touches. `detail.red` names each red check and the merge that owns it. `detail.owners` lists those changes. | Wait for the owner's fix to the red paths, or fix it yourself with `bay start <intent> --fixes <change>`. |
| `CHANGE_ALREADY_MERGED` | This change already merged with a different revision. | Do not retry: the change already merged. For more edits, `bay start <intent>` opens the next change. |
| `CHANGE_SUPERSEDED` | Another change holds or delivered this intent. Or `bay start <change>` names an ended change whose intent has a newer change, named in `detail.superseded_by`. Or, with `bay merge --wait`, another process on this machine ended this change's operation, with `detail.merged` `false`. | Read the change that took over in `detail`; work in it, or `bay start` again once it merges or is abandoned. |
| `ABANDON_REFUSED` | An actor that does not hold the change sent an abandon request. | Only the change's own actor may abandon it: ask that actor, or leave the change as it is. |
| `TOOL_BELOW_FLOOR` | A tool is older than bay needs, and `detail.tool` names it: `git`, `claude` or `codex`. For `git`, Git is below the floor for the machine's role: `bay host init` needs 2.54, and `bay runner run` 2.50. For `claude` or `codex`, the product is older than its floor, 2.1.302 or 0.153.2. `bay doctor` turns the matching row red with it. | Update the tool `detail.tool` names to `detail.floor`, or remove its `[agent]` product, then run the command again. |
| `CONFIG_MISSING` | bay cannot tell main, or main has no `.bay/config.toml`. It names where it looked. | Tell the user bay is not set up here; commit `.bay/config.toml` to main only if they ask. |
| `COVERAGE_UNKNOWN` | Coordination data is out of reach. Only isolated work can go on. | Keep editing; `bay merge` again once coordination data is reachable. |
| `REMOTE_NOT_FETCHED` | `bay status --fleet` has no fetch of the configured remote. | Check the network and the remote, then run the command again; `bay doctor` names what is wrong. |
| `CHANGE_HELD` | Another live session with your actor name holds this change, so `bay start` does not adopt it. `detail` names that session and machine. | Another live session holds this change: wait until it ends, or work on another change. `bay status --fleet` shows the holder. |
| `ACTOR_UNNAMED` | The policy sets `[agent] require_actor = true`, and your actor name came from a default, not `BAY_ACTOR`. | Set `BAY_ACTOR` to your actor name, then run the command again. |
| `ACTOR_UNPROVEN` | The policy sets `[agent] require_actor_proof = true`, and the agent key cannot show which agent signs. Several agents share this OS account and can read its file key, or bay cannot judge the key. `detail` names `actor_proof` and the `actors`. | Tell the user policy needs proof of the actor: a hardware agent key, or one OS account per actor. |
| `HOOK_INPUT_INVALID` | The `tool.before` hook could not read the agent product's payload, so it denied the tool call. Or a tool call reached `bay hook` under a moment name bay does not know, or a `tool.before` call under another moment's name. `detail.why` says which, and `detail.misnamed` is `true` for a wrong name. | Read `detail.why`: a hook entry under a wrong name needs `bay install`; a payload this bay cannot read needs `bay update`. |
| `SHELL_DENIED` | The `tool.before` hook denied a shell command on bay's deny list, such as `git reset --hard` or a force push. The agent reads the code, why, and the next step on stderr. | Do not run that command again; it is never allowed. Ask the user if the work truly needs it. |
| `PATH_HELD` | Another live session in this same tree holds the path you tried to write; other views never meet that hold. Or another session, on any machine, holds the lock of a path `[locks] paths` names. The refusal names the holder. | Another session holds the path: wait until it stops, or edit other paths. |
| `OUTSIDE_MERGE` | `bay talk --to-change` names a main commit that no runner signed as a bay merge, so bay has no record of its agent. | Do not retry: that commit did not merge through bay, so it names no author bay knows. Ask whoever made it. |
| `EVIDENCE_WITHDRAWN` | A bay-hosted main branch refused a push of evidence notes that drops or changes an earlier record. | Do not retry: an evidence record is never removed or changed. Push notes that keep every record. |
| `PROMOTION_REFUSED` | A host from `bay host init` refused a push that moves a promotion target to a commit that breaks its rule, or deletes the target. The message names each broken rule. | Push the commit `bay status` names promotable for that target, or wait until one is. |
| `LANE_LEAK` | The history of a change outside any lane reaches lane commits that are not on main. `detail.leaks` names the lane refs and commits. | Redo the work without the lane commits `detail` names, or start it as a lane change: `bay start <intent> --paths <paths> --lane <lane>`. |
| `APPROVAL_PENDING` | Every other check passed, and an approval check on these paths has no yes yet. `detail.approval` names who can approve and the candidate commit. A check's `set_aside` names an earlier approval whose files changed. | Tell the user who can approve (`detail.approval`); the approver runs `bay approve <change>`. Then the runner merges it, or `bay merge --wait`. |
| `WIP_LIMIT` | From `bay start`: the policy sets `[agent] max_live_changes`, and you hold that many live changes, named in `detail.live_changes`. From `bay merge`: `[limits] max_waiting_approvals` changes already wait for a yes, named in `detail.review`. | Merge or abandon one of the changes `detail` names (`live_changes`, or `review.oldest`), then run the command again. |
| `DISK_LOW` | This machine has less free space than its `bay.minFreeMiB` floor, so `bay start` made nothing. `detail` names `free_mib`, `floor_mib` and the `path`. | Tell the user to free disk space on this machine, or lower `bay.minFreeMiB`, then run the command again. |
| `MAIN_HELD` | Main is held by a `[[hold]]` of main, the hold plugin, or a config bay cannot read at main's tip, and this change carries no `Fixes:` or `Reverts:`. The operation stays `queued`, and `detail.hold` names the hold. | Wait: the change stays queued and merges once the hold clears; `bay status` shows the hold. Only fixes and reverts merge now. |
| `PATHS_UNDECLARED` | `bay start --from` names a branch that changes paths `--paths` does not declare. bay creates nothing. `detail.uncovered` names those paths. | Run `bay start <intent> --from <branch>` again, with `--paths` covering every path in `detail.uncovered`. |
| `DEPENDENCY_PENDING` | The change was started with `--after`, and a change it names has not merged yet. The operation stays `queued` and holds no queue place. `detail.pending` names each one. | Wait: the change merges after each change it names with `--after` merges; `bay status <change>` shows them. |
| `DEPENDENCY_ABANDONED` | A change named by `--after` was abandoned or superseded. `detail.ended` names it. This change can never merge as it is. A named change that ends `no_effect` counts as merged. | Run `bay abandon`, then `bay start <intent> --new` without that `--after`; or restart the dropped change. |
| `CHANGE_TOO_LARGE` | The change is over a `[limits]` cap of the policy at its base. `detail.exceeded` names each cap, its `max`, the `value` and the largest `paths`. bay refuses it when you submit, or at the candidate build, before any check runs. | Keep large files elsewhere or split the change, then `bay merge` again. Never raise the limits to get it through. |

## Work already on main

Sometimes the merged candidate has the same tree as main. Another change merged the same bytes, or the change's edits cancel out. Then bay merges nothing and runs no check. The result is `ok` with `state` `no_effect`, and exits `0`. `detail.main_commit` names the main commit that already holds the work. Another agent can adopt that change at once, as after an abandon.

```json
{"schema": "bay.result/1", "op_id": "c7k2m4qa.1", "decision": "ok", "state": "no_effect",
 "say": "Nothing new to add; main already has these edits.", "change": "c7k2m4qa",
 "detail": {"main_commit": "a1b2c3d", "tree": "9f8e7d6"}, "freshness": {"main": "a1b2c3d", "fetched_at": "2026-10-17T19:30:02Z"}}
```

## Open talks on a change

`bay merge` and `bay undo` can carry `detail.open_talks`: open talks to you about that change. Talk to other agents explains its rows.

## Merges outside bay

`bay status`, the fleet map and `bay status --since` name main commits that did not merge through bay. See what your fleet is doing explains each field. An `outside_merge` event names the `commit`, its `merged_as` and its `subject`. Its `id` names the commit, so one merge is always one event.

## Refusals after the agent left

Some refusals arrive after the agent has left. The runner writes each one as a `refused` event for the actor of the change. The agent hook delivers it to a live session, an idle session, or the next session of that actor. With a `[plugin.wake] command`, the runner also wakes the author's session once. Talk to other agents explains it.

## Doctor codes

`bay doctor` uses its own codes. `reason` names the first red row. The `store` row advises on the object store and its commit-graph, and never turns doctor red. The `hook-overrides` row lists other tools' hooks for bay's events under `added`. Those hooks run beside bay's, so they never turn the row red. On a bare repository, the `ref-format` row is `degraded` on the `files` ref format.

| Code | Meaning | What to do next |
|---|---|---|
| `TOOL_ABSENT` | `[agent] product` names an agent product that is not installed here. `detail.tool` names it: `claude` or `codex`. | Tell the user to install the tool `detail.tool` names, or remove its `[agent]` product, then run `bay doctor` again. |
| `REMOTE_UNREACHABLE` | The remote did not answer with the key of this machine, or no remote is set. Or, before an edit of a path `[locks] paths` names, the remote did not answer, so bay cannot tell who holds its lock. | Check the network and this machine's key for the remote, then run `bay doctor` again. |
| `HOOKS_MISSING` | A hook entry that bay needs is absent from the project agent settings. When main tracks that file, the row's `recovery` names merging the change that adds the entries. Or `.claude/settings.local.json` or the user settings file sets `disableAllHooks` or `allowManagedHooksOnly`, or cannot be read. On a host, the main hook is missing. Or no agent hook is set, and main holds merges that went around bay: `detail.guard.around_bay` counts them. | Do what the hooks row's `recovery` says: merge the tracked settings file, or run `bay install`. |
| `SKILL_MISMATCH` | The bay skill is missing or does not match this bay version. It lives in `~/.claude/skills/bay/`, or under `$CLAUDE_CONFIG_DIR`. | Run `bay install` to copy this version's skill, then `bay doctor` again. |
| `SANDBOX_ABSENT` | This machine runs checks, and it has no check sandbox. | Tell the user to install the sandbox `detail` names, or set the runner's sandbox to none. |
| `CHECK_ENV_UNSET` | With `--role executor`, a check in the policy names an environment variable that is not set here. | Tell the user to set the variables `detail` names for the runner, then run `bay doctor --role executor` again. |
| `RUNNER_KEY_MISSING` | With `--role executor` and a registry, the runner's `executor` or `producer` key is not set or not enrolled. An unreadable registry also returns it. | Tell the user to set and enroll the runner's `executor` and `producer` keys, then run `bay doctor --role executor` again. |
| `KEY_EXPOSED` | A signing key file, or a token beside it, is readable by the account that runs agent sessions. | Tell the user to move the key `detail` names onto hardware, or out of the account agent sessions run as. |

> The `key-custody` row lists `actors`, and `actor_proof` says what a signature tells about who signed.
> Several actors under one account that can read a file agent key make that row `degraded`.
> `KEY_EXPOSED` still turns it red.
> The `gc-log` row is `degraded` while a `gc.log` stops Git's automatic garbage collection.
> Without an agent hook, the `hooks` row reads `off`. Security and privacy says what bay then guards.
> The Update page lists the refusals of `bay update`.

---

# Changelog

> What changed in each bay release, newest first.

Source: https://getbay.io/docs/changelog (HTML).

Every release is signed, and the install script verifies it. Version numbers are 0.x, so a minor release can still change a command. Each change is listed here.

## 0.3.7

- `bay save` and `bay start` take what the work is, in a few words; a card id still works.
- Saving again with the same words goes on with the same change.
- Each entry of `bay help --json`'s word list now says what it `means` and which names to `avoid`.
- Plain sentences lead with the effect: "Nothing was added: a check did not pass."
- A refusal without a code says "Nothing was changed:" and why, not "Something went wrong".

## 0.3.6

- `[locks] paths` in `.bay/policy.toml` names files that never merge, such as images or binaries.
- Each such file is edited in one session at a time, across machines.
- The first edit takes its lock on the remote; another machine's edit is refused `PATH_HELD`, naming the holder.
- A lock ends when its session stops, or after the takeover window.
- Offline, an edit of a locked path is refused `REMOTE_UNREACHABLE`.
- A runner never moves to a new bay that lacks its command; it keeps running and says why.

## 0.3.5

Results print the new words everywhere; scripts that read the old field names need the new ones.

- `freshness.trunk` is `freshness.main`; `detail.trunk` and `trunk_*` fields say `main`.
- `landed`, `landing` and `landings` fields say `merged`, `merge` and `merges`; `published` is `is_merged`.
- A change's state `landed` and an operation's `published` print as `merged`.
- `waiting_for` says `runner` and `runner_capacity`; `executors` in the fleet view is `runners`.
- Events `trunk_red`, `trunk_not_linear` and `unattributed_landing` are `main_red`, `main_not_linear` and `outside_merge`.
- The notice `landed_on_your_paths` is `merged_on_your_paths`; the warning `no_live_executor` is `no_live_runner`.
- `bay status --heads` classes main as `main` and merged branches as `merged`; doctor's `trunk-hook` row is `main-hook`.
- A completion profile's dimension `published` is `merged`, in results and in `[profile.<name>] require`.
- Notes, event ids and the hook's notice text say merge, main and runner too.
- The key class keeps its name, `executor`, wherever it is printed, as `bay enrol executor` takes it.
- Records already stored in Git keep their names and are still read.

## 0.3.4

- The README is rewritten for a newcomer: one word per idea, one branch, no agent branches.
- `bay help`, install and refusal texts say merge where they still said land.

## 0.3.3

- `bay enrol` refuses a key that was removed, under any name, before it signs or pushes anything.
- `bay doctor` is red, `REGISTRY_UNREADABLE`, when the key registry breaks a rule, and names the repair.
- `bay enrol --remove` repairs a registry that enrols a removed key again.
- Fixed: on a remote that is not a bay host, 0.3.2 could push a registry no bay command could read.
- The config names the branch bay serves `main = "<branch>"`; `trunk` is an unknown key now.
- Git config `bay.main` replaces `bay.trunk`. A bay host without it guards the branch its `HEAD` names.
- The skill and README say main is the one branch you name, and agents never create branches.
- They show a `dev → staging → production` repository: bay merges into `dev` and promotes along the chain.

## 0.3.2

- `bay enrol --remove <class> <name> --reason rotated|revoked` removes a key, and records why.
- A rotated key's records made before its removal still count; a revoked key's records count nowhere.
- The host refuses a registry version that undoes a removal, and a removed key is never enrolled again.
- A plain sentence names a broken file by its path, such as `.bay/config.toml`, not as bay's records.

## 0.3.1

- Every help text, next step, doctor note and the skill say merge, main, runner, plugin and machine.
- `bay help` shows values you may leave out, as `--wait[=<SECONDS>]`; `help --json` marks them `value_optional`.
- `bay help` names each argument by its placeholder, such as `<PUBLIC_KEY_FILE>`.
- `bay share` and a talk's send push all their refs or none.
- An unchecked merge no longer counts as a check run in `detail.flow`.
- A config may no longer name the bay it needs: `bay = "<version>"` is an unknown key now.
- A config that names another main says to run `bay install --main`.
- A shell command the hook denies names `SHELL_DENIED` and its next step.
- Every recorded agent payload is held to the hook contract: exits, reasons and time.
- `POLICY_INVALID` and the skill say how to repair a broken policy: a merge signed by the registry key.
- The skill says each agent sets `BAY_ACTOR` to its enrolled name under full signing.

## 0.3.0

One name per idea. Old names are removed, not aliased, so update scripts and settings files together.

- `bay land` is `bay merge`; the decision `landed` is `merged`.
- `bay watch` is `bay status --since [<cursor>]`; `--landed` is `--merged`.
- `bay executor` is `bay runner`, and `[executor]` in the config is `[runner]`.
- `bay adapter test` is `bay plugin test`; plugins are configured as `[plugin.<kind>] command, timeout_s`.
- `bay approve --reject` is `bay reject <change>`; `--trunk` is `--main` on `bay install` and `bay host init`.
- Gone: `start --resume`, `land --abandon`, `--observe`, `--intent-revision`, `status --stranded` and `status --heads --prune`.
- `[[freeze]]` is `[[hold]]`; a hold with no `target` holds main.
- Hook moments are renamed, such as `tool.before` and `turn.end`; `bay install` rewrites the entries.
- `bay doctor` names hook entries an older bay wrote, under `old_names`; a tool call under an old name is refused.
- A tool call refused under a wrong hook name says to run `bay install`, not to update bay.
- Reason codes say main and merge: `MAIN_MOVED`, `MAIN_RED`, `MAIN_HELD`, `CHANGE_NOT_MERGED`, `OUTSIDE_MERGE`, `RUNNER_KEY_MISSING`.
- `QUEUED` replaces two queue codes; `TALK_REFUSED` replaces seven talk codes, with `detail.why`.
- `TOOL_BELOW_FLOOR` and `TOOL_ABSENT` name the tool in `detail.tool`: git, bay, claude or codex.
- A lane change's checks end `ok` with the verdicts; it still never merges.
- Results no longer carry `published` or `next`; `receipts` is `evidence`.
- A throughput lever turns evidence reuse on; `evidence_reuse` is needed only for reuse alone.

## 0.2.17

- `bay status --fleet` shows each machine's last `bay doctor` verdict, and `ready` counts the fleet's green and red.
- `bay doctor` publishes this machine's verdict on its presence record, so the fleet view needs no ssh.
- `unattributed` counts the landings that went around bay per writer, under `by_writer`.
- `detail.flow` adds queue wait and check time, p50 and p90, from the records bay keeps.
- A config may name the bay it needs, `bay = "<version>"`; an older bay then says to update.
- `[policy] reuse_unsigned = true` lets unsigned evidence be reused and batched; results still name their trust.
- `bay install` and `bay update` put the skill in every Claude config directory found; doctor checks each one.
- `git config --global --add bay.claudeConfigDirs <dir>` names a config directory bay cannot find itself.
- `bay help` and `bay help --json` say what each command writes, and what it never writes.
- The hook refuses a force push spelled as a short-flag cluster, such as `-fu` or `-vf`, not only `-f`.
- A tool call that reaches `bay hook` under an unknown or wrong name is refused, never let through.
- `bay install` in a clone says when its hook entries still have to land on main.
- Doctor's and `HOOKS_MISSING`'s recovery name landing the settings file; `bay save`'s repair says `bay start <change>`.

## 0.2.16

- `bay help --json` declares the Codex adapter too: its events, timeout, and that its hooks fail open.
- Doctor's `off` hooks row names both agent products, `claude` and `codex`.
- A refused enrolment says "The approver name is a short lower-case word", not "A approver".
- `bay install --machine` says what it writes, and that it never writes hook entries or a project's settings.
- With a tracked `.claude/settings.json`, doctor's hooks recovery names landing the change that commits bay's entries.
- The skill gives the order for rolling bay's hooks out to many machines.

## 0.2.15

- One word list: `bay help --json` exports the words a person never sees, and the word said instead.
- A plain sentence says "main" for the trunk, never "the shared copy"; help text says main too.
- At a terminal, bay shows the plain sentence first and no JSON; a pipe or `--json` gets the document.
- `bay --help` is the same grouped page as `bay help`.
- "view" joins the words a person never sees; it is said as "folder".
- `bay help` shows `bay update --version`; next steps say `bay start <change>`, the one verb.
- A test budgets bay's commands, flags, reason codes and plain words; growth must say what it simplifies.

## 0.2.14

- `bay help --json` declares each agent adapter: its events, which can block, how each fails, and what it cannot see.
- An executor outcome the author must act on wakes them once through `[wake] command`; `land --wait` wakes nothing.
- `bay.wake/1` gains `cause`: `talk` or `outcome`; talk requests keep every field they had.
- `[agent] product = "codex"`: bay reads Codex's hooks as a sensor; they fail open, so main stays the guard.
- `AGENT_ABSENT` and `AGENT_BELOW_FLOOR` name a missing or old agent product other than Claude Code.
- `bay status` names the flow: live changes per actor, landings, cycle time, the oldest change and the approval queue.
- `[limits] max_waiting_approvals` caps how many changes wait for a yes; a change needing one is refused `WIP_LIMIT`.

## 0.2.13

- `bay help` shows every command, subcommand and flag on one page, grouped for people, agents and admins.
- `bay enrol <class> <name> <key.pub>` adds one key to the key registry; no Git plumbing is needed.
- The skill has an agent-led section for setting up bay for a team.
- `bay start <change>` picks up an existing change; `--resume` still works.
- `bay abandon [<change>]` gives a change up; `bay land --abandon` still works.
- `bay prune` deletes landed branches; `bay status` never deletes, and `status --heads --prune` still works.
- `bay status --heads` also names stranded local branches and worktrees whose folder is gone.
- After `bay approve`, `say` names who adds the change next; it no longer says nothing else is needed.
- A plain sentence names a Git path as "bay's records", never as a path that does not exist.
- `bay doctor` no longer calls a repository with no agent hook green; its hooks row reads `off`.
- Doctor is red when a trunk bay lands on also holds landings that went around bay, and counts them.
- `bay status` names `guard.hook`, and `unattributed` counts the landings that came `through_bay`.

## 0.2.12

- A land that loses a race on its change chain reads the chain again; it no longer fails.
- A resubmit while an executor records an attempt is now queued, with one request, never two.

## 0.2.11

- A check marked `waivable = true` can be waived on one candidate by an enrolled approver.
- `bay approve <change> --waive <check> --why <text>` records a signed waiver; a reason is required.
- `bay waiting` lists each waivable failed check with its real verdict and the command that waives it.
- A waiver is never a pass: the landing names it in `coverage_gaps` with the real verdict.
- Targets such as `staging` never count a waiver toward promotion.
- A refused check now shares its evidence, so an approver on another machine reads the real verdict.

## 0.2.10

- Under `signing = "approvals"`, a second machine now lands; it no longer fails with "producer local collides".
- A clone with no `bay.producerName` takes its own, `local-<id>`, and keeps it in its git config.

## 0.2.9

- Every refusal names its next step in `next_step`; `bay help --json` lists each reason's next step.
- `bay status` names the next step of a blocked operation.

## 0.2.8

- Reading a change's chain from the remote never moves local work back; no command forces it.
- Several executors on one queue no longer fail with "the change chain kept moving".
- A local chain head the remote moved apart from is set aside under `refs/bay/kept/`, never lost.
- A process's own chain writes the remote never took are taken back when it finishes.
- After a candidate moves, `bay approve` says the answer still holds while the approved files are unchanged.
- `--wait=SECONDS` watches only while the land waits in the queue for an executor; other results return at once.

## 0.2.7

- An approval now holds while the files it covers are unchanged; unrelated landings no longer ask again.
- A landing names each approval it carried from an earlier candidate under `carried_approvals`.
- When gated files change, `bay status` and the refusal name them under `set_aside`.
- `[[check]] carry = false` asks for an approval on every candidate.
- `allowed` now uses four words: read, refresh, repair, wait.
- `bay start <intent>` after a landing opens the next change; `--new` is no longer needed there.
- `bay land --wait=SECONDS` and `bay undo --wait=SECONDS` watch that long, then return while the run continues.
- `bay land <change>` takes the change positionally; `--change` and `--observe` still work.

## 0.2.6

- Without a key registry, an approval now blocks promotion and status the same way it already blocks landing.
- bay stops with an error when Git cannot find a commit, instead of treating it as not an ancestor.
- Policy reads file names with spaces or non-ASCII characters correctly.
- A hung adapter is killed with every process it started, never leaving children behind.
- `say` repeats a message's first sentence whole or not at all, so it never flips meaning.
- After `bay approve`, with an executor running, bay says the executor's next pass lands it.
- 0.2.5 was never released; its changes below arrive with 0.2.6.

## 0.2.5

- `bay waiting` lists the changes waiting for your approval, with candidate, paths and a diff summary.
- `bay approve <change>` signs and pushes your approval of the exact pending candidate.
- `bay approve --reject --why <text>` records a signed no; the change is blocked, naming you.
- Once a person approves a change, the executor's next pass lands it; nobody runs `bay land` again.
- If main moved after the approval, the change is rebuilt and waits for approval of the new candidate.
- Every result now carries `say`: one plain sentence saying what happened and what to do next.
- A policy `[limits]` table caps file size, files touched and added lines per change.
- A change over a limit is refused `CHANGE_TOO_LARGE` before any check runs.
- Checks can read box directories named with `bay.checkReadPath`, read-only; `bay doctor` lists them.
- `bay executor install` writes a launchd agent or systemd user unit for the executor, and starts nothing.
- Agents hear once when changes are queued and no executor is running.
- The skill tells agents how to work with people who are not developers: save, waiting, approve, undo.
- A replaced evidence record, such as an unsigned copy followed by the signed one, now counts as a change.

## 0.2.4

- `bay save <intent>` lands the edits made in the main checkout as one change.
- After a save lands, the main checkout fast-forwards to the new main when nothing is in the way.
- A refused save leaves the main checkout's files exactly as they were, and names the change if one opened.
- `[policy] signing = "approvals"` signs only approvals: small teams land without agent, executor or producer keys.
- Under approvals-only signing, landings and check evidence run unsigned and results say `trust: "approvals"`.
- Under approvals-only signing, evidence reuse and batching are off, because no producer signs.
- An approval still counts only when an enrolled approver's key signed it.
- `bay doctor` shows the signing mode.
- A dependency that ends `no_effect` is met: its dependent lands, never `DEPENDENCY_ABANDONED`.
- A check reading history with `baseline = "base"` now matches its base record by commit, never by tree.

## 0.2.3

- `bay start --after <change>` lands a change only after the named changes landed on main.
- A dependent change waits queued with `DEPENDENCY_PENDING` and holds no queue place meanwhile.
- A dependency that ends without landing refuses its dependent with `DEPENDENCY_ABANDONED`.
- The dependent's main commit names each dependency in a `Bay-After:` trailer.
- `[executor] parallel_checks = N` runs up to N of a candidate's checks at once; default 1.
- A check may set `materialize = "changed"` to see only the files a change touched, plus its `paths`.
- Such a check's request says `"materialized": "changed"`, and its evidence is keyed by commit.
- `bay status --heads --prune` runs an optional `[heads] on_prune` adapter after each delete, from trunk's copy.
- A failing prune adapter never undoes the prune or stops it; each branch records the adapter's outcome.
- `bay start --from <branch>` takes the remote's tip when it has one, and fails if the remote cannot be listed.
- A `--from` branch touching undeclared paths is refused as `PATHS_UNDECLARED`, naming them.
- bay finds landings that an outside merge pushed off trunk's first-parent line, by ancestry.
- Red judgement, culprits and targets read that landing line; undo finds such a landing by ancestry.
- `bay status` warns `trunk_not_linear` for such merges in the red window; `bay watch` names each new one once.

## 0.2.2

- A check request now names its base commit, the change, its actor and the change's commit messages.
- A check declaring `inputs = ["history"]` reads repository history through a temporary, read-only Git directory.
- History-reading checks are keyed by commit, so their evidence never stands for another commit.
- `bay start --from <branch>` takes in a branch made outside bay; it lands as one checked commit.
- `bay status --heads` lists every remote branch as trunk, target, lane, landed, stale or unique.
- `bay status --heads --prune` archives each landed branch under `refs/bay/archive/heads/`, then deletes it under a lease.
- Only branches shown merged by ancestry or equal patches are pruned, never by Change-Id alone.
- A `[[freeze]]` naming the trunk holds it: only fixes and reverts land until its end.
- A `[hold] command` adapter can hold the trunk; an unreadable answer counts as held.
- Held changes wait queued with `TRUNK_HELD`; `bay status` shows the hold in `detail.hold`.
- A check with `baseline = "base"` fails a change only on failures its base does not share.
- Pre-existing failures land named under `coverage_gaps` as `baseline_failures`, never as a pass.
- After `bay land --abandon`, `bay start <intent> --new` claims the next generation, superseding the abandoned change.
- `bay start --resume` refuses an abandoned change once a newer generation of its intent exists.
- Re-opening a change with plain `bay start` reports `behind`, the trunk commits it missed, with a hint to resume.

## 0.2.1

- A land whose `[executor] command` cannot start or refuses now ends `failed`, and nothing lands it later.
- That failure names the executor command and its reason, never `git`.
- The agent hook now denies a `git push` to any promotion target, in every refspec form.
- A `[[target]]` with `advance = true` is fast-forwarded by the executor to its newest promotable commit.
- A live change's session hears once when another change lands on its declared paths, and should refresh.
- `bay start` refuses `WIP_LIMIT` when an actor already holds `[agent] max_live_changes` live changes.
- Below the box's `bay.minFreeMiB` floor, `bay start` refuses `DISK_LOW`; a check there ends `infra_failure` naming it.
- `bay doctor` names a `gc.log` that blocks automatic garbage collection.

## 0.2.0

### Scale

- An executor pass costs the same however many closed changes and finished queue places the repository holds.
- After the retention window, the executor removes finished queue places below the head, always keeping the highest.
- Terminal records and `bay status` show an operation's submit, queue, head, check and publish times.

### Red trunk

- `[red] window = N` in `.bay/config.toml` sets how many trunk commits red and unattributed judgements read.
- A post check unanswered in that window shows as `red_pending`, never as clear; it refuses no landing.
- A red post check names its `suspect` range: the last pass, the first failure, and the landings between.
- `[executor] bisect_post = true` bisects a red post check's suspect range, recording each run as post evidence.
- The bisect reruns the suspect once to rule out a flake, in at most log2(n) + 1 runs.
- A red pinned to one landing names it `culprit` with each run's verdict; it owns the red and `TRUNK_RED`.

### Promotion

- `[[target]]` in `.bay/config.toml` names promotion targets such as `staging`, with required checks and a soak.
- `[[freeze]]` holds a target until its end; meanwhile only a `Fixes:` or `Reverts:` landing is promoted.
- `bay status` lists each target's tip, drift, lag, checks, freeze and newest promotable commit under `detail.targets`.
- `bay watch` names a target that moves as `target_moved`, or `target_unpromotable` when the commit fails its rule.
- A bay host refuses a push that moves a target to an unpromotable commit, with `PROMOTION_REFUSED`.

### Lanes

- `[[lane]]` in `.bay/config.toml` names sandbox lane refs, such as `refs/heads/research/*`.
- `bay start --lane <name>` opens a lane change: its checks run against trunk, and it never lands.
- A lane change's land is refused `LANE_NEVER_LANDS`, carrying its check verdicts; the host hook refuses it too.
- A change whose view reaches a lane's commits off trunk is refused `LANE_LEAK`, naming lane and commits.
- The agent hook denies merge, cherry-pick, checkout and restore from a lane ref in other views.
- `bay status`, the fleet map and `bay watch` name a forge merge of lane work as `lane_leak`.
- A lane change's view is released after the lane's shorter `window_s`.

### Approval

- A `[[check]]` with `kind = "approval"` and `when_paths` needs a person's signed yes for those paths.
- A new `approver` key class in the registry signs `approval` records, and nothing else.
- An approver runs `bay evidence record --kind approval` on the exact candidate commit a land names.
- A change on those paths is held with `APPROVAL_PENDING` until an enrolled approver's record arrives.
- `bay status` and the refusal name who can approve and the candidate commit to approve.
- A rejection blocks the change as `CHECK_FAILED`, naming the approver.
- An approval signed by any other key never counts, and the host refuses it.
- Without a key registry, no approval counts, and the refusal says so.
- A change elsewhere lands without an approval; quarantine sets an approval check aside like any check.

### Actor proof

- `[agent] require_actor_proof = true` in `.bay/policy.toml` refuses land `ACTOR_UNPROVEN` when actors share an agent key.
- The remedy is a hardware agent key, or one OS account per actor. Off by default.
- `bay doctor`'s key-custody row shows `require_actor_proof`, and whether land is refused on this box.

### Overlaps

- `bay start` lists live changes declaring overlapping paths in `detail.overlaps`, with intent, actor and paths.
- Session start names the same overlaps for the view's change; neither refuses.
- A queued change's early merge conflict reaches its session's agent once, with the paths.

### Throughput

- `[executor] ahead = N` checks queued entries ahead of their turn, on the bases the entries before them predict.
- An entry whose prediction held lands on that evidence with no new check run; a missed prediction is ignored.
- `footprint = true` on a policy check carries its record to a later candidate whose read set is byte-identical.
- `[executor] footprint_lanes = true` checks a queued entry beside the head when their paths are disjoint.
- Landings stay in queue order; a carried record names the record it was carried from.
- `[executor] batch = { min, max, max_failure_rate }` checks the queue head and entries behind it as one tree.
- A passing batch lands in one push; each member keeps its own commit, `Change-Id` and `Bay-Batch` trailer.
- A failing batch bisects on the same tip, lands its passing prefix, and checks the culprit alone.
- No batch forms while the lane's recent failure rate is above `max_failure_rate`, default 0.3.
- A bay host admits a `Bay-Batch` commit only within a whole batch under a config that sets `batch`.

### Flaky checks

- `retry_on_failure = N` in a `[[check]]` reruns a failed check on the same tree, up to N times.
- A failure then a pass is `flaky`: every run stays in evidence, never a plain pass.
- `allow_flaky = true` lets a flaky check land, named in `coverage_gaps` with its runs; otherwise `CHECK_FLAKY`.
- `[[quarantine]]` takes `owner` and `since`; `bay status` lists each with owner, age and overdue.
- `bay status` counts each check's flaky landings over the red window under `detail.flakes`.

### Executor presence

- Each executor pass publishes its presence: version, machine, environments, last pass, operation held and queue depth.
- `bay status --fleet` lists `executors`, each with its age and whether it is `stale`.
- `bay status` warns `no_live_executor` when operations are queued and no executor presence is fresh.
- `bay doctor --role executor` shows the executor's own presence and how its last push went.

## 0.1.15

- `bay land --wait` waits for another process's run of its operation and returns its outcome, not an early `ok`.
- `bay help --json` lists every command, flag and reason code, read from the binary itself.
- It includes the JSON Schema of `bay.result/1`, also kept as `schemas/bay.result.schema.json`.
- An executor pass costs the same however many closed changes and finished queue places the repository holds.
- After the retention window, the executor removes finished queue places below the head, always keeping the highest.

## 0.1.14

- `bay watch` never judges red from a registry it cannot read; it says `red_unread` and keeps its cursor.
- `bay watch` says `talks_unread` when it cannot read talks, instead of listing none.

## 0.1.13

- A live summary a blob-less cache cannot read is named in the fleet map, never shown as missing.
- `bay start` fails naming an unreadable live summary, never missing the session that holds the change.
- An unreadable `.bay/policy.toml` shows as `quarantines.unread` in `bay status`, never as no quarantines.
- A view's files hold their blobs' exact bytes, whatever `core.autocrlf` the box sets.

## 0.1.12

### Partial clones

- An unreadable `.bay/policy.toml` is an error naming it: land never says POLICY_MISSING, status never reads red as clear.
- The same holds for the committed config, registry key files, executor logs, queue entries, evidence notes and requests.
- A red judgement bay cannot make shows as `red: {"unread": why}` in `bay status` and the fleet map.

### Docs

- The README, skill and demo say main and merge; bay's commands keep trunk and land.

## 0.1.11

### Talk

- The fleet map lists open talks, and each session counts the talks its actor owes and is owed.
- `bay watch` lists when one of your talks arrives, is answered, turns lost or stale, and closes.
- The fleet map's actors include the senders and recipients of open talks.
- A talk's row in `bay status` names the change a delivery talk is about.

### Landing

- `bay status` shows a queued change its merge conflict with trunk before it reaches the head.
- The conflict's paths are in `operation.conflict`; the change keeps its place. It is not pushed to the agent.
- A resubmission racing a background executor on one machine lands once, never panics or leaves an operation running.
- An executor that faults after claiming an operation gives the claim back, so another takes it at once.

### Views

- With `[view] warm_history = true`, `bay start` and `--resume` fetch the declared paths' history, and say how much.
- Below Git 2.54 the history warm-up is skipped, and the result names that floor.

### Repository

- `bay host init` takes the object format of the repository it runs in, else Git's default.
- `log.showSignature` on a box no longer adds signature lines to what bay reads.

### Adapters

- `bay adapter test check|wake|notify -- <command>` runs bay's contract cases against a connector, as bay runs it.
- Each case names what was sent, what came back and why; the command exits 0 only when all pass.
- A late, noisy or environment-dependent answer is named, with what bay would record for it.

### Update

- `bay update` installs at once, even while an executor holds an operation; `UPDATE_IN_USE` is gone.
- The daily background check no longer waits for a running executor either.
- Each release is kept under `$XDG_DATA_HOME/bay/versions/`; updates keep the newest three and every running executor's.
- An executor's operation starts child `bay` processes from its own version, ending on the version it began with.
- On Unix, between operations, a looping executor execs its new binary and names the move in `detail.reexec`.

## 0.1.10

### Speed

- `bay host init` sets the host to keep a commit-graph with changed-path Bloom filters, refreshed by Git's maintenance.
- `bay install` in a clone keeps its commit-graph fresh on fetch and writes the first one with Bloom filters.
- Evidence reads run two Git processes per tree, however many producers record evidence.

### Doctor

- The `store` row reports the commit-graph: disabled, missing, no Bloom filters, or ok, and whether fetches keep it. Advisory.

### Status

- `bay watch --since` judges a red trunk from its cursor, reading only new landings and moved evidence.

### Partial clones

- On a blob-less cache, a view fetches only its declared paths and hook settings, also on resume.
- With Git 2.45+, Git never fetches a missing object inside bay's reads; bay fetches its files by name.
- A bay file a blob-less cache cannot fetch is an error naming the file, never a silent default.

## 0.1.9

Before you upgrade a bay-hosted trunk's clients, upgrade the host. Older hosts refuse an expired talk's close.

### Status

- `bay status` and the fleet map name and count recent trunk merges and pushes that did not land through bay.
- `bay watch`, outside a view, lists an `unattributed_landing` event for each landing outside bay.

### Landing

- Racing starts for one intent make one change: a same-name start adopts it, the others get `CHANGE_SUPERSEDED`.
- `bay undo` never rewinds this store's copy of the undone change's chain.

### Talk

- A talk read after its `expires` closes as expired: the notice prints `EXPIRED`, and no reply is owed.
- Expiry is judged by the ack's commit time, so a talk read in time still owes its reply.
- `bay status` shows an unread talk past its `expires` as `expired`, never `lost`.
- `bay land` and `bay undo` list open talks about that change in `detail.open_talks`, and never block on them.

## 0.1.8

### Speed

- Commands run two to four times faster: bay no longer waits a fixed 20 ms for each Git call.

### Doctor

- `key-custody` lists the actors whose sessions run under this account, and how a signature proves who signed.
- Several actors under one account that can read the agent key make `key-custody` degraded, not red.
- A red `sandbox` row names the AppArmor profile fix when the kernel restricts user namespaces.

### Views

- `bay start` releases clean views of landed, abandoned or no-effect changes once past the retention window.
- A view with changes, own commits, a lock, a live session, a broken worktree, or your directory is kept.

### Evidence

- `bay evidence record` keeps a record whose push failed, and pushes it on the next run.
- It catches up when another clone recorded under the same name first, and reports a divergence.

### Talk

- A reviewer's later finding on a change supersedes its open talk there, so the author sees one.

### Sandbox

- On macOS, a check's own scratch under `/private/var` is no longer read as a reach outside.

### Trunk health

- `bay status --fleet` lists the red checks, and marks each session or change working on red paths.
- `bay watch` lists `trunk_red` when a post check turns trunk red, and `trunk_red_cleared` when it clears.

## 0.1.7

Before you upgrade a bay-hosted trunk's clients, upgrade the host. Older hosts refuse an ack that names its wake.

### Trunk health

- Added a red trunk: a failed post-publication check makes its paths red, owned by the landing that broke it.
- A landing on red paths is refused `TRUNK_RED`, naming the owner and the ways out.
- A fix, undo or supersede of the owner always lands. A later pass or a quarantine clears it too.
- `bay status` lists the red checks. `bay evidence record` takes `--environment` to answer a post check.

### Talk

- `bay status` lists each open talk you sent or must answer: who owes the reply, ages, and standing.
- A talk with no ack within 24 hours is `lost`, and one past its `follow_up` is `stale`. Both stay listed.
- Added the wake adapter, `[wake] command`. Its answer is recorded on the ack, and the sender sees it.
- A wake adapter that fails or takes over 5 seconds is recorded `uncertain`. It is never called twice.
- Added `bay hook talk-wait`: an idle wait that polls for talks and exits 2 when one arrives.
- A live session's hooks fetch talks at most once every 30 seconds.

### Doctor

- Changed `hook-overrides`: another tool's hooks for bay's events are listed, not red. They run beside bay's.
- `hook-overrides` is red only for `disableAllHooks` or `allowManagedHooksOnly`.

## 0.1.6

Before you upgrade a bay-hosted trunk: upgrade its host first. Older bays cannot read the new `no_effect` state.

### Landing

- Added `no_effect`: a change whose work is already on trunk publishes nothing. Before, it published an empty commit.
- Changed a lost push reply with trunk then unreadable to `PUBLISH_OUTCOME_UNKNOWN`, never `failed`. The next land reconciles.
- Changed a held ref lock on the remote into a retry. A lock never released ends `PUBLISH_RETRY_EXHAUSTED`.
- Fixed an executor that retried forever while a reftable lock blocked every ref update.
- Fixed a merge Git could not read being reported as `MERGE_CONFLICT` with no paths. It is now a fault.
- Added `freshness` to the results of `start`, `land` and `undo`.
- Added `elapsed_s`, `receipts` and `next` to an asynchronous land, and `elapsed_s` and `next` to status.

### Evidence and review

- Added `bay evidence record`, the write path for an outside producer or reviewer.
- Records are signed whole, findings included. A rerun writes nothing, and records over 64 KiB are refused.
- Added `bay.reviewerName` and `bay.reviewerKey`. A review record is signed by the reviewer key only.
- Added reviewer delivery talks: a reviewer tells a landing's author what it found, with no agent key.
- A reviewer talks only about a landing it holds a review record on.
- Added `[[dimension]]` to the policy: a site's own completion dimension, such as `reviewed`.
- A profile may require a site dimension. The landing verdict never reads it.
- A rejection is answered only by a landing that names the change, never by a later pass.
- Changed evidence readers to ignore a record of a schema major they do not know. Executors refuse such a request.

### Updates and robustness

- Changed `bay update` to step through the newest release it can verify after a key rollover.
- Every file bay replaces now survives a crash whole.
- Every Git call runs in the C locale, so translated Git messages are read correctly.
- Fixed a check whose shell could not start being reported as reaching outside its sandbox.
- Added doctor rows: the object store and its maintenance, and a hosted trunk on the `files` ref format.
- The doctor `key-custody` row also covers `bay.reviewerKey`.
- `scripts/release.sh publish` tags the commit it built, even if trunk moved since.

### Repository

- Added CONTRIBUTING, SECURITY, a code of conduct, issue and pull request templates, and a public AGENTS.md.

## 0.1.5

The first release signed by the hardware release key. Every release before it was signed by the old key.

### Checks

- Fixed checks on macOS 27, where every check aborted at start under the 0.1.4 sandbox.
- Added a unit `reason` to check results. A refusal and `bay status` show why each failed unit failed.
- A reason over 2 KB makes the run `infra_failure`. bay never cuts a reason or copies raw check output.

## 0.1.4

Builds for Linux (x86_64 and aarch64, musl) and macOS. Apple silicon is tested; Intel (x86_64) is built but not yet run on an Intel Mac.

Known issue: on macOS, checks fail to start (`CHECK_UNKNOWN`). Use 0.1.3 on Macs, or 0.1.5.

Before you upgrade an executor: checks now run in an allow-list sandbox with no network. A check that reads outside its candidate and adapter now fails as `infra_failure`. List extra files in `[[check]] paths`. Set `network = true` in the base policy for a check that needs the network.

### Release keys

- Added a second trusted release key, held in hardware, beside the current one. This release is signed by the current key.
- Changed `bay update` to trust a list of release keys, so a key can roll over. A later release drops the old key.
- Added a doctor `key-custody` row and `KEY_EXPOSED`. It flags a signing key that agent sessions on this account can read.
- Added a `reviewer` key class. A `review` record verifies only against the reviewer file, and a reviewer key never signs a check.
- Changed the registry read to refuse a reviewer name that is also a producer or an agent.

### Checks

- Changed the check sandbox to an allow-list. A check sees its candidate, its adapter and system files, never `HOME` or `.git`.
- Changed checks and input providers to run with no network unless `[[check]] network = true` says so at the base.
- Added `stage = "post"` to `[[check]]`. A post-publication check never blocks a landing, and status shows it `pending` until its record arrives.
- Added `when_paths` to `[[check]]`. The check runs only when the candidate's own diff touches a matching path.
- Changed a policy or adapter change to require every check, whatever its `when_paths`. A skipped check is listed under `not_required`.
- Added `producers` to `[[check]]`. A record from a producer the list leaves out does not count.
- Added `bay.checkEnvironment`, the environments a box serves. An operation no box can serve waits with `CHECK_UNAVAILABLE`.
- Added the adapter's `unavailable` answer. It writes no evidence and never counts toward a queue deadline.
- Changed materialization in a blob-less clone to fetch all absent blobs in one batch, not one fetch per file.
- Added `refs/bay/inflight/<op id>`, a local ref that keeps a candidate safe from maintenance until its operation ends. It is never pushed.

### Landing and history

- Added `bay start <intent> --fixes <change>`. The landing carries a `Fixes:` trailer, and an unlanded target is `CHANGE_NOT_LANDED`.
- Added `CHANGE_HELD`. A same-name `bay start` waits while another live session holds the change, instead of adopting it.
- Added `EVIDENCE_WITHDRAWN`. A bay-hosted trunk refuses a notes push that drops or changes an earlier record.
- Added `trust` (`unsigned` or `signed`) to the land result, check refusals and `bay status`.
- Added `require_signed = true` to a completion profile. Then a dimension that rests only on unsigned records is `unknown`.

### Hooks and sessions

- Added `HOOK_INPUT_INVALID`. The pre-tool hook denies a payload it cannot read.
- Changed the shell deny list to catch denied Git commands inside `bash -c`, `sh -c`, `env`, `xargs`, `sudo` and `exec`.
- Added `SHELL_DENIED` and Agent Hooks verdicts to session rows. A denied command is recorded by its shape, never its text.
- Added `PATH_HELD`. Two sessions in one tree cannot overwrite each other's claimed paths; other views are never refused.
- Changed `bay install` to write a 30-second timeout on the pre-tool hook, so a hung hook denies quickly.
- Changed the installed hook command to exit 0 on a box without bay. Where bay is installed, its deny still blocks.
- Changed views to hold `.claude/settings.json`, so a session started in a view runs bay's hooks.
- Added draft checkpoints at `SessionEnd` and after an idle interval (`[hooks] checkpoint_idle_s`). A killed session loses at most one interval.
- Added a `SessionStart` notice when the tree is behind trunk on its instruction files (`AGENTS.md`, `CLAUDE.md` by default).
- Added a notice, given once, when a talk to the session's actor arrives.
- Added `permission_mode` to session rows and the fleet map. bay's deny still holds under `bypassPermissions`.
- Added `actor_source` to results and session rows, and `[agent] require_actor = true`, which refuses `ACTOR_UNNAMED`.

### Status, watch, talk and evidence

- Added `bay status --stranded`: local branches and worktrees whose commits no remote holds. It reads only.
- Added `bay status --lineage [<range>]`: the `Reverts:`, `Supersedes:` and `Fixes:` pairs on a trunk range.
- Added `behind_commits` and `session_age_s` to each session in `bay status --fleet`.
- Added `bay watch --landed`: landings after a cursor, each with its base, actor and time. Commits outside bay are marked.
- Added `bay evidence missing --kind <k> --producer <p>`: trunk commits no record of that kind and producer covers.
- Added `bay talk --to-change <change>`. It reaches the change's lander from bay's record, and refuses an `UNATTRIBUTED_LANDING`.
- Added the `answered` talk close. A delivery talk closes when a fix, revert or supersede of its change lands.

### Doctor

- Added `bay doctor --role executor`. It judges the box as an executor and exits 10 when a row is red.
- Added doctor rows `agent-hooks`, `hook-overrides`, `repository`, `environments` and `config`.
- Changed `bay executor run` to refuse with `GIT_BELOW_FLOOR` below Git 2.50.

### Configuration

- Changed `.bay/policy.toml` to refuse an unknown key with `POLICY_INVALID`, naming the key, its table and its line.
- Retired `[check] command` in `.bay/config.toml`. The policy names each check's command, and doctor lists the old key as ignored.

## 0.1.3

Builds for Linux (x86_64 and aarch64, musl) and macOS. Apple silicon is tested; Intel (x86_64) is built but not yet run on an Intel Mac.

- Changed where bay reads `.bay/config.toml`: only from the trunk's committed copy, never from a work tree or a change.
- Added `bay install --trunk <name> --remote <name>`, which records where bay finds the trunk on this clone.
- Added `CONFIG_MISSING`: a repository with no config at its trunk is refused, naming where bay looked.
- Added `POLICY_MISSING`: a change never lands without a policy at its base, unless that policy says `unchecked = true`.
- Changed checks to materialize the candidate without a tar of the whole tree, so large repositories check quickly.
- Added `paths` and `materialize_timeout_s` to `[[check]]`; a materialization that runs over is `infra_failure`.
- Added the bay skill under `CLAUDE_CONFIG_DIR` when it is set, and doctor checks it there.
- Changed `bay talk --help` to say agent, not actor.
- Changed `bay update` to take the newest release that has a build for this machine. Releases are no longer split into stable and latest.
- Fixed the bay skill: it now lists watch, talk, status --fleet and land --wait. 0.1.2 had them, but its skill told agents not to run them.
- Removed update channels: `--channel`, `BAY_UPDATE_CHANNEL` and `bay.updateChannel` are no longer read, and doctor names one that is still set.

## 0.1.2

Builds for Linux (x86_64 and aarch64, musl) and macOS. The macOS builds were added on 19 October 2026, from the same commit. Apple silicon is tested; Intel (x86_64) is built but not yet run on an Intel Mac.

- Added `bay status --fleet`: every session on every machine, from signed live summaries, with its change, state and age.
- Added `bay watch [--since <cursor>]`: landings, events and invalidations since a cursor, so a missed notice never hides one.
- Added `bay talk`: a structured ask to another actor, acked by the recipient's own key, with supersede and answers.
- Added named inputs: `bay start --input <kind>:<name>`, and land refuses work whose inputs moved until it is repaired.
- Added the stale-write refusal: the Claude Code hook refuses a write that a stale input covers, from local data only.
- Added the Stop refusal: a session is asked once to checkpoint before it stops with unsaved edits in its view.
- Added hook notices: a refusal or failed check reaches the session once, as one line, within a byte budget.
- Added evidence reuse with `evidence_reuse = "exact-inputs"`: a check on identical inputs is not run again.
- Added runtime evidence and completion profiles, so status never calls a change done while a required part is missing.
- Added `BAY_UPDATE_VERSION` and `BAY_UPDATE_CHANNEL`, which pin one process's updates on a shared login.
- Added a session work record per agent that lists the files each step wrote.
- Changed checks to fail as `infra_failure` on a box with no sandbox, unless the box sets `bay.executorSandbox none`.
- Changed bay to compile for Windows; Windows builds come in a later release.
- Fixed the release script: a new MINOR or MAJOR needs the owner's approval through `BAY_RELEASE_APPROVED`.

## 0.1.1

Builds for Linux (x86_64 and aarch64, musl) and macOS. The macOS builds were added on 19 October 2026, from the same commit. Apple silicon is tested; Intel (x86_64) is built but not yet run on an Intel Mac.

- Added `bay undo <change>`: it lands a new, checked change that reverses one landing; history stays.
- Added check cost: each evidence record keeps the check's seconds, and `bay status` sums them per operation and landing.
- Added `bay update`: it replaces bay with a signed, verified release and never with an unverified download.
- Added a daily background update check from `bay status` and `bay doctor`, off with `BAY_DISABLE_AUTOUPDATE=1`.
- Added box-level update settings in global Git config: `bay.updateVersion`, `bay.updateChannel` and `bay.updateAuto`.
- Added `UPDATE_IN_USE`: an update waits while an executor on the box holds an operation.
- Added `bay install --machine`, which installs only the binary and the bay skill.
- Added the bay skill to `bay install`, and a doctor row that reports a skill that does not match.
- Added `bay status --fleet`, which fails closed until the fleet refs have been fetched.
- Added a `Bay-Session` trailer on landed commits; the actor trailer now carries the bare actor name.
- Added `changed_paths` to the check request, so a check can judge only what a change touched.
- Added `POLICY_INVALID` for a policy bay cannot judge by, and `declared_empty` for a check that selects nothing.
- Added `[[quarantine]]` to set aside a check that is already red on trunk.
- Changed checks to start from a fixed environment, which is part of each check's definition.
- Added `CHECK_UNAVAILABLE`: a queue head with no capable worker waits outside the queue.
- Added `REGISTRY_CHANGED` handling: a revoked key re-signs the same tree and reruns only commit-keyed checks.
- Changed the bay skill to list only the commands this version has.
- Changed `bay doctor` to say "Claude adapter not configured" when the adapter is absent.
- Changed `bay doctor` outside a repository to report the box's own rows instead of an error.
- Changed `.bay/config.toml` loading: an unknown key is an error that names it.
- Changed session start to report the change's state and open refusals from local data only.
- Fixed `bay host init` on Git below 2.54: it refuses with `GIT_BELOW_FLOOR` and creates nothing.
- Fixed a cancel or newer revision that races a landing: the losing operation says it was superseded.
- Fixed a request signed under a registry version that is not on the registry chain: it is refused.
- Changed evidence reading: fields a later version adds are kept and signed over, so newer records still verify.
- Fixed release archives: they carry no extended attributes, so Linux tar unpacks them without warnings.

## 0.1.0-pilot.1

- Added the first build: `bay start`, `land`, `status`, `doctor`, `install`, `share`, `host init` and `executor run`.
- Added signed release assets: one archive per platform, `VERSION`, `SHA256SUMS` and `SHA256SUMS.sig`.
- Added macOS and Linux builds for ARM and Intel.
