Array Inputs & Fan-Out Execution — Design Spec

Date: 2026-03-19 Status: Approved

Summary

Enable List and Loop nodes as dynamic array inputs in presentation/app mode, with backend orchestrator fan-out and gallery output views. App users can add/remove items (prompts, images, etc.) dynamically, and the workflow fans out to produce multiple results displayed in media-type-aware layouts.

Goals

  1. List node as app input: user enters N text prompts → workflow generates N images/videos/etc.
  2. Loop node as app input: user provides N rows of typed data (image+prompt, etc.) → workflow generates N results with correlated data per row.
  3. Backend orchestrator fan-out: server-side list expansion for apps, webhooks, and scheduled triggers.
  4. Gallery output views: auto-layout by media type (grid/carousel/stacked/collapsible).

Non-Goals


Design

1. Loop Node — Typed Columns

Data model change:

// Current
interface LoopColumn {
  readonly id: string
  readonly name: string
  readonly handleId: string
}

// New
interface LoopColumn {
  readonly id: string
  readonly name: string
  readonly handleId: string
  readonly type: "text" | "image-url" | "video-url" | "audio-url"  // default: "text"
}

Editor UI changes:

2. List & Loop as Presentation Inputs

Remove from exclusion list:

In packages/shared/src/presentation-utils.ts, remove "list" and "loop" from ALWAYS_EXCLUDED_TYPES. Do NOT add them to INPUT_NODE_TYPES — they rely solely on the explicit presentationInput flag (the newer opt-in path). The getInputNodes() function already supports this: if n.data.presentationInput === true it returns the node regardless of type.

Node Picker Dialog:

List and loop nodes appear under a new “Array Inputs” section with type badges and metadata:

New input card components:

ListInputCard:

LoopInputCard:

Mobile adaptations:

3. Limits

Two-tier limit system:

Level Field Location Default
Per-node (creator) maxItems ListNodeData / LoopNodeData 10
System ceiling (admin) max_fanout_items app_settings table 20

4. Input Value Storage

presentationStore / run slots:

// List node
inputValues[listNodeId] = {
  items: ["prompt A", "prompt B", "prompt C"]
}

// Loop node
inputValues[loopNodeId] = {
  rows: [
    ["https://img1.jpg", "sunset prompt"],
    ["https://img2.jpg", "ocean prompt"]
  ]
}

5. Backend Orchestrator Fan-Out

Existing infrastructure (already implemented):

What’s new: The orchestrator’s node-executor.ts must detect fan-out and run N iterations (currently it only passes through single values).

Execution flow:

  1. Detect — In node-executor.ts, before executing a node, call getListInputForNode(). If items[] returned and edge outputMode is “each”, enter fan-out loop.
  2. Iterate — For each item: build payload with overridePrompt (text) or overrideMediaUrl (URL-like), execute via same node-executor path. Sequential within node. Check ctx.cancelled before each iteration.
  3. Collect — Store results in existing nodeStates[nodeId].output.listResults[]. Set output.text / output.url to first result for backwards compatibility.
  4. Downstream — Already implemented: input-resolver.ts resolves from listResults based on edge outputMode:
    • "each" → downstream also fans out, zipped by index (item[i] → downstream iteration[i], producing N results not N×N)
    • "all"listResults.join(", ")
    • "last"listResults[listResults.length - 1]
    • "item:N"listResults[N]

Edge outputMode in app mode: Edge data (including outputMode) is frozen in the workflow snapshot at publish time. App users have no access to edge configuration. The published app uses whatever outputMode the creator set in the editor.

Progress tracking additions to NodeExecutionState:

interface NodeExecutionState {
  // ... existing fields ...
  iterationTotal?: number            // total fan-out iterations
  iterationCompleted?: number        // completed so far (for progress UI)
}

These fields are on NodeExecutionState (the per-node status in node_states JSONB), NOT on NodeOutput. The actual results go in NodeOutput.listResults (existing field).

Cancellation & partial failure:

Key constraints:

6. Output Display

Two display modes per output node:

Mode Behavior
Gallery All results in one card, auto-layout by media type
Individual Cards (default) One card per result (current clone behavior)

Default is “individual” for backwards compatibility — existing published apps see no change. Missing outputDisplayModes entry = “individual”.

Auto layout by media type (Gallery mode):

Output Type Layout Details
Image Grid 3-col desktop, 2-col mobile. Click opens lightbox.
Video Carousel Prev/next arrows, thumbnail strip. “1/N” counter. Swipe on mobile.
Audio Stacked list Inline player per result with progress bar and duration.
Text Collapsible sections First expanded, rest collapsed with preview snippet.

Single result = current output card (no change).

Settings storage:

// presentationSettings additions
{
  outputDisplayModes: {
    [nodeId]: "gallery" | "individual"
  }
}

Creator configures in output node picker: after checking an output node, a “When multiple results” toggle appears with Gallery / Individual Cards options.


7. Edge Cases

Empty / single item:

Validation rules for areAllInputsFilled():

Credit estimate in app mode:

Progress during fan-out:

Media uploads in loop cells:


Files to Modify

Shared Package

Frontend — Types

Frontend — Editor (Loop Node)

Frontend — Presentation

Frontend — Workflow Store

Frontend — App Runner

Backend — Orchestrator

Backend — Database