# Execution Model (/docs/execution-model)



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 [#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 [#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.

<Callout type="warn">
  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.
</Callout>

## How edges work [#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 [#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 }}`

<Callout type="info">
  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 }}`.
</Callout>

## How data passes between nodes [#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 [#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 [#example]

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

```json
{
  "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 [#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:**

| Mode            | Example                                   | Returns                                |
| --------------- | ----------------------------------------- | -------------------------------------- |
| Pure expression | A 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.

<Callout type="warn">
  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.
</Callout>

## Iteration and the data mapper [#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 [#mapper-configuration]

| Option        | Values                                                 | Notes                                                       |
| ------------- | ------------------------------------------------------ | ----------------------------------------------------------- |
| `expression`  | A JS expression resolving to the array                 | e.g. `{{ fetch_users }}`                                    |
| `mode`        | `auto` (default), `iterate`, `reshape`                 | See below                                                   |
| `outputMode`  | `array` (default), `object`, `first`, `last`, `concat` | How results are packaged                                    |
| `keyField`    | A field path                                           | Only for `outputMode: object` — which field becomes the key |
| `concurrency` | 1 (default) to 50                                      | Parallel iterations                                         |
| `onEmpty`     | `skip`, `error`                                        | Behaviour 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 [#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 [#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 [#branching]

### If/Else [#ifelse]

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]

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 [#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 [#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]

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-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 [#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

<Callout type="info">
  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.
</Callout>

## Plugin hooks [#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 [#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 [#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 [#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 [#key-differences-at-a-glance]

|                        | n8n                                         | Flowlib                                                                                |
| ---------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------- |
| **Core data model**    | Array of items flows through every node     | Each node produces a single output value                                               |
| **Iteration**          | Automatic — every node processes every item | Opt-in — enable the mapper on specific nodes                                           |
| **Data referencing**   | Implicit — items array is always available  | Explicit — use `{{ node_name.field }}` templates                                       |
| **Loop control**       | Dedicated loop node wraps a sub-workflow    | Mapper config on any node (expression + concurrency)                                   |
| **Parallelism**        | Mainly at workflow level                    | Both — independent branches run concurrently, and the mapper adds per-node parallelism |
| **Scope of iteration** | Affects the entire downstream chain         | Affects 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.
