Elizon Docs
Workflows

Building a Workflow

The node types a workflow is built from, and how your input reaches them.

A workflow is a graph of nodes joined by edges. You assemble it in Studio; this page covers the parts that decide what your integration has to send and what it gets back.

Node types

NodeWhat it does
Manual triggerEntry point for a run started by hand in Studio, or through the API.
RecurrenceEntry point for a run started on a schedule — see Triggers.
HTTP RequestEntry point for a run started by an inbound webhook — see Triggers. Also declares the payload the run must carry.
Form TriggerEntry point for a run started by a person submitting a hosted form — see Triggers.
AI AgentRuns one of your configured agents as a step.
Team StepRuns a configured team as a step.
AI PromptA one-off prompt to a model, without a saved agent behind it.
ClassifyRoutes the run by classifying text into one of a set of labels.
If / ElseBranches on a condition.
LoopIterates over an array, running its body once per item (or per batch).
VariableSets a named value that later nodes can read.
Edit FieldsBuilds an object out of named fields, from expressions or fixed values.
Human ReviewPauses the run for a person to approve — see Human review.
NoteA canvas annotation. Never executes.

Declaring what your payload has to look like

Payload declaration belongs to the trigger, not to a separate node. There is no Input node.

An HTTP Request trigger declares fields by name, type (string, number, boolean, or object), and whether each is required. Those declarations are checked before the first node runs, so a bad payload fails immediately and cheaply rather than halfway through a run that already cost you tokens.

If validation fails, the run is closed as failed and the error you get back names the offending fields.

The fields describe the request body, so a webhook call is validated against the body you POSTed rather than against the { body: … } envelope the run carries it in. A Studio manual run and a POST /workflows/:id/run call are validated against their payload directly.

An HTTP Request trigger that declares no fields accepts anything, including an empty payload — and so does a graph with no HTTP Request trigger at all.

The check runs on every run of that graph, whichever trigger started it. A graph that carries an HTTP Request trigger with required fields therefore fails a scheduled run (whose payload is empty) and a form submission (which is checked against the HTTP trigger's fields as well as the form's own) — if a graph is reachable from more than one trigger, declare only fields every path can supply.

A Form Trigger declares its own, richer field list (a label as well as a name, eleven field types, options for the choice types). Those are validated by the form's own ingress at submission time, so the person filling the form sees per-field errors on the page rather than a run that fails somewhere they will never look — see Triggers.

Referencing values: the {{ }} syntax

Node configuration fields can interpolate values from the run using {{ }} tokens. There are exactly four namespaces:

TokenReads
{{ trigger.<path> }}The payload the run was started with.
{{ upstream.<nodeId>.output }}The output of a node that has already run.
{{ variables.<name> }}A value set by a Variable node.
{{ loop.item }}, {{ loop.index }}The current element and position inside a Loop.

This is a substitution syntax, not an expression language — dot-path lookup only. There are no operators, no function calls, and no way to evaluate arbitrary code, by design.

trigger.* is shaped by which trigger started the run

A run started through POST /workflows/:id/run binds your request's input object directly, so {{ trigger.orderId }} reads input.orderId. A run started by a webhook binds the request body one level down, under body — so the same field is {{ trigger.body.orderId }}. A form submission uses that same body envelope, keyed by each field's name: {{ trigger.body.email }}. A scheduled run has an empty trigger payload. If a graph is meant to be reachable from more than one trigger, this difference is the thing to get right.

An unresolvable reference is a real error, not a silent empty string: a node that reads {{ variables.total }} where no such variable was ever set fails with a message naming the token, rather than proceeding on undefined.

Edit Fields: setting named values

An Edit Fields node builds an object out of named fields — the direct way to reshape, rename, or assemble data mid-graph without pressing an AI Prompt or a Variable node into service for it.

Each row is a name and a value, and each row's value is independently one of two things:

  • Expression (the default) — resolved with the {{ }} syntax above, against the same four namespaces. {{ upstream.classify-1.output.assignedCategories }} renames another node's field into one of yours.
  • Fixed — used verbatim. Text that happens to look like {{ … }} stays literal text rather than being resolved, which is what makes a fixed row safe for values that contain braces.

Rows declare no type. A row that resolves to the number 92 reaches downstream nodes as the number 92, not as "92".

The node's output is an object of exactly the rows you set — Keep only set fields. Turning that setting off (n8n's merge mode) is not available: a node here has no single implicit input to merge onto, since it may have any number of upstream nodes and none of them is privileged. Studio keeps the setting locked on, and a node that reached the canvas with it off — hand-crafted through the API, or restored from a version saved elsewhere — fails at run time with that explanation rather than silently dropping the data you asked to keep — tick the setting back on in the panel to repair it. To carry data through, add a row that references it — for example {{ upstream.<nodeId>.output }}.

A row whose expression cannot be resolved fails the node at run time and names the offending row — an expression can only be judged against real run data, so Studio cannot warn you about it while you author. What it does check as you type is the name side: a row with no name (which the node also refuses at run time), a name another row already sets, and a name no downstream {{ }} reference could address.

What comes back

A run's output is derived from the graph, not assembled by a node you place. There is no Output node.

When a run ends, Elizon looks at the nodes that terminate the graph it actually walked — nodes with no outgoing edge, that completed:

  • Exactly one — its own output, unwrapped. That is any JSON value, not necessarily an object: a node whose output is a string returns a string.
  • Several — an object keyed by node id, so two independent end points are both reported rather than one of them silently winning.
  • None (every terminal node was skipped or errored, or the graph is empty) — output is null.

Note nodes never count, however they are placed on the canvas, and neither do the nodes inside a Loop's body — the Loop node itself is the end point of that path.

If you want a specific result shape from a synchronous call, end the graph on a node that produces it — an Edit Fields node is the direct way to do that.

Loops have a ceiling

A Loop node stops after a bounded number of iterations — 100 by default on a self-hosted deployment, configurable via the WORKFLOW_LOOP_ITERATION_CEILING environment variable up to a hard cap of 1000.

Hitting that bound is not a failure. The run ends with status loop_limit_reached, keeps whatever the loop did process, and reports no error — the bound is a declared safety limit, and it held. Treat it as its own outcome in your integration rather than folding it into either completed or failed.

Where to go next

  • Triggers — how a run actually starts.
  • Publishing — making the graph runnable from outside Studio.