map node

Fan a prompt across N parallel branches, collect their outputs, and optionally reduce them.

Fans a prompt out across N parallel branches: either the same prompt run count times, or one branch per item in a list (over). It collects each branch's full text output and, optionally, runs a built-in reduce step that synthesizes the results into one. map routes to success when at least one branch returns a result, and to failure only when every branch fails.

Purpose#

Use map when you want N independent attempts at the same task, or the same task applied across a list, run in parallel and optionally merged afterward. count mode (same prompt × N) suits best-of-N drafting, multiple independent analyses, or sampling several takes before reducing to the best one. over mode (one branch per item) suits summarizing each section of a document, drafting a reply per support ticket, describing each file in a changeset, or scoring each option.

It differs from consensus, which votes (boolean, confidence, or answer, toward a quorum), and round_robin, which ranks options via pairwise matchups. map keeps every branch's raw text output and hands the list downstream. If you don't need a vote or a ranking, and you need the actual N outputs, map is the node.

YAML schema#

states:
  draft:
    type: map # discriminator
    label: Draft candidate replies # required
    model: claude-haiku-4-5 # required, must be in roscoe.yaml allow-list
    prompt: | # required — sent to every branch
      Draft a warm, concise reply to this refund request:
      {{ request }}
    count: 5 # exactly ONE source: count | over
    # over: "{{ split.sections }}"   # the other source (see below)
    # itemVar: item                  # over mode only — the per-item variable name
    reduce: # optional — one synthesis call over the outputs
      prompt: |
        Merge these drafts into the single best reply:
        {{ outputs }}
    maxRetries: 1 # optional, 0-3 (per branch)
    on: # exactly two keys: success | failure
      success: send
      failure: escalate

Configuration#

Field Type Required Default Meaning
type 'map' yes Discriminator.
label string yes Human-readable name for the step.
model string yes Model id; checked against roscoe.yaml at pre-flight.
prompt string yes Sent to every branch. Supports {{ node.key }}; in over mode also {{ item }} (see below).
count integer (2-12) one of Run this many parallel branches with the same prompt.
over string one of A {{ … }} ref to a list to map over, one branch per item (capped at 12). Accepts prose (see below).
itemVar string no item over mode: the variable each item binds to in the prompt, e.g. {{ item }}.
reduce { prompt } no Optional synthesis over the collected outputs. Omit to collect-only.
maxRetries integer (0-3) no 1 Per-branch retries on a transient failure.
timeoutSeconds integer (1-2147483) no min(120s, defaults.timeoutMs) Per-branch 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 success and failure.

Exactly one of count or over must be set. The schema rejects zero or both. {{ item }} is only meaningful in over mode; using it with count is flagged at validation time (in count mode every branch gets the identical prompt).

Fan-out modes#

Mode Use it when…
count you want N independent attempts at the same task (best-of-N drafts, repeated analyses).
over you have a list and want the same task applied to each item (per-section, per-ticket).

The two are mutually exclusive. The editor presents them as a segmented toggle and clears the other field when you switch, so an invalid both-set state can't be saved.

The shape is the same regardless of mode: one list in, N branches out, and an optional reduce back down to one result.

flowchart LR
  L[Input list] --> I1[item 1]
  L --> I2[item 2]
  L --> I3[item 3]
  I1 --> R[reduce]
  I2 --> R
  I3 --> R
  R --> O[Output]

Each item fans out to its own parallel branch; the optional reduce step then folds every branch's output into one result. Skip reduce and the branch outputs flow downstream as a list instead.

Mapping over a generated list (over)#

The most common shape is an earlier node producing a list, then map fanning out over it. The upstream node needs no special formatting: over accepts an array value (a json-typed workflow input, a subworkflow output, or another fan-out node's array output, e.g. {{ prev.outputs }}), a JSON array embedded in text, or free prose, a bulleted list, or script stdout. For that last case, Roscoe extracts the list with one model call, so an ai_agent that brainstorms options in plain English, or a script node that prints one item per line, both work directly.

states:
  brainstorm:
    type: ai_agent
    model: claude-haiku-4-5
    prompt: Brainstorm 5 distinct marketing channels for a coffee subscription.
    on: { done: pitch }
  pitch:
    type: map
    model: claude-haiku-4-5
    over: '{{ brainstorm.response }}' # the agent's prose — no "return JSON" needed
    itemVar: channel
    prompt: 'Write a one-line pitch for the {{ channel }} channel.'
    on: { success: plan, failure: failed }

A real array (or a JSON array embedded in text) is used directly and skips the extraction call. The extraction step is told to return nothing rather than invent items, so an upstream node that misunderstands the ask (posing a question back, apologizing, writing a single paragraph) won't fabricate a list. Instead the node fails with an actionable error naming the ref, distinct from an "all branches failed" outcome.

Item shape: when over resolves to a real array, each {{ item }} is the original element, so {{ item.title }} works if the items are objects. When the list is extracted from free prose, each item is the option's text string ({{ item }} is the whole text; {{ item.field }} resolves to empty).

No silent caps. over is capped at 12 items; if the list resolves to more, the node maps the first 12 and surfaces resolvedCount / truncated in its outcome (and logs a warning) so the cap is visible, never silent.

Optional reduce#

When reduce is set and at least one branch succeeded, map runs one more call over the collected outputs and stores the result as reduced. The reduce prompt sees two injected variables: {{ outputs }}, a JSON array of the successful branch responses (e.g. ["draft one","draft two"]), not newline-joined text, so word the prompt to expect a JSON blob; and {{ items }}, the mapped items in over mode (empty in count mode).

Omit reduce to collect only: the raw outputs flow downstream as {{ <nodeId>.outputs }} for a later node to consume.

Outputs#

map writes a MapOutcome into variables.<nodeId>:

Key Type Notes
outcome 'success' | 'failure' | 'cancelled' Mirrors the chosen transition.
outputs string[] The successful branch responses, in branch order. The headline output.
reduced string | null The synthesis, when reduce ran; null otherwise.
branches MapBranch[] Per-branch audit: index, item?, status, response, attempts, durationMs.
successes number Count of completed branches.
failures number Count of failed / timed-out branches.
count number Resolved branch count.
mode 'count' | 'over' Echo of the fan-out mode.
resolvedCount number (over mode) Items over resolved to before the 12-cap.
truncated boolean (over mode) true when the list was capped to 12.

Downstream nodes read {{ map.outputs }} (a JSON array via interpolation), {{ map.reduced }}, {{ map.successes }}, etc.

Transitions#

Transition When
success at least one branch returned a non-empty response.
failure every branch failed (or, in over mode, the list was empty).

Both keys must be present in on. Partial success is success: failed branches are recorded in branches[], but they don't fail the node as long as one branch produced output. An over ref that resolves to nothing, or isn't a list at all, is a separate, actionable run failure that names the ref, not the failure transition.

Execution mode notes#

When the run is driven from Claude Code or Cowork over MCP, the fan-out is handed back to the host, which runs each branch as an independent subagent on your Claude subscription and returns the outputs via advance_map. If over needs list extraction, that call is also handed back as an extract round, and the optional reduce runs as a final single-subagent round. Every model call runs on your subscription this way; the server makes none, and no API key is required. See Running on your Claude subscription.

Server and web UI runs, and runs driven from Claude desktop chat, execute branches in parallel against the configured LLM backend (a bounded concurrency pool). Any list extraction or reduce call also runs server-side, so it needs ANTHROPIC_API_KEY set or the claude CLI installed.

Worked example#

name: map-draft-replies
version: 1
description: Five parallel drafts of a customer reply, merged into the best one.
initial: draft
inputs:
  - name: request
    type: string
    optional: true
    description: The customer's refund request to reply to.
    default: |
      I bought the annual plan two days ago but my company already has a team
      licence. Can I get a refund? I haven't used it at all.
states:
  draft:
    type: map
    label: Draft five candidate replies
    model: claude-haiku-4-5
    count: 5
    prompt: |
      A customer wrote in with this request:
        "{{ request }}"
      Draft a warm, concise reply (3-5 sentences). Vary your angle from other drafts.
    reduce:
      prompt: |
        Here are several independent draft replies to the same customer:
        {{ outputs }}
        Merge them into the single best reply. Return only the reply.
    on:
      success: done
      failure: failed

  done:
    type: end
    label: Reply ready
    outcome: success
    message: Synthesized the best reply from the drafts.

  failed:
    type: end
    label: All drafts failed
    outcome: failure
    message: Every branch failed to produce a draft.

Common pitfalls#

  • Setting both count and over (or neither). They're mutually exclusive: pick exactly one. The schema rejects zero or both.
  • Using {{ item }} in count mode. It only resolves in over mode; every count branch gets the identical prompt, so using it there is flagged at validation time.
  • Expecting {{ outputs }} to be newline-joined text. It's a JSON array of branch responses; word the reduce prompt to expect a JSON blob.
  • Treating an empty over as a normal failure. A list that resolves to nothing (or isn't a list at all) is a separate, actionable run failure that names the ref. Fix the upstream node rather than routing it through failure.
  • Relying on more than 12 items. over caps at 12; check truncated / resolvedCount if you might exceed it, and pre-filter upstream if you genuinely have more.
  • Missing API key for server-side runs. When map runs server-side (standalone, or driven from Claude desktop chat), branches, and any extraction or reduce, need ANTHROPIC_API_KEY or the claude CLI. Driven from Claude Code or Cowork, it runs entirely on your subscription and needs neither.

See also#

  • consensus — N agents vote on one question (quorum), rather than collecting N raw outputs.
  • round_robinrank several options via pairwise matchups, rather than mapping a task across them.
  • Running on your Claude subscription — how the fan-out (and extraction / reduce) run as host subagents.

View this page as Markdown

Predictable workflows from unpredictable AI