# round_robin node

Pits several candidate ideas against each other in pairwise judge matchups
(every pair, both possibly judged twice) and routes to `decided` with a clear
winner, or `tie` when the field is genuinely level. `round_robin` is the tool
for letting a workflow resolve "which of these is best?" internally instead
of punting the choice back to a human.

## Purpose

Use `round_robin` when an earlier step produced a handful of options: designs,
plans, copy variants, candidate fixes. Feed them in and the workflow picks the
strongest one, with a transparent, inspectable account of how it got there.
Each matchup asks the model "which of A or B better fits the criterion, and
how decisively?"; the results are tallied into a standings table and rendered
as a crosstable (who beat whom, by how much, and any non-transitive cycles).

Every idea faces every other idea once (or twice under `swapMode: all`), and
every matchup's result feeds a single tally that names the winner. For four
candidates that means six matchups, as the bracket below shows.

```mermaid
flowchart LR
  I1[Idea 1]
  I2[Idea 2]
  I3[Idea 3]
  I4[Idea 4]
  M12[I1 vs I2]
  M13[I1 vs I3]
  M14[I1 vs I4]
  M23[I2 vs I3]
  M24[I2 vs I4]
  M34[I3 vs I4]
  W[Winner]
  I1 --> M12
  I2 --> M12
  I1 --> M13
  I3 --> M13
  I1 --> M14
  I4 --> M14
  I2 --> M23
  I3 --> M23
  I2 --> M24
  I4 --> M24
  I3 --> M34
  I4 --> M34
  M12 --> W
  M13 --> W
  M14 --> W
  M23 --> W
  M24 --> W
  M34 --> W
```

It beats a single [ai_judge](/docs/nodes/ai-judge) ranking at small N:
pairwise comparisons are easier for a model than ranking a whole list, and the
full crosstable surfaces _order bias_ and _rock-paper-scissors cycles_ that a
one-shot ranking would hide. Cost is `C(N,2)` judge calls (more under swap
modes), so candidates are capped at 8. Beyond that, latency and the
legibility of the crosstable both degrade.

## YAML schema

```yaml
states:
  pick:
    type: round_robin # discriminator
    label: Pick the best idea # required
    model: claude-haiku-4-5 # required, must be in roscoe.yaml allow-list
    criterion: | # required — what "better" means for each matchup
      Which idea best reduces support response time, for the least effort?
    candidates: # exactly ONE source: candidates | candidatesFrom | generate
      - Add an AI triage assistant
      - Publish a self-serve help center
      - Staff a 24/7 follow-the-sun rota
    swapMode: contested # optional: off | contested | all, default contested
    contestedMargin: 60 # optional, 0-100, default 60
    maxRetries: 1 # optional, 0-3
    on: # exactly two keys: decided | tie
      decided: ship
      tie: ask-human
```

## Configuration

| Field             | Type                          | Required | Default                         | Meaning                                                                                                                               |
| ----------------- | ----------------------------- | -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `type`            | `'round_robin'`               | yes      | —                               | Discriminator.                                                                                                                        |
| `label`           | string                        | yes      | —                               | Human-readable name for the step.                                                                                                     |
| `model`           | string                        | yes      | —                               | Model id; checked against `roscoe.yaml` at pre-flight.                                                                                |
| `criterion`       | string                        | yes      | —                               | What each pairwise judge optimises for. Supports `{{ node.key }}` interpolation.                                                      |
| `candidates`      | string[] (2-8)                | one of   | —                               | Inline list of candidate ideas.                                                                                                       |
| `candidatesFrom`  | string                        | one of   | —                               | A `{{ … }}` ref to an upstream array or any text output; non-array text is auto-normalised into a list (see below). ≥2, trimmed to 8. |
| `generate`        | `{ prompt, count }`           | one of   | —                               | Self-generate the candidates in one call first (makes it a standalone "best idea for X" node).                                        |
| `swapMode`        | `off` \| `contested` \| `all` | no       | `contested`                     | Position-bias control (see below).                                                                                                    |
| `contestedMargin` | integer (0-100)               | no       | `60`                            | In `contested` mode, re-judge a pair in reverse when its winning margin is below this.                                                |
| `maxRetries`      | integer (0-3)                 | no       | `1`                             | Per-matchup retries on a malformed judge response.                                                                                    |
| `timeoutSeconds`  | integer (1-2147483)           | no       | `min(120s, defaults.timeoutMs)` | Per-matchup deadline. When set it is authoritative.                                                                                   |
| `maxIterations`   | integer (1-50)                | no       | —                               | Per-run iteration cap when this node sits in a feedback loop.                                                                         |
| `detectStall`     | boolean                       | no       | `false`                         | Fail the run early if the outcome is identical three times in a row.                                                                  |
| `on`              | transition map                | yes      | —                               | Must contain `decided` and `tie`.                                                                                                     |

Exactly **one** of `candidates` / `candidatesFrom` / `generate` must be set:
the schema rejects zero or more than one.

### Candidate sources

| Source           | Use it when…                                                                                            |
| ---------------- | ------------------------------------------------------------------------------------------------------- |
| `candidates`     | the options are known at authoring time (a fixed set of strategies).                                    |
| `candidatesFrom` | an upstream node produced the options (an `ai_agent` that brainstormed, a `json` input, a subworkflow). |
| `generate`       | you want the node itself to brainstorm `count` ideas, then run the round-robin on them.                 |

> **Note on `generate` / prose `candidatesFrom` under the subscription
> handback:** when candidates need a model call (a `generate` step, or a
> `candidatesFrom` that resolved to free text), Claude Code / Cowork run that
> one call as a host subagent on your subscription too (a short `resolve`
> round before the matchups), so the server makes no model call. (Inline
> `candidates` and array/JSON-in-text `candidatesFrom` need no call at all.)

### Feeding candidates from an upstream node

There are two reliable ways to have a workflow produce the options and then
judge them. The `generate` field lets the round-robin node brainstorm them
itself: the response is schema-validated (each item a string or a
`{ label, text }` object) and, if malformed, fails the node rather than
retrying, so a single clean generate call is the "ideate then round-robin"
path when producing candidates is the only job. The `candidatesFrom` field
instead references an upstream node's output, for when an earlier node does
other work too, or when the options come from outside the round-robin
entirely.

The feeder needs no special formatting: `candidatesFrom` accepts either an
array value (a `json`-typed workflow input, or a subworkflow output) or any
text output (an `ai_agent`'s `{{ ideate.response }}`, a `script` node's
`{{ step.stdout }}`, and so on).

```yaml
states:
  ideate:
    type: ai_agent
    model: claude-haiku-4-5
    prompt: Brainstorm 5 re-engagement channels, each a short concrete tactic.
    on: { done: pick }
  pick:
    type: round_robin
    model: claude-haiku-4-5
    criterion: Which channel best balances reach and low annoyance?
    candidatesFrom: '{{ ideate.response }}'
    on: { decided: ship, tie: ask-human }
```

If the referenced value isn't already an array (or a JSON array embedded in
the text), the round-robin **automatically normalises it into a clean list
with one small `model` call**. A prose answer, a bullet list, or a markdown
doc all work this way, without prompting the feeder to "return JSON". That
extraction is told to return nothing rather than invent options, so a feeder
that misunderstands (asks a question, apologises, writes a single paragraph)
doesn't produce a fake board.

> A JSON array (or any array value) is used **directly** and skips the
> normalisation call. When normalisation IS needed, it runs on your Claude
> subscription under Code / Cowork (a one-call `resolve` round handed to a
> host subagent, with no server-side call), and server-side against the
> configured backend only for standalone / desktop-chat runs. Either way the
> pairwise judging runs on your subscription under the handback.

The `{{ … }}` ref is validated when you save/validate the workflow (a typo, or
a reference to a field the upstream node doesn't produce, is rejected up
front). If resolution + normalisation still can't yield at least 2 distinct
options, the node **fails** with an error naming the ref and previewing what
the feeder produced. It does not silently tie or fabricate candidates.

### Swap modes (position bias)

Models can be swayed by which option is presented first. `swapMode` trades
cost for protection against that:

| Mode        | Behaviour                                                                                                             |
| ----------- | --------------------------------------------------------------------------------------------------------------------- |
| `off`       | judge each pair once (`C(N,2)` calls). Cheapest; trusts the single ordering.                                          |
| `contested` | judge once, then **re-judge in reverse only** the pairs whose margin is below `contestedMargin` or that form a cycle. |
| `all`       | judge every pair in **both** orders (`N·(N-1)` calls). Most reliable, ~2× the cost.                                   |

When a pair is judged both ways and the two orders disagree (A wins when
presented first, B wins when _it's_ first), the matchup is scored as a
**draw**. Neither position can be trusted. `contested` is the recommended
default: it spends the extra care only where outcomes are actually close.

## Outputs

`round_robin` writes a `RoundRobinOutcome` into `variables.<nodeId>`:

| Key         | Type                                    | Notes                                                                                                                                               |
| ----------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `outcome`   | `'decided'` \| `'tie'` \| `'cancelled'` | Mirrors the chosen transition. `'cancelled'` if the run was cancelled.                                                                              |
| `winnerIdx` | number \| null                          | Index into `ideas[]` of rank 1; `null` on a tie/cancel.                                                                                             |
| `ideas`     | `{ label, text }[]`                     | The resolved candidates, in their original order.                                                                                                   |
| `standings` | array of standing rows (rank 1 first)   | `idx, rank, wins, losses, draws, points, margin, strengthOfOpposition`.                                                                             |
| `tieBreak`  | string \| null                          | When `decided` on level points, which criterion separated the top two: `'head-to-head'`, `'margin'`, or `'strengthOfOpposition'`. `null` otherwise. |
| `matches`   | array of match objects                  | One per judge call; see below.                                                                                                                      |
| `cycles`    | `number[][]`                            | Detected non-transitive loops (e.g. `[[1,3,4]]`), by idea index.                                                                                    |
| `swapMode`  | string                                  | Echo of the configured mode.                                                                                                                        |

Each match contains `aIdx`/`bIdx` (the ideas judged, in presented order),
`winnerIdx` (the winning idea, or `null` for an order-bias draw), `margin`
(0-100), `reasoning`, `swapped`, `status`, and `durationMs`. Points: a clean
win is 1, a draw 0.5. Tie-breaks, in order: **points → head-to-head →
winning margin → strength of opposition** (the summed decisiveness of an
option's wins, then the sum of opponents' points, a.k.a. Buchholz). Strength of
opposition is informational: in a complete round-robin two options on equal
points always share it, so in practice winning margin is the last criterion
that actually separates a tie. When a tie-break decides the winner,
`tieBreak` names which one, and `standings[].margin` /
`strengthOfOpposition` expose the values so the result is auditable.

## Transitions

- `decided` — one idea holds rank 1 outright after every tie-break.
- `tie` — the top two are still level, or the leader is tangled in a
  non-transitive cycle.

Both keys must be present in `on`. A `tie` is surfaced honestly: the node
does not fabricate a winner. Route it to a human gate, a re-run, or a
deterministic pick, whichever your workflow prefers.

## Execution mode notes

On **server and web UI runs**, the matchups are judged in parallel against the
configured LLM backend.

On **MCP runs from Claude Code or Cowork**, the pairwise judging is handed
back to the host, which runs the matchups as independent subagents on your
Claude subscription and returns the verdicts via `advance_round_robin`
(streaming, as they finish). If candidates need a model call first
(`generate`, or a prose `candidatesFrom`), that runs as a one-call `resolve`
round on the subscription too. The server makes no model call, and Roscoe
tallies the round-robin itself, so no API key is required. See
[Running on your Claude subscription](/docs/running/subscription).

On **MCP runs from Claude desktop chat, and standalone runs**, chat can't
spawn subagents, so the round-robin runs server-side against the configured
LLM backend. It needs either `ANTHROPIC_API_KEY` set or the `claude` CLI
installed.

## Worked example

```yaml
name: round-robin
version: 1
description: A round-robin that picks the best support-improvement idea.
initial: pick
inputs:
  - name: criterion
    type: string
    optional: true
    description: What "better" means — the basis each pairwise judge uses.
    default: Which idea would most reduce customer support response time, for the least effort?
states:
  pick:
    type: round_robin
    label: Pick the best idea
    model: claude-haiku-4-5
    criterion: '{{ criterion }}'
    candidates:
      - Add an AI triage assistant that routes and pre-drafts replies
      - Publish a searchable self-serve help center
      - Staff a 24/7 follow-the-sun support rota
      - Auto-suggest replies to agents as they type
      - Send proactive status alerts before customers ask
    swapMode: contested
    on:
      decided: decided-end
      tie: tie-end

  decided-end:
    type: end
    label: Winner selected
    outcome: success
    message: The round-robin produced a clear winner.

  tie-end:
    type: end
    label: No clear winner
    outcome: failure
    message: The top ideas were too close to separate — consider a human tie-break.
```

## Common pitfalls

### More than 8 candidates

An inline `candidates` list of more than 8 entries is rejected at validation
time (the schema bounds it at 2-8); a `candidatesFrom` array is silently
trimmed to 8. The cap keeps the `C(N,2)` matchup count and the crosstable
manageable. If you genuinely have more options, pre-filter with an `ai_judge`
screen first.

### Setting more than one candidate source

`candidates`, `candidatesFrom`, and `generate` are mutually exclusive. Pick
exactly one, or the schema rejects the node.

### Treating a `tie` as a failure

A tie (including a rock-paper-scissors cycle) is real information: the options
are genuinely equivalent on your criterion. Route `tie` to a human or a
tighter criterion rather than forcing a pick.

### A vague `criterion`

Pairwise judges are only as good as the basis you give them, and "which is
better?" yields noisy, order-biased verdicts. Spell out what "better" means
(impact, effort, risk) the way you would for a single ai_judge.

### `swapMode: all` with long candidate text

That's `N·(N-1)` judge calls over the full text each time, and it adds up.
Start with `contested`.

### Missing API key for server-side runs

When the round-robin runs server-side (standalone, or driven from Claude
desktop chat), it needs `ANTHROPIC_API_KEY` or the `claude` CLI. Driven from
Claude Code / Cowork it runs on your subscription and needs neither.

## Where to next

- [Pick the best of several options](/docs/recipes/round-robin-pick-best) — a
  copy-pasteable best-of-N recipe (draft, judge, act on the winner).
- [ai_judge](/docs/nodes/ai-judge) — a single decision, cheaper, when there's
  nothing to compare against.
- [consensus](/docs/nodes/consensus) — N agents vote on one question, rather than
  ranking several options.
