Flowlib

Execution Model

How Flowlib flows run — scheduling, data passing, templates, iteration, branching, and how it compares to tools like n8n.

Flowlib takes a different approach to workflow execution than most automation tools. This page explains how a flow is scheduled, how data moves between nodes, and how iteration works.

How a flow runs

When you trigger a flow, Flowlib follows this sequence:

  1. Load the flow — fetch the flow definition (nodes + edges) from the database
  2. Build the dependency graph — use the edges to work out which nodes depend on which
  3. Schedule nodes as they become ready — a node is ready once every one of its parents has reached a terminal state (completed, failed, or skipped)
  4. Store the result — save the execution trace: every node's input, output, timing, and status

Scheduling and concurrency

Flowlib uses a ready-set scheduler. Rather than walking the graph in a single fixed line, it repeatedly launches whichever nodes are ready, up to a concurrency limit (8 by default).

The practical consequence: independent branches run in parallel. If a flow fans out from one node into three unrelated chains, those chains progress simultaneously. Ordering is guaranteed only where you have expressed it — through edges. Two nodes with no dependency path between them have no guaranteed order relative to one another.

The concurrency limit is not currently configurable. If two nodes must run in a specific order, connect them with an edge — that is the only way to express sequencing.

How edges work

Edges are the connections between nodes on the canvas. They serve two purposes:

  1. Execution order — a node waits until all of its parents have finished
  2. Data availability — a node's incoming data is assembled from its upstream nodes

Direct parents vs. ancestors

When a node runs, its incoming data contains two categories of upstream output:

  • Direct parents — nodes with an edge directly into this node. Their outputs appear as top-level keys.
  • Indirect ancestors — nodes further back in the chain. Their outputs are grouped under a previous_nodes key.

For a flow of Fetch User → Transform → Send Email, when "Send Email" runs:

  • transform is a direct parent → available as {{ transform }}
  • fetch_user is an indirect ancestor → available as {{ previous_nodes.fetch_user }}

A manual trigger parent is the one exception: its declared inputs are spread flat into the top level rather than nested under the trigger's own key. A trigger input named topic is referenced as {{ topic }}, not {{ trigger.topic }}.

How data passes between nodes

Every node produces an output when it runs. That output is stored and made available to downstream nodes as part of their incoming data object — a JSON object keyed by each upstream node's reference ID.

This object is assembled for every node before it runs, whether or not that node's configuration mentions any of it. What is opt-in is use, not availability: template expressions read from this object, and a node that references nothing still receives it in full.

Reference IDs

Each node has a reference ID derived from its label, normalized to snake_case:

  • "Fetch User" → fetch_user
  • "Send Email" → send_email
  • "API Response" → api_response

These become the keys in the incoming data object. When a new node would collide with an existing reference ID, the editor appends a counter starting at 2 (some_node, some_node2).

Example

A node called "Send Email" with two upstream nodes receives:

{
  "fetch_user": { "id": 123, "name": "Alice", "email": "alice@example.com" },
  "generate_subject": "Welcome aboard, Alice!"
}

"Send Email" can now reference {{ fetch_user.email }} or {{ generate_subject }} in its config.

Template expressions

Node config fields support JavaScript expressions wrapped in double curly braces: {{ expression }}. These are evaluated against the incoming data object before the node runs.

Simple references:

  • {{ fetch_user.name }}"Alice"
  • {{ fetch_user.id }}123

Full JavaScript:

  • {{ users.filter(u => u.active).length }}3
  • {{ fetch_user.name.toUpperCase() }}"ALICE"

Two evaluation modes:

ModeExampleReturns
Pure expressionA field set to {{ fetch_user }}The raw value (object, array, number)
Mixed template"Hello {{ fetch_user.name }}, welcome!"A string with expressions interpolated

If the entire field is a single {{ }} block, you get back the raw JavaScript value. If the field mixes text with expressions, the result is always a string.

Expressions run in a sandboxed JavaScript environment (QuickJS via WebAssembly), isolated from the host. An expression is treated as a function body, so return is supported. Async expressions are rejected, and evaluation is bounded by memory and time limits.

Built-in helpers: json(), first(), last(), keys(), values(), exists(), isArray(), isObject(). The full context object is also available as $input, which is useful when an upstream reference ID collides with a JavaScript global.

The two modes handle failure differently. A failing pure expression fails the node. A failing expression inside a mixed template resolves to an empty string and the node continues. If a templated string arrives with a suspicious gap in it, this is usually why.

Iteration and the data mapper

Most automation tools (like n8n) pass arrays of "items" through the entire workflow, processing each item at every node. Flowlib works differently.

In Flowlib, nodes process single values by default. If a node returns an array, the downstream node receives that array as one value. There is no automatic item-by-item processing.

When you do need iteration, enable the data mapper on a specific node: take this array, and run this node once per item.

Mapper configuration

OptionValuesNotes
expressionA JS expression resolving to the arraye.g. {{ fetch_users }}
modeauto (default), iterate, reshapeSee below
outputModearray (default), object, first, last, concatHow results are packaged
keyFieldA field pathOnly for outputMode: object — which field becomes the key
concurrency1 (default) to 50Parallel iterations
onEmptyskip, errorBehaviour when the expression yields an empty array

Modes:

  • auto — iterate if the expression returns an array, otherwise run once
  • iterate — always iterate; fails the node if the expression does not return an array
  • reshape — always a single run; if the expression returns an array it is wrapped as { items: [...] }

Iteration context

During each iteration, the node receives all the usual upstream data, plus the current item's properties spread into the context, plus an _item object with index, total, first, last, iteration, and the raw value.

Iterating over a list of users, each iteration can reference {{ name }} and {{ email }} directly, plus {{ _item.index }} for position.

Concurrency

With concurrency: 1 (the default), items process one at a time. Higher values process items in parallel batches. This is separate from the flow-level scheduler concurrency: the mapper controls parallelism within a single node.

Branching

If/Else

The If/Else node evaluates a condition and routes execution to one of two branches. Nodes on the inactive branch are marked skipped.

If/Else is a passthrough — its output equals its input. It does not transform data; it decides which path runs.

Switch

Switch extends this to multiple branches. You define cases with JavaScript expressions, and execution routes to the matching case. A default branch catches anything unmatched.

How skipping works

When a branching node runs, nodes connected to inactive branches are marked skipped — but only if all of their incoming edges come from skipped or inactive sources. A node with another live path into it still runs.

Triggers

A flow can contain multiple trigger nodes — manual, cron, or webhook. When a run starts from one specific trigger, every other trigger node and its downstream branch is skipped before execution begins. A manual run that indicates no active trigger runs all of them.

Agent nodes

Agent nodes run an iterative loop rather than executing once:

  1. Send a prompt to an LLM along with the available tool definitions
  2. If the LLM requests tool calls, execute them and feed the results back
  3. Repeat until the stop condition is met
  4. The agent's final response becomes the node's output

Stop conditions are explicit_stop (default), tool_result, or max_iterations, and the iteration ceiling defaults to 10. Agent nodes also retry transient errors internally (3 attempts by default) — the only retry behaviour in the system.

Failure, pausing, and cancellation

Failure is a hard stop. When a node fails, the scheduler stops launching new nodes and the run is marked failed; in-flight nodes are allowed to settle. There is no per-node continue-on-fail and no error-output branch — a failure ends the run.

Runs can pause for batch work. A node that submits a batch job returns a pending state, and the run parks in PAUSED_FOR_BATCH rather than blocking. A later resume picks it up and continues from where it stopped. This is what allows long-running provider batch APIs to work without holding a process open.

Runs can be cancelled. Cancellation propagates to in-flight nodes through an abort signal, and the scheduler stops launching new work.

State and execution traces

Every node execution produces a trace record: its incoming data, output, status (completed / failed / skipped), any error message, and timing. Traces are visible in the UI and are the primary debugging tool.

Two persistence strategies are available:

  • per-node (default) — each trace is written as the node completes, giving live progress in the UI
  • per-run — traces are buffered in memory and flushed once at the end, trading in-progress visibility for far fewer database writes

A node's resolved config params — the post-template values the node actually ran with — are passed to the action and to plugin hooks, but are not written to the trace. The trace records what went into the node, not what its parameters resolved to.

Plugin hooks

Plugins can intervene at several points in the lifecycle: cancelling a run before it starts, overriding flow inputs, mutating a node's resolved params, skipping a node, and rewriting a node's output. This is the extension point hosted deployments use for quota enforcement and usage metering.

How Flowlib differs from n8n

If you are coming from n8n, the biggest conceptual difference is how data flows between nodes.

n8n: items flow through the whole pipeline

In n8n, every node receives an array of items, and most nodes automatically process every item. The items array is the central data structure flowing through the workflow.

  • If a node produces 10 items, the next node runs 10 times automatically
  • Stopping automatic iteration requires a specific loop or batch node
  • All nodes in the chain see and operate on the same items array

Flowlib: nodes produce values, downstream nodes reference them

In Flowlib, each node produces a single output value, which may be an object, array, string, or number. Downstream nodes choose what to reference using template expressions.

  • A node returning an array of 10 users passes that array as one value
  • The next node decides what to do with it — use the whole array, pick a field, or enable the mapper to iterate
  • Iteration is opt-in and per-node, not a workflow-wide behaviour

Key differences at a glance

n8nFlowlib
Core data modelArray of items flows through every nodeEach node produces a single output value
IterationAutomatic — every node processes every itemOpt-in — enable the mapper on specific nodes
Data referencingImplicit — items array is always availableExplicit — use {{ node_name.field }} templates
Loop controlDedicated loop node wraps a sub-workflowMapper config on any node (expression + concurrency)
ParallelismMainly at workflow levelBoth — independent branches run concurrently, and the mapper adds per-node parallelism
Scope of iterationAffects the entire downstream chainAffects only the node with the mapper enabled

The Flowlib approach gives you more control: you decide which node iterates, how many items run in parallel, and how results are packaged. The tradeoff is that you must be explicit about data references — which also makes flows easier to reason about, since there is no hidden implicit passing.

On this page