Skip to main content

Author/reviewer pairs on different model families

One agent writes a change; a second agent, built on a different model family, reviews it. The reviewer fixes what is cheap to fix, hands anything structural back to the author, and never merges its own work. A self-hosting team built this by hand inside their coordinator, with an identity map, a no-self-merge rule and a reviewer charter. The stack carries most of it for you.

Why a different family​

Correlated blind spots. A reviewer from the same model family tends to approve what its author got wrong, because it would have written the same thing. Pair, for example, a Crush author on one provider with a Claude Code reviewer.

Today, "different family" is whatever each harness's own model configuration says: model on a one-shot, the client's own provider config on a resident session. Fail-closed model pinning, which refuses to run when the model that actually answered isn't the one configured, is coming (ADR-0026). With it, the pairing is enforced rather than intended.

Harness: two identities, two harnesses​

Run the author and the reviewer as separate harnesses. Each has its own env_file, holding its own forge token (a different forge account for each, so the forge can tell them apart) and its own Switchboard endpoint token. A resident author and a triggered reviewer is a common shape:

# The author: a resident Crush session, woken by its own endpoint's doorbells.
[harness.author]
harness = "crush"
args = ["--yolo", "--channels", "server:switchboard"]
workdir = "~/agents/author"
env_file = "~/.config/harness/env/author.env" # the author's forge token + endpoint token
restart = "on-failure"
restart_delay = 30
enabled = true

# The reviewer: a Claude Code one-shot, fired by review requests on its own endpoint.
[channel.reviewer]
url = "https://switchboard.example.com/mcp/reviewer-k3x9"
env_file = "~/.config/harness/env/reviewer-channel.env"
headers = { Authorization = "Bearer ${SB_TOKEN}" }

[harness.reviewer]
harness = "claude-code"
prompt_file = "~/agents/reviewer/REVIEW.md" # the charter below
model = "claude-opus-5"
auto_accept = true
workdir = "~/agents/reviewer"
env_file = "~/.config/harness/env/reviewer.env" # the reviewer's forge token + endpoint token
triggers = ["channel.reviewer"]
schedule = "@every 2h" # safety net for a missed doorbell
timeout = "45m"

Keep the two env_files apart. A reviewer that can read the author's forge token can approve as the author, and every guard below assumes it can't.

Switchboard: route reviews to the reviewer, never to the author​

Give each identity its own forge webhook into Switchboard, and put these rules on the reviewer's. They drop review requests meant for anyone else, and any trigger to review a pull request the reviewer itself authored. Everything else reaches the webhook's queue:

{
"rules": [
{"id": "review-request-not-for-me", "name": "review requests for anyone other than params.identity",
"expr": ".kind == \"pull_request\" and ((.payload.action // \"\") | IN(\"review_requested\", \"review_request_removed\")) and ((($params.identity // \"\") == \"\") or ((.payload.requested_reviewer.login // \"\") != $params.identity))",
"action": {"drop": true}},
{"id": "own-pr-review-trigger", "name": "never review a pull request params.identity authored",
"expr": ".kind == \"pull_request\" and ($params.identity // \"\") != \"\" and ((.payload.pull_request.user.login // \"\") == $params.identity) and ((.payload.action // \"\") | IN(\"opened\", \"reopened\", \"synchronized\", \"synchronize\", \"edited\", \"ready_for_review\", \"review_requested\"))",
"action": {"drop": true}}
],
"params": {"identity": "reviewer-bot"}
}

These are the routing cookbook's review recipe. A pool of reviewers on one webhook uses the cookbook's pool-review pack instead. Installing a pack as a one-command preset is coming (Switchboard ADR-0036); until then, apply the rules with set_webhook_rules, after checking them against real deliveries with test_webhook_rules.

:::caution Match .kind against what the forge actually sends

.kind is the X-GitHub-Event / X-Gitea-Event header, verbatim. Gitea puts the review outcome there (pull_request_approved, pull_request_rejected, pull_request_comment) and a sub-type in X-Gitea-Event-Type. A rule written against the sub-type matches nothing, and a rule that matches nothing looks exactly like one that is working. Dry-run every rule against stored deliveries before you trust it (the header table).

:::

The forge: the merge gate lives here​

Routing decides who is woken, not what they may do once awake. Put the hard rules in the forge, where no agent's judgment can get around them:

  • Branch protection requires one approval on the default branch.
  • An author can't approve their own pull request. Forges refuse it; separate identities make that refusal apply.
  • Stale approvals are dismissed when new commits are pushed.
  • A review request voids earlier approvals. On Gitea, requesting review from someone clears the official mark on the reviews they had already left, and only official approvals satisfy an approval requirement. A lane that comments on a PR and then re-requests review has therefore just deleted its own approval, and the PR looks approved and green to every reader while the forge refuses to merge it.
  • The reviewer arms auto-merge only after approving a finished PR. An auto-merge armed before a late push merges the SHA that was armed, and silently drops the push. After any push to a PR you thought had merged, check the change reached the default branch by reading the file, not the PR's merged flag.

The reviewer's charter​

Put it in the reviewer's prompt (REVIEW.md above):

  • Fix nits, failing tests and missing coverage yourself, as separate commits on the author's branch, then leave one summary comment saying what you changed.
  • Defer anything architectural to the author, as a review comment. Don't redesign someone else's change.
  • Never merge your own PR, and never approve one you pushed fixes to without reading the result.
  • Post your review last. Send any review re-request before you review, never after: the request clears the official mark on the reviews the requested reviewer already left, which silently voids the approval you just posted.

Claiming: one review, one reviewer​

A review request can reach several live sessions of the same identity, such as a triggered one-shot and an interactive session someone left open. Take the work with claim_next, which hands each todo to exactly one caller, and re-read the PR's state (open, merged, head SHA) before anything expensive, because the PR may have moved on while the todo waited.