Collect (Fan-In) Node — Design Spec

Note: This node shipped as reduce. This design doc uses its original working name, “collect”.

Date: 2026-05-21 Status: Proposed Phase: 1 of 2 — Phase 2 (parallelize executeNodeForList) lives in a separate spec.

Summary

Add a collect node that consumes the listResults of an upstream fanned-out node and produces a single aggregated output via a pluggable strategy (pick-best-via-LLM, concat, count, first-non-empty, vote, merge-json). Closes the fan-out/fan-in asymmetry in the DAG without touching the acyclic invariant: List/Loop → fanned-out node → Collect → continue.

Goals

  1. First-class “generate N → reduce to 1 → continue” pattern as a 2-node thing instead of a custom downstream node or out-of-band pipeline.
  2. Strategy registry that ships new aggregation modes additively (mirrors parameter-picker-registry) — each strategy declares its config schema and its result-rendering metadata.
  3. UI that reads as “N inputs → 1 output” without lying about what happened (no “lost jobs” perception).
  4. No changes to the credit-guard hot path; no changes to executor concurrency semantics; no new cycles in the DAG.

Non-Goals


Design

1. Node shape

Data type — added to frontend/src/types/nodes.ts:

interface CollectNodeData extends BaseNodeData {
  type: "collect"
  strategyId: CollectStrategyId   // discriminant
  strategyConfig: Record<string, unknown>  // shape per-strategy, validated at runtime
}

Handles:

Category in add-node-popup.tsx: “Workflow” — the category that already holds router, sub-workflow-input, sub-workflow-output, sub-workflow, teleport-send, teleport-receive. (List/Loop themselves live in the “Data” category since they’re data sources; Collect is control-flow, so it belongs with Router. Collect makes no provider calls except pick-best-llm, and even that is a control-plane LLM judge, not a generative output.)

2. Strategy registry

Shared registry at packages/shared/src/collect-strategy-registry.ts (single source of truth for backend + frontend):

export type CollectStrategy<TConfig = unknown, TResult = unknown> = {
  readonly id: string
  readonly label: string                      // human-readable, used in dropdown
  readonly description: string                // short tooltip
  readonly configSchema: ZodType<TConfig>     // validated in route + UI
  readonly defaultConfig: TConfig
  readonly outputType: OutputType             // "image" | "video" | "audio" | "text" | "data" — from packages/shared/src/presentation-utils.ts
  readonly creditCostKey: string              // e.g. "collect:pick-best-llm" — looked up in STATIC_CREDIT_COSTS
}

export type CollectStrategyId = typeof COLLECT_STRATEGIES[number]["id"]

export const COLLECT_STRATEGIES = [
  PICK_BEST_LLM_STRATEGY,
  CONCAT_STRATEGY,
  FIRST_NON_EMPTY_STRATEGY,
  COUNT_STRATEGY,
  VOTE_STRATEGY,
  MERGE_JSON_STRATEGY,
] as const

export function getStrategy(id: CollectStrategyId): CollectStrategy

Input shape — string[], not NodeOutput[]. NodeOutput.listResults is string[] (backend/src/services/workflow-engine/types.ts:41) and extractNodeOutputAsList() returns string[]. Image/video/audio inputs come through as URL strings; the strategy decides how to interpret them (pick-best-llm sends URLs to Sonnet which can see images; merge-json parses each string as JSON).

Backend execution side — strategy implementations live in backend/src/services/collect-strategies/<id>.ts, each exporting:

export async function execute(
  items: string[],
  config: TConfig,
  ctx: { userId: string; jobId: string; logger: Logger }
): Promise<{ result: string | number; meta: ResultMeta }>

export type ResultMeta = {
  readonly selectedIndex?: number            // pick-best, first-non-empty
  readonly reasoning?: string                // pick-best LLM rationale
  readonly summary: string                   // 1-line human-readable
}

A central dispatcher backend/src/services/collect-strategies/index.ts maps strategyId → execute() and is the only thing the route imports. Adding a strategy = new file + 1 line in the dispatcher + 1 entry in the shared registry.

Pattern note: one-file-per-strategy is a new convention for this codebase. Prior pluggable registries (parameter-picker-registry, combine-transitions, audio-crossfade-curves) centralize their config in a single file. Collect splits because each strategy has meaningful execution code (especially pick-best-llm), not just static config. If subsequent registries adopt this pattern, extract a shared convention in a follow-up.

3. v1 strategies

Strategy Config Output type Behavior Credit cost key
pick-best-llm { criteria: string; inputKind: "text" \| "image-url" } string (URL or text — matches input) Sonnet judges all N items against criteria, returns chosen item + reasoning. For inputKind: "image-url" Sonnet sees images via URL; video pick-best deferred to v2 (needs frame extraction). collect:pick-best-llm (TBD, suggest 3 cr)
concat { separator: string } (default "\n\n") text Joins all string items with separator collect:concat (0 cr)
first-non-empty {} string (matches input) Returns first item that is non-null/non-empty collect:first-non-empty (0 cr)
count {} "data" (value is a number stringified for transport) Returns valid.length (count of survivors after empty-string filter) collect:count (0 cr)
vote { caseSensitive?: boolean } text Returns most-common string (ties → first) collect:vote (0 cr)
merge-json { strategy: "deep" \| "shallow" } json (serialized) Parses each item as JSON, merges into one object, returns JSON string collect:merge-json (0 cr)

All “Behavior” descriptions above operate on survivors only — empty strings from failed upstream iterations are filtered out before strategy execution (see §4 Failure handling). E.g. count returns survivor count, not attempt count; concat joins survivors without empty separators.

Pick-best-llm prompt template (in backend/src/services/collect-strategies/pick-best-llm.ts):

You are judging N candidate outputs against criteria.
Criteria: {criteria}
Candidates:
  [1] {item_1}
  [2] {item_2}
  ...
Reply with JSON: { "chosen_index": <1-based>, "reasoning": "<one sentence>" }

Uses llmComplete from backend/src/lib/llm-client.ts with model id claude-sonnet-4.6 (DOT form — canonical for the unified LLM client per backend/CLAUDE.md “Unified LLM Client” table). NOT to be confused with claude-sonnet-4-6 (hyphen) used by ee/pipelines/llms/call-llm.ts, which is a separate code path for pipeline-specific critics.

Image content block handling — implementation decision required. llmComplete tries KIE’s messages proxy first and falls back to direct Anthropic SDK only when directFallbackModel is set. The direct-Anthropic path accepts { type: "image", source: { type: "url", url: item } } content blocks natively (verified at llm-client.ts:144 — the client converts the shape correctly). KIE’s proxy behavior with image content blocks is NOT verified — it may strip image blocks, error, or work silently incorrectly.

For inputKind: "image-url", the implementer must pick ONE of:

  1. Force direct-Anthropic path: pass directFallbackModel: "claude-sonnet-4-6" on every call (verified at packages/shared/src/llm-models.ts:70 — this is the current direct-Anthropic SDK id for the Sonnet model that pairs with the KIE-side claude-sonnet-4.6; the dot vs hyphen difference is real, one is KIE’s id and one is Anthropic SDK’s). Skips KIE entirely. Simplest, but loses KIE cost/rate-limit pooling.
  2. Verify KIE supports image content blocks via direct test against KIE before implementation. If yes, route normally; if no, fall back to option 1.
  3. Borrow the ee/pipelines/llms/image-critic.ts pattern (uses callLLM, which has battle-tested image support). Requires either moving pick-best-llm into ee/ OR extracting a shared core helper. Heavier, but it’s the same problem image-critic already solved.

Recommended default: option 1 for v1 (smallest blast radius, fully predictable). Revisit if pick-best-llm volume justifies KIE routing.

Output type: all strategies return string (URLs are strings) or number (count). Downstream type-routing follows the same per-item URL regex classification already used by input-resolver.ts:117-128 (mixed photo/video lists handled via VIDEO_URL_RE).

4. DAG execution semantics

The core problem: today’s executeNodeForList (frontend/src/components/editor/workflow-editor/list-execution.ts:20) detects an upstream List/Loop and fans out the consuming node, running it N times. A Collect node connected to that same fan-out source must NOT itself be fanned out — it needs the whole listResults array as a single input.

Existing precedent — ARRAY_ACCUMULATING_TYPES. backend/src/services/workflow-engine/input-resolver.ts:661 already declares:

const ARRAY_ACCUMULATING_TYPES = new Set(["combine-videos", "mix-audio", "combine-audio"])

…and at line 111-116 handles the fan-in case by routing each item individually through routeOutput() into type-specific media arrays (videoUrls, audioUrls). This is the closest existing pattern: nodes that consume a list rather than being fanned out.

Why a new sibling set, not joining ARRAY_ACCUMULATING_TYPES. ARRAY_ACCUMULATING_TYPES routes items into typed media arrays via routeVideoOutput/routeAudioOutput. Collect doesn’t care about media type — it wants the raw string[]. Different routing target, so:

// backend/src/services/workflow-engine/input-resolver.ts (sibling of ARRAY_ACCUMULATING_TYPES at line 661)
// Match the existing convention: plain Set<string> with string literals, NOT a literal-union type
const FAN_IN_NODE_TYPES = new Set(["collect"])

Resolution rule — in getListInputForNode (backend/src/services/workflow-engine/input-resolver.ts:369):

Before walking upstream for a fan-out source, check FAN_IN_NODE_TYPES.has(node.type). If true, the node is itself the consumer — return null for fan-out detection (no fan-out happens). In the per-item-routing branch (around line 105-116), add a parallel branch for FAN_IN_NODE_TYPES that sets inputs.listInputs = filtered (full string[]) instead of routing items individually.

New input-resolver behavior — single-result wrapping. Today the resolver pulls state?.output?.listResults (line 85-90 area); if upstream wasn’t fanned out, this is undefined and Collect has no input. We need a fallback: when target is in FAN_IN_NODE_TYPES and listResults is missing, wrap the upstream’s primary output: effectiveListResults = listResults ?? (output != null ? [output] : []). This is new logic to add to the resolver.

Frontend mirror: same check in list-execution.ts:executeNodeForList (line 20) — skip fan-out if the current node is in the frontend’s FAN_IN_NODE_TYPES set. Read upstream’s __listResults directly and pass as string[] to a single execute call.

Result: the graph List → GenerateImage → Collect produces:

Cycle invariant: unchanged. Kahn topo sort still works — Collect is just another node with a deterministic input → output relationship.

Failure handling — dense listResults with empty-string failures. executeNodeForList allocates new Array(items.length).fill("") at frontend/src/components/editor/workflow-editor/list-execution.ts:106 and writes results in-place per iteration. Failed iterations remain "". Collect therefore receives a dense array including empty strings for failures — NOT a sparse array, and NOT explicit nulls. Define the contract:

5. Credit pricing

6. Frontend UX

Node component (frontend/src/components/nodes/collect-node.tsx):

Config panel (frontend/src/components/editor/config-panels/collect-configs.tsx):

Skip: per-iteration connector visual (one edge per clone into Collect). It fights the current “one source edge → Collect” mental model in React Flow and isn’t worth the canvas-layer work. The pill + inputs tab + existing expandLoopResults clones on the fan-out side already carry the legibility load.

7. Backend

Route (backend/src/routes/collect.ts):

const collectBody = z.object({
  strategyId: z.enum(COLLECT_STRATEGY_IDS),       // discriminant; from shared registry
  strategyConfig: z.record(z.unknown()),           // per-strategy shape; validated at dispatcher via getStrategy(id).configSchema
  inputs: z.array(z.string()),                     // the upstream listResults — dense array, empty strings for failures (see §4)
  workflowExecutionId: z.string().uuid().optional(),  // audit trail + dedup-fingerprint differentiator (see below)
})
app.post("/v1/collect", {
  preHandler: creditGuard(
    (req) => `collect:${(req.body as Record<string,unknown>).strategyId}`,
    { dedup: false }  // identical inputs across workflow runs MUST NOT collapse
  ),
}, ...)

{ dedup: false } is the established escape hatch (precedent: routes/voice-clones.ts uses it for both POST handlers). The optional workflowExecutionId in the body is a belt-and-suspenders measure but is NOT sufficient on its own — pass dedup: false.

Strategy implementations in backend/src/services/collect-strategies/:

EE consideration: pick-best-llm consumes paid LLM credits — its credit cost is gated through the standard creditGuard middleware shim (which dispatches to ee/lib/credit-guard-impl only when hasCredits()). No new ee/ placement needed; the strategy itself lives in core, the credit gating is already at the middleware layer.

MCP exposure: add collect tool at backend/src/lib/mcp/tools/collect.ts (the existing MCP tool directory — apps.ts, characters.ts, components.ts, etc. live here) mirroring the route. Scope: workflows:execute (consistent with other workflow-execution tools). Out of scope for v1 ship — add as a follow-up after the node lands.

8. Registration checklist

Cross-references the New Node Registration checklist in the root CLAUDE.md. All standard steps apply. Steps with non-standard content:

Two new sets to add (NOT in the existing 19-step list):

These are net-new concepts; flag for inclusion in the root CLAUDE.md registration checklist once this lands.

Additional verification points (during implementation):

9. Public docs

Per CLAUDE.md “Public Docs Maintenance Rule”:

10. SDK & client


Tests

Shared (packages/shared/src/__tests__/collect-strategy-registry.test.ts):

Backend (backend/src/services/collect-strategies/__tests__/):

Backend route (backend/src/routes/__tests__/collect.test.ts):

Backend DAG (backend/src/services/workflow-engine/__tests__/):

Frontend (frontend/src/components/__tests__/collect-node.test.tsx):

Total estimate: ~50 tests across 8-10 files.


Open questions

None blocking. The single-vs-multi-source question was resolved in conversation (single-source for v1, multi-source as v2). The strategy list is finalized at 6 for v1. The credit cost for pick-best-llm is a suggested 3 cr — to be finalized with the cost-model owner during PR review.

Phase 2 preview (separate spec)

Once Collect ships and proves the shape:

  1. Parallelize executeNodeForList — concurrency knob on Loop/List nodes (default 4, configurable up to 16).
  2. Dedup-fingerprint × fan-out concurrency-safety pass — either dedup: false from executeNodeForList (option already exists on creditGuard, used by voice-clones.ts) or mix iteration index into fingerprint payload. Settle before flipping parallel on.
  3. Credit reservation race audit — verify FOR UPDATE locks on credit RPC survive parallel fan-out load; add a load test if not already covered.

v2 additions (no spec yet):


Verified codebase references (2026-05-21)

Every architectural claim in this spec is anchored to a real file. Implementers should navigate from these, not from CLAUDE.md alone:

Concept Path Line Notes
NodeOutput.listResults: string[] backend/src/services/workflow-engine/types.ts 41 Strings only — strategies parse URLs/JSON from strings
getListInputForNode backend/src/services/workflow-engine/input-resolver.ts 369 Fan-out detection — must early-return for FAN_IN_NODE_TYPES
ARRAY_ACCUMULATING_TYPES (sibling pattern) backend/src/services/workflow-engine/input-resolver.ts 661 Set(["combine-videos", "mix-audio", "combine-audio"]) — closest precedent; routes per-item to typed media arrays
Per-item routing branch backend/src/services/workflow-engine/input-resolver.ts 111-116 Where FAN_IN_NODE_TYPES branch must hook in
Mixed media-type per-item classification backend/src/services/workflow-engine/input-resolver.ts 117-128 VIDEO_URL_RE precedent for typing strings
executeNodeForList frontend/src/components/editor/workflow-editor/list-execution.ts 20 Fan-out engine — must skip when target is in FAN_IN_NODE_TYPES
expandLoopResults frontend/src/components/editor/workflow-editor/list-execution.ts 188 Visual clone expansion — unchanged, lives on fan-out side
isExecutableNode / EXECUTABLE_TYPES frontend/src/components/editor/workflow-editor/types.ts 262 Step-15 gate — add "collect" to the set
STATIC_CREDIT_COSTS (composite keys) backend/src/ee/billing/credits.ts ~96+ Pattern: "gpt-image:high", "flux:2K" — use "collect:<strategy>"
Composite-key resolver helper frontend/src/components/editor/config-panels/helpers.ts buildCreditModelIdentifier() — Collect frontend equivalent reads data.strategyId
llmComplete (unified LLM client) backend/src/lib/llm-client.ts 56 claude-sonnet-4.6 (DOT) — canonical for this path
MCP tools directory backend/src/lib/mcp/tools/ NOT ee/mcp/tools/
NODE_REGISTRY backend/src/lib/node-registry.ts Discovery API descriptor
creditGuard core shim backend/src/middleware/credit-guard.ts Stays in core, dispatches to ee/lib/credit-guard-impl.ts

Net-new logic flagged for review

These are NOT just registration changes — they are net-new code paths that need their own test coverage:

  1. FAN_IN_NODE_TYPES set + per-item-routing branch in input-resolver.ts (~10 lines). Mirror in frontend list-execution.ts.
  2. Single-result wrapping fallback — when target is in FAN_IN_NODE_TYPES and upstream has no listResults, wrap the primary output as [output]. Currently no analogous behavior in the resolver; needs unit test for both array and non-array upstream cases.
  3. extractNodeOutputAsList() consumption path — verify the frontend helper (per frontend/CLAUDE.md “List / Loop / Skip Node Patterns”) returns string[] consistently across all upstream node types; Collect depends on this contract.
  4. llmComplete image-URL content block usage — pick-best-llm sends { type: "image", source: { type: "url", ... } } content. Verify the unified client’s messages API path supports this shape (the ee/pipelines/llms/image-critic.ts does similar work via callLLM; the llmComplete path may need a thin extension).