round_robin node

A round-robin, pairwise judge matchups pick the best of several options.

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-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).

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.

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.

View this page as Markdown

Predictable workflows from unpredictable AI