English
Workflows
A workflow is a graph you draw in the editor: one or more triggers start it, steps do the work, and links between their ports decide which way the data goes. This page explains the model behind the canvas: the document a workflow is stored as, how a run walks through it, what the publish check refuses, and how the flow-control nodes (loop, sub-workflow, wait, approval) behave.
For what starts a workflow, see Triggers and conditions. For how values are read inside a node ({{ email.subject }}, {{ data.sort.category }}), see Data and expressions.
The three kinds of nodes
Every box on the canvas is a node, and a node is one of three kinds.
| Kind | What it does | Examples |
|---|---|---|
| Trigger | Starts runs. It has no input port and never runs as a step. | trigger.email, trigger.webhook, trigger.schedule, trigger.called, trigger.manual, integration triggers |
| Step | Runs once per run when its inputs are ready, and publishes data for the nodes after it. | ai.categorize, mail.send, flow.if, http.request |
| Service provider | Supplies a service to other nodes instead of running. It never becomes a step, has no data input or output, and appears in no run plan. | llm.anthropic, llm.openai, llm.mistral, llm.openrouter, llm.ollama, llm.openai_compatible |
A model provider is linked to the model service port of an AI node (ai.categorize, ai.extract, ai.summarize, ai.compose, ai.prompt). That node then uses this provider's model. An AI node with nothing on its model port uses the instance defaults. A provider linked to nothing is only a warning (unused_service_provider).
The workflow document
A workflow version is stored as one JSON document. This is the shape the editor saves and the API returns:
json
{
"id": "wfv_…",
"workflowId": "wf_…",
"nodes": [
{ "id": "__trigger__", "type": "trigger.email", "version": 1, "name": "Email received", "params": {} },
{ "id": "sort", "type": "ai.categorize", "version": 1, "name": "Sort", "params": { "…": "…" } },
{ "id": "claude", "type": "llm.anthropic", "version": 1, "name": "Claude", "params": { "…": "…" } }
],
"connections": [
{ "from": "__trigger__", "output": "main", "to": "sort" },
{ "from": "claude", "output": "model", "to": "sort", "input": "model", "kind": "service" }
]
}Nodes carry these fields:
| Field | Meaning |
|---|---|
id | Unique inside the version. 1 to 64 characters among A-Z a-z 0-9 _ -. Connections point at it. |
type | The catalog type, for example ai.categorize. |
version | The node version the workflow was built with. |
name | A display label (1 to 200 characters). Nothing references it, but it names the node's data (see Data that flows). |
params | The settings, raw: {{ }} expressions are stored as typed and resolved at run time. |
onError | fail (default), continue or errorPort. See When a step fails. |
position | Canvas coordinates. Purely visual: it never changes the run order. |
disabled | true to disable the node. See Disabled nodes. |
notes | A free note shown on the canvas (up to 2,000 characters). |
Connections carry from and to (node ids), output (the port on the source), input (the port on the target, main when omitted) and kind (data when omitted, or service for a model-provider link). There is a single kind of data link: branching is expressed by port names, never by special link types.
Two optional fields sit next to the graph and are ignored by the publish check and by real runs: notes (sticky notes on the canvas) and pins (outputs pinned on a node for test runs). Any other top-level key is refused.
Ports and branches
A port is a named entry or exit on a node. Most nodes have one input, main, and one output, main. Nodes that decide something have several outputs, and each output starts its own branch.
| Port(s) | Node | Taken when |
|---|---|---|
main | most nodes | the node succeeded |
true / false | flow.if (Condition) | the conditions hold / do not hold |
cat:<category> and other | ai.categorize (Categorize) | one port per category, named cat: plus the category name exactly as typed (cat:Invoices); other exists only when the fallback is "Exit through Other" |
case:<rule> and otherwise | flow.switch (Switch) | one port per rule, named case: plus the rule name (case:Urgent); the first rule that matches wins; otherwise when none does |
item and done | flow.loop (Loop) | item opens the loop body; done fires once at the end |
approved / rejected | flow.approval (Approval) | the approver's decision, or the timeout action |
main, event, past | flow.wait (Wait) | main when the deadline is reached; event exists only when an early wake-up is set; past exists only in "Until a date" mode with the "already passed" branch chosen |
error | any node with onError: "errorPort" | the node failed |
| none | flow.stop (Stop) | the branch ends here |
Port names derived from your input (categories, switch rules) are kept exactly as typed, accents and spaces included. A port name must be non-empty, have no leading or trailing space, contain no control character, and be at most 200 characters. Renaming a category or a rule renames its port, and the link that used the old name becomes invalid (unknown_output_port) until you reconnect it.
A step always leaves through one port per run. ai.categorize with "Multiple categories" ticked still exits through the first true category; the full list is in its data.
How a run walks the graph
A run follows the data links from the trigger that fired. The rules are fixed and do not depend on where boxes sit on the canvas.
- Only the firing trigger's branches run. A workflow with an email trigger and a webhook trigger runs only the webhook half when a webhook call arrives, and vice versa.
- A node is ready when all its inputs are settled and at least one of them is live. Settled means the node upstream has finished (success, failure handled by
onError, or skipped). Live means it succeeded and left through the port this link starts from. This is an OR join: a node linked to bothtrueof a condition andcat:Invoicesof a categorizer runs as soon as either path arrives. - Branches that are not taken are skipped, and the skip propagates to everything that only depends on them. The run detail shows them as skipped with the reason "branch not taken".
- Independent branches can run in parallel. When an order between two nodes matters, link one after the other.
- The order is deterministic. Ties are broken by node id, so the same graph always runs in the same order.
- The graph has no cycle. A link that would bring the data back upstream is refused at publication (
cycle_detected). Repetition goes throughflow.loop, which runs its body in separate runs.
Data that flows between nodes
Each run carries:
email— the triggering email, when the trigger brings one (see the triggering email).data.<node>— what each finished step published. The key is the node's name turned into an identifier: accents removed, other characters replaced by_, lower case. A node namedSortpublishes underdata.sort. The node id is always available as an alias (data.n3for a node with idn3, with-replaced by_).- The trigger's own data, under a key named after it:
data.webhook(webhook body),data.input(sub-workflow input),data.schedule(schedule times),data.airtable,data.notion,data.mynotary,data.yousign(integration events).
Renaming a node changes the key of its data, so expressions that quoted the old name must be updated. The full syntax is on Data and expressions.
Node effects
Every node declares what it does outside the product. This declaration drives test runs, the send kill switch and the organisation's node policy.
| Effect | Meaning | Examples |
|---|---|---|
none | Reads, computes or decides; can be replayed without consequence. | AI nodes, flow.if, flow.switch, flow.loop, flow.wait, flow.approval, workflow.call, mail.compose, triggers, model providers |
external_write | Writes somewhere outside the workflow: moves or flags a message, calls an HTTP API, writes to a table, a drive or a CRM. | mail.move, mail.flag, http.request, notify.send, table writes, integration nodes, flow.signal |
send | Sends an email. | mail.send |
Why it matters:
- In test runs, nodes with an effect describe what they would have done instead of doing it. Nothing is sent, moved or written.
flow.signalisexternal_writefor this reason: a real signal would change the state of other, real runs. - The send kill switch holds back
sendoperations only. When sending is turned off (theSEND_ENABLEDinstance setting, or the organisation switch), pending sends stay queued and leave when sending is turned back on; moving, flagging and drafting keep working. See Governance. - Loops and sub-workflows have no effect of their own. The nodes inside them do: a
mail.sendin a loop body sends one email per iteration.
When a step fails
Each node has an error policy, set in its settings (onError in the document):
| Value | Label | What happens |
|---|---|---|
fail (default) | Stop the run | Temporary errors (network, rate limit, 5xx) are retried automatically; when retries run out, or on a permanent error, the run fails. |
continue | Continue | The step is marked done, the error stays visible on it, and the run goes on through main. |
errorPort | Follow the error branch | The node gains an error output; on failure the run goes on through it, so you can link a fallback. |
Retrying a failed run and reading error codes are covered in Errors and retries.
Disabled nodes
Disabling a node keeps it in the document but stops it from running: no call, no effect, no model request. The data passes through it, and the nodes after it run as if it were not there.
The data leaves a disabled node through main when the node has that output, otherwise through its first declared output (for example the first category of a categorizer). Branches linked to its other outputs are not taken. To cut a branch, delete the link instead.
A disabled node raises the warning disabled_node. A disabled trigger does not fire. When every trigger is disabled, the workflow still publishes, with the warning all_triggers_disabled.
Validation at publication
Saving a draft never blocks: the editor shows problems as you build. Publishing runs the full check and refuses the publication while any error remains. Warnings are shown but do not block.
Blocking errors
| Code | Meaning |
|---|---|
no_trigger | The workflow has no trigger. |
empty_workflow | There is no step besides the triggers. |
duplicate_trigger | A trigger type that a workflow can hold only once appears twice (trigger.webhook, trigger.called, trigger.mynotary, trigger.notion, trigger.yousign). |
trigger_has_incoming | A link points at a trigger. Nothing can flow into a trigger. |
invalid_trigger_conditions | An email trigger's conditions cannot be evaluated (see condition errors). Checked on disabled triggers too. |
invalid_schedule | A schedule trigger cannot be armed. The reason is one of cron_missing, cron_field_count, cron_field_syntax, cron_too_frequent, interval_out_of_range, unknown_timezone. Checked on disabled triggers too. |
duplicate_node_id | Two nodes share an id. |
unknown_node_type / unknown_node_version | The catalog has no such node, or not in this version. |
invalid_params | A setting is missing or invalid. Includes resource_missing when a setting points at something that no longer exists (for example a deleted mailbox chosen as the sending mailbox). |
carrier_email_required | The node acts on the triggering email, or one of its settings needs it, and at least one trigger starts runs without an email. See the triggering email. |
unknown_connection_source / unknown_connection_target | A link starts or ends at a node that does not exist. |
self_connection | A link goes from a node to itself. |
unknown_output_port / unknown_input_port | A link uses a port the node does not have (often a renamed category or rule). |
invalid_service_connection | A model-provider link is wrong. The reason is one of not_a_service_provider, unknown_service_output_port, unknown_service_port, service_kind_mismatch, duplicate_service_connection (two providers on one port), service_port_data_connection. |
cycle_detected | The data links form a cycle. |
invalid_loop | A loop body is not cleanly delimited. The reason is one of loop_body_empty, loop_body_overlaps_done, loop_body_outside_edge, loop_nesting_too_deep. |
Warnings
| Code | Meaning |
|---|---|
unreachable_node | No path leads to this step from a trigger: it will never run. |
unused_service_provider | A model provider is linked to no node. |
duplicate_connection | The same link exists twice. |
disabled_node | The node is disabled and will be passed through. |
all_triggers_disabled | Every trigger is disabled: nothing will start the workflow on its own. |
trigger_not_armed | The trigger does not fire on its own. Only trigger.manual raises it: you start it yourself. |
carrier_email_inherited | The workflow is called by other workflows and this node needs an email: it works when the caller has one, and fails when the caller was started by a schedule or a webhook. |
Other publication refusals
Besides the graph check, publishing can be refused with:
workflow.forbidden_node— the organisation's node policy forbids a node type used in the graph (see Governance);workflow.call_cycle— the workflow would call itself through itsworkflow.callnodes, directly or through other published workflows.
Loops
flow.loop (Loop) repeats part of the graph once per element of a list: each attachment, each table row, each recipient.
- Two outputs. What you link to
item(shown as "for each") is the loop body. It runs once per element, each time in a separate child run with its own steps, effects and trace.donefires once, after every iteration has concluded, with the counts in the loop's data. - No return link. The body does not loop back to the Loop node; the graph stays acyclic.
- Inside the body, the current element is
{{ data.item }}, along with{{ data.index }},{{ data.count }},{{ data.first }}and{{ data.last }}. Everything produced before the loop stays readable. - The list comes from an expression (
{{ data.read.rows }},{{ email.attachments }}) or a hand-written list (one value per line or comma-separated). - Settings: items per iteration from 1 to 100 (above 1,
data.itemis an array); iterations in parallel from 1 to 5 (1 keeps the order); on an iteration failure, stop the loop (default) or carry on and collect the error; maximum iterations from 1 to 500 (default 100, and a longer list fails the loop rather than handling part of it); a time budget in minutes (default 60, at most 24 hours).
The publish check enforces a clean body:
loop_body_empty— nothing is linked toitem;loop_body_overlaps_done— a node is both in the body and afterdone, so it would run twice;loop_body_outside_edge— a node in the body receives a link from outside the loop (remove it: the body already sees everything before the loop);loop_nesting_too_deep— more than two nested loops.
Details: Loop node.
Sub-workflows
workflow.call (Call a workflow) runs another workflow, so a routine you use in five workflows can live in one.
- The target must be published and have an active
trigger.called(Called by a workflow) trigger. It is chosen by id: renaming nodes inside it breaks nothing for the callers. - Input: the called workflow receives the caller's working data under
{{ data.input }}, and the same triggering email when the caller has one ({{ email.subject }}reads the same message on both sides). - Two modes. "Wait for completion" (
wait, default) suspends the caller until the child concludes; the child's data then becomes this node's output underoutput({{ data.call.output.compose.body }}for a step namedComposein the child). "Fire and forget" (fireAndForget) starts the child and continues at once. - Maximum wait from 1 to 4,320 minutes (default 60). After it, the branch resumes with
status: "timeout". - Test runs are inherited: a test run calls the child in a test run too, so nothing is sent.
- Failure: a child that fails makes the calling step fail, and the calling node's
onErrorapplies. - Limits: calls chain at most three levels below the first run. A chain that loops back (A calls B, B calls A) is refused at publication (
workflow.call_cycle) and, if it appears later, at run time.
Details: Call a workflow and Called by a workflow.
Waiting and signals
flow.wait (Wait) pauses a run for a duration, until a date, or for a number of business days or hours. Nothing is held in memory: the run is stored as waiting and resumes when the deadline or an event arrives, even after a restart.
- Modes:
duration(minutes to years; months and years are calendar months and years),until(a date taken from the data, with an offset and a time of day),business(business days or hours, skipping weekends, public holidays and company closures set in the administration). - Outputs:
mainwhen the deadline is reached;eventwhen an early wake-up happens first;pastwhen, inuntilmode, the date had already passed and "If the date has already passed" is set to "Take the "already passed" branch" (the other choices are "Continue right away", the default, and "Fail"). - Early wake-up: on a reply in the thread of the triggering email (optionally only from a given address or
@domain), or on a signal carrying a correlation key. Waking on a reply needs a triggering email: without one, publication refuses it (carrier_email_required). - The longest wait is 730 days unless the instance sets a lower ceiling.
flow.signal (Emit a signal) ends the waits that watch a given key, for example case:{{ data.extract.reference }}:documents-received. Its action is resume — "Wake the waits": they leave through event, and short values set in "What the signal carries" are readable under data.<node>.signal — or cancel — "Cancel the waiting runs": nothing that followed the wait runs. Keys are shared across the organisation. An outside system can emit the same signal with POST /api/v1/signals (session, or API key with the signals:write scope).
Details: Wait and Emit a signal.
Approvals
flow.approval (Approval) stops the run until a person decides.
- Two outputs,
approvedandrejected, so a refusal can be handled as well as an approval. - Timeout from 1 to 720 hours (default 48). Without an answer, the "Without an answer" setting decides:
reject(default) orapprove. - In a test run the node approves itself at once and records that it would have asked.
Approval requests, decisions and the review of drafts before sending are described in Review and approvals.
Draft, published, paused
A workflow has a draft, which the editor saves as you work, and at most one published version, which is what triggers run.
- Publishing runs the check above, freezes the draft as a new published version and arms its triggers. A published version never changes: every run uses the exact version it started with, and editing the draft changes nothing for runs in progress.
- Unpublishing disarms the triggers.
- Pausing keeps the published version but starts no new run from incoming emails, schedules or polling triggers; runs already running finish. Resuming needs no republication.
- Archiving disarms the triggers and hides the workflow without deleting its history. Only a draft that was never published and never ran can be deleted.
Version history and restoring an earlier version are covered in Versions.
Copying, importing and exporting
In the editor, you can copy a selection of nodes and paste it into the same workflow: pasted nodes get new ids, and only the links between the copied nodes are kept. Triggers that a workflow can hold only once (trigger.webhook, trigger.called, trigger.mynotary, trigger.notion, trigger.yousign) are left out of the copy.
There is no file import or export in the editor. To move a graph programmatically, use the API: GET /api/v1/workflows/:id/draft returns the draft document, and PUT /api/v1/workflows/:id/draft saves one ({ "graph": { … } }, with an optional expectedDraft to refuse the save with 409 workflow.draft_conflict if someone else changed the draft meanwhile). Saving never blocks on validation; publishing does. See the API reference.