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.
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 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#
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-humanConfiguration#
| 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/ prosecandidatesFromunder the subscription handback: when candidates need a model call (ageneratestep, or acandidatesFromthat resolved to free text), Claude Code / Cowork run that one call as a host subagent on your subscription too (a shortresolveround before the matchups), so the server makes no model call. (Inlinecandidatesand array/JSON-in-textcandidatesFromneed 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).
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
resolveround 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.
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#
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 — a copy-pasteable best-of-N recipe (draft, judge, act on the winner).
- ai_judge — a single decision, cheaper, when there's nothing to compare against.
- consensus — N agents vote on one question, rather than ranking several options.