Skip to content

Workflow Nodes

Workflows are built from connected nodes that execute in sequence. Each node has a specific type that determines its behavior. This page documents all available node types.


Overview

Node Type Category Purpose
Event Trigger Entry point — reacts to component data events
Recycle Trigger Timer-based recurring trigger
Expression Operation Runs JavaScript code
Condition Control Flow Branches based on a boolean result
Switch Control Flow Multi-path branching
Each Control Flow Iterates over a collection

Triggers

Trigger nodes are the entry points of a workflow. Every workflow must start with exactly one trigger node.

Event

The Event node starts the workflow when a data operation occurs on the component. It listens for a specific event and passes the affected documents to downstream nodes.

Use Event nodes when you need to react to data changes in real time — validating input before it's saved, syncing data to external systems after a create, sending notifications on updates, or cleaning up related records on delete.

Supported Events

Event Description
Create After a new record is created
Before::Create Before a new record is created (synchronous — can modify or block)
Update After a record is updated
Before::Update Before a record is updated (synchronous — can modify or block)
Delete After a record is deleted
Before::Delete Before a record is deleted (synchronous — can block)

For components with multiple schemas, events are scoped per schema (e.g. orders::Create, orders::Before::Update).

Output

When an Event node triggers, it populates the execution context with:

  • $models — array of Model instances representing the affected documents
  • $args — contextual arguments (e.g. current_revision for Update events)
  • $context — metadata about the event (event name, initiator, component reference)
  • $event — shorthand for the event name
  • $initiator — the user or service that triggered the operation

See Expression Context for full details on all available variables.

Before Events

Before:: events run synchronously before the operation is committed. They can:

  • Modify the data using the Decorator object
  • Block the operation by throwing an error
// Modify data before it's saved
Decorator.set({ status: "pending_review" })
// Block the operation
throw new Error("Orders above $10,000 require approval")

Warning

Before:: events block the API response until the workflow completes. Keep them fast and focused.


Recycle

The Recycle node is a timer-based trigger that executes on a fixed schedule. It fetches all records from the component and processes them through the workflow at each interval.

Use Recycle nodes for background jobs that need to run periodically — syncing stale data, sending digest emails, cleaning up expired records, generating daily reports, or polling external APIs for updates.

Configuration

Field Type Description
timer string Schedule interval

Available Intervals

Value Schedule
30min Every 30 minutes
1h Every hour
3h Every 3 hours
6h Every 6 hours
9h Every 9 hours
12h Every 12 hours
1d Once per day
mondays Every Monday
tuesdays Every Tuesday
wednesdays Every Wednesday
thursdays Every Thursday
fridays Every Friday
saturdays Every Saturday
sundays Every Sunday
month_start First day of each month
month_end Last day of each month

Output

The Recycle node queries all records from the component matching the optional ruleset filter. The results are available in child nodes as the models variable.

Ruleset

You can filter which records to process by setting ruleset conditions. For example, to only process active users:

ruleset.active = true

Info

A Recycle node can be paused without removing it. Paused recycles are skipped during scheduled ticks.


Operations

Operation nodes perform actions — running code, sending emails, or making HTTP calls.

Expression

The Expression node executes JavaScript code in a secure sandbox. It is the most versatile node type, with access to the full platform SDK and all workflow context variables.

Use Expression nodes for any custom logic — transforming data, calling the SDK, interacting with external APIs, computing values, or preparing data for downstream nodes. It is the general-purpose workhorse of the workflow engine.

Avoid loops for batch operations

If you need to perform the same operation on many items (e.g. send 100 emails, update 100 records), do not use a for loop inside an Expression node. If the loop fails on item 50, the entire node is marked as failed. When the workflow engine retries, it re-executes the entire expression from scratch — repeating items 1–49 that already succeeded. Use an Each node instead for fault-tolerant batch processing.

Configuration

Field Type Description
expression string The JavaScript code to execute

Execution Context

Expression nodes have access to the full set of runtime variables and libraries documented in the Expression Context page, including:

  • $models, $args, $context, $event, $initiator
  • $config, $globalEnv, $currentEnv
  • $sdk — the platform SDK
  • io — data flow control
  • Platform services (Component, Query, Thunder, Functions, etc.)
  • Utility libraries (Lodash, Moment, Handlebars, Crypto, etc.)

Data Flow

Use the io object to pass data to downstream nodes:

// Pipe data to the next node
io.pipe({ orderId: $models[0].data.uuid, total: 99.99 })
// Pass an array to a connected Each node
io.each($models.map(m => m.data))

Reading Upstream Data

Access data piped from a specific upstream node:

const orderData = io.getPipedDataOf("Process Order")

Empty Return

If an expression returns nothing (bare return or no return statement), child nodes are skipped. This is useful for conditionally stopping execution:

if (!$models[0].data.active) {
    return // stop here — don't execute downstream nodes
}

io.pipe({ id: $models[0].data.uuid })

Error Handling

Error Type Behavior
throw new Error(...) Node fails permanently — no retry
throw RetryableError(...) Node retries with exponential backoff

See RetryableError for retry behavior details.


Control Flow

Control flow nodes determine which path the workflow takes next.

Condition

The Condition node evaluates a JavaScript expression to either true or false and routes execution to the corresponding branch.

Use Condition nodes for simple yes/no decisions — checking if a value meets a threshold, whether a flag is set, or if a user has a specific role. For decisions with more than two outcomes, use a Switch node instead.

Configuration

Field Type Description
expression string JavaScript code that calls resolver.resolve() or resolver.reject()

How It Works

The expression has access to a resolver object with two methods:

Method Effect
resolver.resolve() Marks the condition as true — follows the pass connection
resolver.reject() Marks the condition as false — follows the reject connection
if ($models[0].data.total > 100) {
    resolver.resolve()
} else {
    resolver.reject()
}

Connections

A Condition node has two outgoing connection types:

Connection Type When Followed
pass resolver.resolve() was called
reject resolver.reject() was called

Nodes on the path not taken are marked as skipped.

Available Variables

Condition nodes have access to a limited context:

  • $models (via the model variable)
  • io — for reading upstream piped data
  • moment, _ (Lodash)

Switch

The Switch node evaluates a JavaScript expression that selects one or more named paths to follow. Unlike Condition which only supports two branches, Switch supports unlimited named paths and can activate multiple paths simultaneously.

Use Switch nodes when a workflow needs to fan out based on a category or type — routing orders by fulfillment method, dispatching notifications to different channels, or triggering different processing pipelines based on record status. Each path can carry its own piped data, so downstream nodes receive only what's relevant to their branch.

Configuration

Field Type Description
expression string JavaScript code that calls follow.path(name)

How It Works

The expression has access to a follow object:

Method Description
follow.path(name) Selects a named path to follow. Returns a Path object

Each named path corresponds to a named connection on the node. You can select multiple paths:

if ($models[0].data.type === "subscription") {
    follow.path("billing")
    follow.path("notifications")
} else {
    follow.path("one-time")
}

Path Data

Each path can pipe its own data to downstream nodes:

Method Description
path.pipe(data) Pipe data to nodes on this specific path
path.each(array) Pass an array to an Each node on this path
const order = $models[0].data

if (order.requires_shipping) {
    follow.path("shipping").pipe({ address: order.shipping_address })
}

if (order.has_digital_items) {
    follow.path("digital").pipe({ items: order.digital_items })
}

Connections

Each outgoing connection has a name that matches the string passed to follow.path(). Paths not selected are marked as skipped.

Available Variables

Switch nodes have access to:

  • $models (via the model variable)
  • io — for reading upstream piped data
  • follow — the path selection object
  • Debug — for debugging
  • moment, _ (Lodash)

Each

The Each node iterates over a collection, executing its child nodes once per item. It is fed data from an upstream Expression or Switch node that calls io.each(array) or path.each(array).

Use Each nodes whenever you need to perform the same operation on multiple items — updating records one by one, calling APIs per item, or processing a batch of data.

Why Each Instead of a Loop?

The critical advantage of Each over a JavaScript for loop inside an Expression node is fault-tolerant, resumable iteration.

Consider this scenario: you need to process 100 items.

// DON'T DO THIS for batch operations
const items = await component.find({ perPage: 100 })

for (const item of items.data) {
    await http.post("https://api.example.com/sync", item.data)
}

If the HTTP call fails on item 50, the entire Expression node fails. When the workflow engine retries, it re-executes the whole expression from the beginning — items 1 through 49 are processed again, potentially causing duplicates or wasted work.

// Prepare node: pass items to Each
const items = await component.find({ perPage: 100 })
io.each(items.data)

The Each node processes each item as an independent execution. The workflow engine saves progress after every iteration. If item 50 fails:

  • Items 1–49 are already completed and will not be re-executed
  • Only item 50 is retried
  • Items 51–100 continue processing independently

Rule of thumb

If you're iterating over a list to perform side effects (API calls, emails, data writes), always use an Each node. Reserve Expression loops for pure data transformations that don't have side effects.

How It Works

  1. An upstream node calls io.each(array) to provide the collection
  2. The Each node receives the array and iterates over it
  3. For each item, all child nodes are executed with the current item available via the cursor
  4. The workflow engine saves a cursor after each iteration, enabling resume from the last successful position

Cursor

Inside child nodes of an Each loop, the current iteration data is available through the cursor:

Template Variable Description
{{_cursor.current}} The current item
{{_cursor.current.<field>}} A field on the current item
{{_cursor.index}} The current zero-based index
{{_cursor.total}} Total number of items

In expression nodes, access the cursor via:

const cursor = io.cursor()
const currentItem = cursor.current
const index = cursor.index

Supported Child Nodes

The following node types can be direct children of an Each node:

  • Expression

Error Handling

Each iteration is independent — if one item fails, it does not affect other iterations. Failed iterations retry independently with their own backoff schedule.

This is the key differentiator from a manual loop: the workflow engine tracks which iterations completed, which failed, and which are pending. On retry, only the failed iteration is re-executed — completed iterations are never repeated.

Example

An expression node prepares a list of recipients:

const users = await component.find({ perPage: 100 })
io.each(users.data.map(u => ({ email: u.data.email, name: u.data.name })))

The connected Each node iterates, and its child Expression node processes each item:

const cursor = io.cursor()
const user = cursor.current

// process each user individually
await component.update(user.uuid, { last_notified: new Date().toISOString() })

Node Execution Lifecycle

Every node transitions through the following statuses during execution:

pending → running → completed
                  → errored (may retry → running)
                  → skipped (condition/switch path not taken)
Status Description
pending Node is waiting to execute
running Node is currently executing
completed Node finished successfully
errored Node failed — may retry depending on error type
skipped Node was not executed (unused branch in Condition/Switch)

Retry Behavior

Expression nodes support automatic retries:

  • Retryable errors (timeouts, network issues, RetryableError) are retried with exponential backoff
  • Non-retryable errors (syntax errors, validation failures) fail immediately
  • Child nodes of an Each loop retry independently per iteration

Connections

Nodes are linked by connections that define the execution flow. Each connection has a type that determines when it is followed:

Connection Type Used By When Followed
(default) Operation, Event, Each Always — after the node completes
pass Condition When resolver.resolve() is called
reject Condition When resolver.reject() is called
(named) Switch When follow.path(name) selects this path