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.
Block types
Section titled “Block types”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.
Building a workflow
Section titled “Building a workflow”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.
Building a loop body
Section titled “Building a loop body”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
breakandcontinue); - 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.
Branching and containers
Section titled “Branching and containers”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.
Switch
Section titled “Switch”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 asfetch.statusis 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
1matches a chosen value of1,"1"or1.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 takesdefault. - If the chosen connector is not connected to anything, the branch ends there, the same
as an unconnected
ifbranch: 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_eachandwhilecontainers. A wait in awhileloop 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.
Set variable
Section titled “Set variable”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
varswith an assignmentcountis 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,booleanorjsonis 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}}.afails when the expression runs; write{{x}}?.afor 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 ternarya ? b : c,??,contains,startsWith,endsWith,in, the rangea..b, member and index access,?., and slices such asx[a:b]. Thematchesoperator is not supported.+also joins strings. A/always yields a float, while%requires integers — JSON numbers are floats, so writeint(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.roundtakes one argument — there is no precision argument.map,filterand the other collection functions are not available, and neither are$env,let, pipes or theif … else … endform. 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..1000fails. 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). splitreturns at most 1000 items, and the result ofjoinis 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 insplit(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.
Data flow between blocks
Section titled “Data flow between blocks”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.
Block names
Section titled “Block names”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.
What each block stores
Section titled “What each block stores”| 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}}.
Which references a block can use
Section titled “Which references a block can use”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.
How a reference is filled in
Section titled “How a reference is filled in”- 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
iforwhilecondition, afor_eachsource, aparseinput, 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.
Conditions
Section titled “Conditions”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.
Triggers and scheduling
Section titled “Triggers and scheduling”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.
One-off
Section titled “One-off”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.
Concurrency and overlap
Section titled “Concurrency and overlap”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.
Run monitoring
Section titled “Run monitoring”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.
Permissions
Section titled “Permissions”| 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:
- On the Global Variables settings page, the variable must have Allow in
scripts (
allow_in_scripts) turned on. This requiresglobalvars:write. On its own it exposes the variable to no workflow. - The workflow’s own allowed-secrets list must name that variable. This requires both
jobs:scriptandworkflows: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.