Skip to content

Workflows

A workflow is a second schedulable entity alongside a job. Where a job performs one HTTP call or one script run, a workflow executes a graph of blocks: a sequence of nodes that can branch, loop, and pass data forward from one step to the next.

Reach for a workflow when the work is multi-step logic rather than a single call — call an API, parse its response, branch on a field, iterate the result one page at a time, and finish by recording success or failure. A single HTTP or script job cannot express that.

A workflow is its own entity with its own schedule and its own permission keys. See Job Types for the job side of the system.

A workflow’s graph is built from these node types:

Type Purpose
http Make an HTTP call, using the same request-building machinery as an HTTP job — method, URL, query params, headers, body and auth.
script Run a script under one of the runtimes script jobs use: Bash, JavaScript, TypeScript or Groovy. Uses the same execution machinery as a script job — temp file, process group, timeout, and output capture/truncation.
parse Parse a prior step’s output as JSON or XML — or auto-detect the format — into structured data other blocks can reference.
if A branch node with two outgoing edges, true and false.
switch A branch node that routes to the first case whose value equals the chosen value, and otherwise to its default branch. See Switch.
wait Pauses the run for a number of seconds, minutes or hours, up to 24 hours. The duration can be a {{template}} such as a Retry-After value. See Wait.
set_variable Stores run-scoped values for later blocks in the run. These are not global variables. See Set variable.
container A nested-execution node with two kinds: for_each and while. See Branching and containers.
break / continue Loop-control nodes usable inside a container, with the same semantics as break and continue in a general-purpose language.
success / fail Terminal nodes.

A script block’s body is edited in the same code editor as a script job. See Editing a script in the browser.

A script block can also declare volumes in its data.capabilities, in the same {"volumes": [{"name": "...", "mode": "ro"|"rw"}]} shape a script job uses, and picks them in the block’s configuration panel. The block gets the same mounts and ZILEAN_VOLUME_<NAME> variables as a script job. Only script blocks accept a declaration: saving a workflow with a non-empty capabilities on any other block is rejected with 400. Declarations are checked again each time the step runs, and a volume that is no longer usable fails the step without retrying. Saving a graph with a script block still needs jobs:script.

A run ends in exactly one of three terminal statuses — succeeded, failed or cancelled. Cancelled is not reachable from a block: it happens when a workflow is disabled or deleted, or its group is disabled, while a run is mid-flight. A cancelled run records no error step; only a failed run records one.

The workflow editor is a canvas. A new workflow starts with a single Start block, which marks where every run begins; it cannot be removed and has nothing to configure.

Add blocks from the palette by clicking one — it lands in the middle of the visible canvas — or by dragging it onto the spot you want. The first block you add is connected to Start automatically. Connect the rest by dragging from a block’s bottom handle to the next block’s top handle; an if block has two bottom handles, true on the left and false on the right. Select a block to edit its configuration in the side panel.

Every block other than Start needs an incoming connection. Until the graph is complete, the editor names what is missing and Save stays disabled. Start itself is an editor marker rather than a block type: the run begins at whichever block Start points to.

A for_each or while container appears on the canvas as a resizable box. The blocks inside the box are its body; the blocks outside it are not. To put blocks in a body:

  • drag a palette entry and release it over the box;
  • select the box, then click palette entries — each lands below the last block in the body (this is also how to add break and continue);
  • drag a block that is already on the canvas into the box.

While you drag, the box the block will land in is highlighted and labelled Drop into …. Whether a block goes in is decided by where its centre is when you release it, and the box grows to fit a block placed near its edge, so a block is never left half inside. Dragging a block out until the box is no longer highlighted moves it back onto the canvas. Deleting a container deletes its body with it.

Connections stay on one level: blocks inside the same container can be wired together, and blocks on the canvas can be wired together, but the editor won’t connect across a container’s border. If you drag a wired block into or out of a box, its old connection is drawn as a red dashed line and Save stays disabled until you reconnect it.

if gives the graph two branches. The node’s condition is evaluated and the run continues down the true edge or the false edge, so one workflow can take different paths depending on what an earlier step returned.

A container nests a set of blocks and executes them repeatedly. There are two kinds:

  • for_each — iterate over a list, running the container’s body once per item and collecting one output per pass.
  • while — run the container’s body for as long as a condition holds.

A container’s body is the set of blocks nested inside it; the editor draws it as a box around them (see Building a loop body). The body has exactly one entry block — the one no other body block connects into — and a run enters the body there without needing a connection from the container itself. break and continue apply inside a container — break leaves the loop, continue moves to the next iteration.

While a for_each loop is open, the run stores a copy of its items. Nested loops each keep their own copy, and the copies held by all open loops together are capped at 2 MiB; a run that exceeds the cap fails with an error stating the size it reached and the limit.

A switch block compares one value against a list of cases and continues down the branch of the first case that matches.

  • Switch on is the value being compared, usually a reference such as {{fetch.status}}. A reference that makes up the whole field keeps its type. Write the reference with braces. A bare name such as fetch.status is compared as the literal text, and the editor warns about it. See Conditions.
  • Each case has a value, which is fixed text or a reference, and its own connector on the block. A default connector always exists.
  • Cases are checked from top to bottom. The first case whose value equals the chosen value wins, and there is no fallthrough: only that case’s branch runs.
  • Numbers and true/false values compare by value, so a case of 1 matches a chosen value of 1, "1" or 1.0. Other values compare as text.
  • If no case matches, the run continues down default. An unresolved {{...}} in the chosen value stays as literal text, so it normally takes default.
  • If the chosen connector is not connected to anything, the branch ends there, the same as an unconnected if branch: inside a container it finishes that pass, and at the top level it ends the run as succeeded.
  • To send two values to the same block, add a case for each and connect both connectors to that block.
  • Two cases with the same fixed value are rejected on save, because the later one could never match.
  • Removing a case removes the connection on its connector.

A wait block pauses the run, then continues to the next block. It has a Duration and a Unit of seconds, minutes or hours.

  • The duration is a positive whole number, or a reference that resolves to one, such as {{fetch.headers.retry_after}} (a number or numeric text).
  • The longest single wait is 24 hours. A fixed duration over the limit is rejected on save. A referenced duration is checked when the block runs.
  • A referenced duration that does not resolve, is zero, negative, fractional or not a number, or exceeds 24 hours fails the run at that step, with no retry.
  • A wait has no name and stores nothing. It needs a connection to a next block, unless it is the last block in a container’s body, where the wait comes before the container’s next pass.
  • Waits work inside for_each and while containers. A wait in a while loop repeats on every pass, so a run can last a very long time.
  • Nothing is held open during the wait. The run resumes when the time is up.

While a run waits, it stays running. See Concurrency and overlap for how the wait interacts with the overlap lock and with disabling the workflow.

A set_variable block writes one or more values into the run so later blocks can use them. It needs a name, like most blocks, and holds 1 to 50 assignments, each with a name, a value, a type and a mode.

  • An assignment name is letters, digits and underscores, starts with a letter or underscore, and is unique within the block.
  • Each assignment’s Template / Expression toggle picks how its value is worked out. Template is the default: the value is text that can contain references, the references are replaced by their values, and text with no reference is a fixed value.
  • A block named vars with an assignment count is read as {{vars.count}}, the same way every other block’s output is read. There are no bare top-level names.
  • The values are local to the run. A Set variable block never creates or changes a global variable.
  • Assignments in one block cannot refer to each other, because the values are stored together after all of them are worked out. Read them from a later block instead.
  • A value derived from a secret variable is stored redacted, so later blocks see it masked. Reference the secret variable directly (for example {{secret_var}}) in the block that needs it.
  • Coercing a secret-derived value to number, boolean or json is rejected: the assignment fails, following the block’s Continue on error setting.
  • Keep earlier outputs and Continue on error work as they do on parse.

In Expression mode the value is evaluated as an expression: {{fetch.body.total}} * 2, upper(trim({{user.name}})), {{page.next}} ?? 0.

  • References are still {{path}} placeholders, but each one is bound as a value rather than spliced in as text. An unresolvable reference is nil, so {{x}}.a fails when the expression runs; write {{x}}?.a for nil-safe access. A reference cannot appear inside a string literal: "a {{x}}" is not substituted. Use concatenation instead, for example "a " + string({{x}}). A reference also cannot be used as a member name; use index access instead, as in {{y}}[{{x}}].
  • Operators: + - * / % **, == != < > <= >=, && || !, the ternary a ? b : c, ??, contains, startsWith, endsWith, in, the range a..b, member and index access, ?., and slices such as x[a:b]. The matches operator is not supported. + also joins strings. A / always yields a float, while % requires integers — JSON numbers are floats, so write int(x) % 2. Division by zero and a result that is not finite (Inf, NaN) fail the step. Integer arithmetic that overflows fails with an integer overflow error.
  • Functions, and only these: len, upper, lower, trim, split, join, string, int, float, abs, round, floor, ceil, min, max. round takes one argument — there is no precision argument. map, filter and the other collection functions are not available, and neither are $env, let, pipes or the if … else … end form. A function name used without parentheses is an error.
  • Limits: an expression is at most 1024 bytes and 200 nodes. An integer result must be within ±2^53 (the safe integer range). The result is capped at the step output size, 8 KiB of JSON. Evaluation also runs under a memory budget of roughly 1000 elements, so a large range such as 1..1000 fails. String operations are not counted by this budget. Instead, the referenced values together may not exceed 64 KiB, counting each use of a reference, so using one reference several times multiplies its size. Beyond that, the step fails with an error. The cap counts the whole referenced value, so referencing a large object just to read one field fails; reference the field directly instead (for example {{fetch.body.total}} rather than {{fetch.body}}.total).
  • split returns at most 1000 items, and the result of join is limited to 8 KiB. Otherwise the step fails with an error. split(x, "") returns one item per character, so it fails for a string longer than 1000 characters. Pass a count as the third argument, as in split(x, "", 100), to get at most that many items, the last one holding the rest. The count must be a whole number.
  • An expression cannot reference a secret variable: the step fails with cannot use a secret variable in an expression.
  • A syntax error, an unknown function or an unknown name is rejected when the workflow is saved, with an error naming the block and the assignment. An error during evaluation fails the step the same way a failed type conversion does; with Continue on error on, it stores {ok: false, error} instead.

Each assignment’s type controls the stored value, after either mode has produced it:

Type Result
auto The value as resolved. A value that is a single reference keeps its type. This is the default.
string Text. Objects and arrays become JSON text.
number A number, or the step fails.
boolean true or false, or the step fails.
json Text parsed as JSON, or the step fails. A value that is already an object or array is kept.

In Template mode, a value made of a single reference that cannot be resolved stays as literal {{...}} text under auto, and fails under number, boolean and json. With Continue on error on, a failed conversion stores {ok: false, error} instead of failing the run.

Inside a container’s body a stored value is visible to later blocks in the same pass. The next pass starts empty, and nothing stored in a body is visible after the container finishes. If the Set variable block is the last block in the body that produces output, its values become that pass’s entry in the container’s output array, which is a way to shape each pass’s result.

A run carries a context: a key/value bag that blocks read from and write into. Each block that produces output stores it in the context under the block’s name, and any text field in a later block can read it with a {{...}} reference — {{fetch.status}}, {{orders.items[0].id}}. When the run reaches that field, the reference is replaced by the value itself.

Every block except wait, break, continue, success and fail needs a name, and a block’s name is how its output is referenced. A name is letters, digits and underscores, starts with a letter or underscore, and is unique in the workflow; trigger is reserved. The editor names each block as it is added — http_1, parse_1, if_1, switch_1, set_variable_1, for_each_1 and so on — and you can rename it. The editor also avoids names already used by a global variable.

Source Reference What it holds
The run’s trigger {{trigger.source}}, {{trigger.at}}, {{trigger.input}} What started the run (cron, rate, one_off, manual or webhook), when it started, and the input passed to a manual run.
http block {{name.ok}}, {{name.status}}, {{name.body}}, {{name.error}} Whether the call succeeded, the HTTP status code, the response body as text, and the error message when it failed.
script block {{name.ok}}, {{name.exit_code}}, {{name.stdout}}, {{name.stderr}}, {{name.error}} Whether the script exited 0, its exit code, its output streams as text, and the error message when it failed.
parse block {{name}}, {{name.field}}, {{name.list[0]}} The parsed value itself — its shape is whatever the JSON or XML contained.
set_variable block {{name.assignment}} The value of each assignment, converted to its type. For example, {{vars.count}}.
Container, after it finishes {{name}} An array with each pass’s last output.
Inside a container’s body {{name.item}}, {{name.index}} The current item (for_each only) and the pass number, counting from 0.
Global variable {{variable}} The variable’s value. Only a bare name — a global has no fields.

if, switch, wait, break, continue, success and fail store nothing.

An http block’s body and a script block’s output are plain text, so {{fetch.body.items}} does not reach into them. Put a parse block after the call with {{fetch.body}} as its input, then reference the parsed value: {{parsed.items}}.

A block can use the outputs of the blocks that run before it on its path from Start, the trigger, and global variables. Inside a container’s body it can also use that container’s item/index, the outputs of body blocks before it, and everything available to the container itself. Blocks inside a body are not visible outside it — after the loop, use the container’s own output array.

A reference is looked up in this order: the enclosing containers’ item/index, then block outputs, then global variables. A block whose name matches a global variable therefore hides that variable everywhere in the workflow; the editor warns when a name does this.

Each block’s config panel has an Available data list showing every reference that block can use at its place in the graph; click one to copy it. Typing {{ in any field offers the same list, and typing {{fetch. narrows it to that block’s fields.

http, script, parse, set_variable and container blocks have a Keep earlier outputs setting, on by default. Turning it off makes the block’s output replace what is stored at its level instead of joining it, so earlier outputs — and, at the top level, the trigger — are no longer available to the blocks after it. For example, with it off on a parse block that reads {{fetch.body}}, the blocks after it see only the parsed value, and the large raw response is not carried through the rest of the run. Global variables are unaffected.

  • In a text field — a URL, a header, a body — a number, boolean, object or array is written as JSON text.
  • In a field that is used as a value — an if or while condition, a for_each source, a parse input, a script block’s environment values — a reference that makes up the whole field keeps its type, so {{parsed.items}} stays an array and {{fetch.status}} stays a number.
  • A reference that cannot be resolved — a typo, or a block on a branch that did not run — is left as its literal {{...}} text rather than becoming empty. In an assignment’s Expression mode it is nil instead.

An if block (or a while container) compares a Field with a Value using an operator such as ==, >, contains, exists or is_empty. Both inputs follow the rules above. Field is almost always a reference — {{fetch.status}} — and Value is usually fixed text such as 200, though it can be a reference too.

Field needs the braces. fetch.status without them is compared as the literal text “fetch.status”, so == 200 is never true and exists is always true; the editor warns when a condition’s Field, a switch block’s Switch on value, or a for_each source contains no reference.

A workflow supports the same schedule types as a job:

schedule_type Fires
cron On a cron expression.
rate At a fixed interval.
one_off Exactly once, at run_at.
manual Never on its own — started only by an explicit Run now action.

The cron, rate and one-off mechanics are shared with jobs — expression format, timezone handling, the reconciler, and the fields each type uses. See Scheduling for the full description rather than a restatement here. manual is the workflow-only addition: a workflow with no schedule at all, started only when someone asks for it.

A one-off workflow fires exactly once, at run_at, using the same run_at-based mechanism a one-off job uses. It waits for its scheduled time before it is considered to have started, and it does not fire early. Until that time arrives it is a schedule waiting for its moment — not a stalled or abandoned run. The run and its scheduled marker commit together, so a failed insert leaves the workflow unscheduled and it can be retried.

After it runs, one_off_after_run decides what remains: keep (the default) leaves the row disabled for inspection, and delete removes it once the run finishes. See Scheduling.

A workflow run is sequential. There is no pooling and no equivalent of a job’s pool_count: one firing produces one run, and its blocks execute in the order the graph dictates.

Overlap is handled at the whole-workflow level with a single lock, using the same skip/allow vocabulary a job’s overlap policy uses. Rather than jobs’ per-pool-slot locking, the choice applies to the workflow as a whole: a new firing either starts a concurrent run or is skipped because one is already in progress.

A run that is waiting in a wait block is still in progress. With the skip policy the workflow stays locked until the wait ends, so scheduled firings during the wait are skipped. With allow, no lock is held and new firings start their own runs. A waiting run is not treated as stalled while it waits; if its next step has not started within about an hour of the wait ending, it is failed as stalled.

Disabling a workflow, or its group, during a wait does not stop the wait. The run is cancelled when the wait ends and its next step is about to run. Deleting the workflow removes its runs. If the workflow is re-enabled before the wait ends, the run continues normally.

For looping, retry and pooling on the job side, see Loop, retry & pooling. Branching and iteration are expressed as blocks in a workflow’s graph instead, and there is no pooling to configure.

Every workflow run is recorded with its status — running, succeeded, failed or cancelled. Every block that executes within a run is recorded as its own step, with a status, timing and output. In the run detail, each step is labelled with its block’s name, or its block type for an unnamed block such as wait, success or fail, with the block’s internal id shown as secondary text. Labels come from the workflow as it was when the run started, so later edits or renames do not change how an older run reads.

When a workflow is deleted, disabled, or its group disabled while a run is in flight, the run ends as cancelled at its next step. No error step is added to the step list for that. Failures, such as a step that cannot be dispatched or an unknown block, end the run as failed and record an error step.

A run that is paused in a wait block stays running and shows Waiting until the time it resumes, so it does not read as stuck. The wait step’s output records that resume time.

That gives a per-step run history shaped like the graph, rather than a job’s flat attempt sequence. The runs list and the run detail view are where you read it. See Monitoring jobs for the monitoring surfaces available across the system.

Action Permission
List and get workflows, their runs, and their run steps. workflows:read
Create, update, delete, enable/disable, and manually run a workflow. workflows:write
Save a workflow whose graph contains any script block. jobs:script, in addition to workflows:write

workflows:read and workflows:write are distinct from jobs:read and jobs:write because a workflow is its own schedulable entity, not a scheduled_jobs row. Admin carries both keys; Read-Only carries workflows:read only — authoring a workflow that could contain a script block is treated at the same tier as script-job authoring, so it stays out of Read-Only by default. See Permissions for the full key list and built-in role defaults.

The jobs:script check happens after the graph is decoded, because whether a workflow contains a script block is only known once its body has been parsed. That mirrors the script-job gate, which is enforced inside the create and update handlers for the same reason.

Opting a secret variable into a script block’s environment

Section titled “Opting a secret variable into a script block’s environment”

A script block receives secret global variables through the identical two-gate mechanism a script job uses:

  1. On the Global Variables settings page, the variable must have Allow in scripts (allow_in_scripts) turned on. This requires globalvars:write. On its own it exposes the variable to no workflow.
  2. The workflow’s own allowed-secrets list must name that variable. This requires both jobs:script and workflows:write.

A secret reaches a script block’s environment only when both gates are set. The environment-injection mechanics are identical to a script job’s — the variable name is normalized to GLOBALVAR_<NAME>, and an injected value is redacted from the step’s captured output. See Opting a secret variable into a script’s environment for the mechanics in full rather than a duplicate here.