Workflow Nodes

From the <start> node, the engine walks nodes connected by next edges until an <end>. This page is the reference for every node type.

Common attributes

All nodes share:

Attribute Required Description

id

yes

unique within the workflow; the target of next edges

title

no

human-readable label (shown in the designer and in approvals)

description

no

free documentation

Canvas positions and edge routes are not node attributes: the designer keeps them in the definition’s own <layout> block, which the engine ignores.

Except for a diverging split (which branches through its flows) and end, every node has a required single next.

Expressions and scripts

All conditions (split flows, wait conditions) and script bodies are JavaScript, evaluated against the running record. Available bindings:

Name Description

self

the record (fields readable and, in scripts, writable)

old

the record’s fields before the save that triggered the run (update trigger); first walk only. Unlike in a trigger’s when, it is always available

event

the trigger snapshot: event.self / event.old; first walk only

vars

the run’s variables — set by earlier nodes, and kept across a pause so a value set before an approval is still there after it

parent

read-only view of the record of whatever invoked the graph you are in, and its variables: the calling run inside a call, the run itself inside a subflow. It chains, so inside a subflow of a called workflow parent is that workflow’s record and parent.parent is the caller’s

visit

which pass over the current node the walk is on, so a loop can count without a field (see Bounding a loop that comes back to a park)

task

after a task node: outcome, completed, rejected, timedOut, comment, completedBy, visit

agent

after an agent node: text (raw answer), result (JSON-parsed answer when possible)

error

on the path an error boundary catches: message and code (null when the failure had no name)

message

when a message or a signal woke this run: name, key, data (what the sender’s <payload> evaluated to, an empty object when it sent none) and sentAt. null whenever something else started the walk

log

logger: log.info(…​), log.debug(…​), log.warn(…​), log.error(…​)

_t

translation function

agent, task and error hold the last answer of their kind on the path the run took, and a park does not lose them — whether a wait, a task or a background node made it, the walk that resumes reads what the walk that parked read. Each branch of a split starts from what the split read; after a join none of them is bound, since no single branch’s answer is the one to read, so copy what a later node needs into vars before the join. parent reads the same on every walk of a run. old and event describe the save that started the run and are read on its first walk only — up to the first park — so copy into vars what a node after a park needs from them.

Unlike the trigger’s sandboxed when, node expressions execute against the full record — reference fields and collections may be traversed.

Running in the background

An agent, email or script node can run after the walk that reached it has committed, on a worker of its own, instead of inside it. The run stops at the node the way it stops at a wait; everything before it is saved; then the node runs and the run carries on from there, reading agent, task, error and parent as it would have inline. old and event are gone by then, as after any park.

<agent id="classify" ref="support-triage" next="route">   (1)
  <prompt><![CDATA[Classify ${self.subject}]]></prompt>
</agent>
<email id="notify" template="id:42" async="false" next="done"/>   (2)
<script id="rebuild" async="true" next="done">   (3)
<![CDATA[ __bean__('com.example.Stats').rebuild(self); ]]>
</script>
1 in the background: async defaults to true on an agent
2 sent inside the walk: async="false" opts an email out
3 in the background: a script defaults to false, so a slow one opts in

This is for work that is slow or talks to something outside the database — a model, a mail server, a heavy service:

  • The user who saved the record does not wait for it. The save returns as soon as the walk up to the node commits.

  • A failure fails only the node and what follows it. Nothing before it is rolled back, because it has already committed.

What it costs:

  • The node’s effect is not visible when the save returns, only a moment later. Meanwhile the record’s form shows the run as pending on its workflow button, and reloads the record once the node commits, asking first when it has unsaved edits. Keep a quick script inline when the user should see its result straight away.

  • A background node runs at most once. If the walk that ran it never commits — the server stopped, or a later node rolled the transaction back — the next resume does not run it again: the node fails with an unknown outcome, which an error boundary on it can catch, so someone can check whether the email left or the script’s work landed.

  • A call whose child contains a background node can stop, so the limits on a child that waits apply to it (see call).

The resume starts right after the commit. If it never does — the server stopped in between — the scheduler picks the run up once workflow.scheduler.resume-grace-seconds (default 120) has passed. workflow.scheduler.resume-pool-size sets how many background nodes run at once, per server; it defaults to the scheduler’s own pool size.

script

Runs an inline JavaScript body against the record. Add async="true" to run a slow script in the background (see Running in the background).

<script id="prepare" next="done">
<![CDATA[
  self.note = _t('Confirmed on') + ' ' + self.confirmDate;
  vars.total = self.totalAmount;      // visible to later nodes as vars.total
  save();                             // persist self

  var task = create('com.axelor.team.db.TeamTask'); (1)
  task.name = 'Follow up ' + self.name;
  save(task);
]]>
</script>
1 create(name) accepts a model’s fully qualified name or a custom model name

Beyond mutating self, a script may:

  • save() — persist the record’s pending changes

  • create(name) / save(obj) — build and persist new records (real or custom models)

  • assign to vars.<name> — pass values to later nodes, including a following call iterating over produced records. Variables survive a pause, so one set before a task is still readable after it

  • fail(message) / fail(code, message) — end the run as failed. A code names the failure so a boundary can catch it; see Catching a failure

  • track(note) — leave a note on the record’s own message stream

  • log.info(…​) / log.warn(…​) / log.error(…​) / log.debug(…​) — write to the server log

  • _t(key) — translate; _t(key, a, b) also substitutes {0}, {1}

Stopping the run yourself

Some things a graph cannot express as a condition, and the honest answer is to stop:

<script id="check" next="charge">
<![CDATA[
  if (!self.customer.billingAddress) {
    fail(_t('Customer has no billing address'));
  }
]]>
</script>

The run ends as failed at that node, and the sentence you passed is what the run viewer, the run history and the server log all show. Without fail the only way to stop is to throw, and the author’s message then arrives wrapped in whatever shape the script engine gave it.

Give the failure a name — fail('NO_STOCK', …​) — and it stops being the end of the run: an error boundary on this node can catch it and route somewhere else. See Catching a failure.

Three things worth knowing:

  • Nothing is rolled back. A workflow fired by a save runs after that save has committed, so there is no longer anything to undo — and the transaction the workflow does run in is shared with every other workflow firing on the same event, so failing does not discard their work either. fail stops this run and records why; it does not reverse what already happened.

  • Nobody is shown the message when the trigger was a save: the record was saved successfully, and it was. A workflow run manually returns the message to its caller, which is the one case where a person is still waiting.

  • A try/catch in your script will swallow it, because it is an exception like any other. Do not wrap a fail in one and expect the run to stop.

To end a run without failing, route to an <end terminate="true"/>. That keeps the ending visible in the diagram, which a helper would hide.

Leaving a note on the record

track(note) posts a comment on the record’s own message stream, where somebody reading the record will find it — unlike log, which reaches only whoever is reading the server log.

<script id="note" next="done">
<![CDATA[
  track(_t('Approved automatically: under the review threshold'));
]]>
</script>

The note is authored by whoever triggered the run when there is one, and by nobody on a scheduled run. It does not email anyone directly — the record’s followers are notified by the same machinery that handles any other comment.

A workflow that fires on every update and calls track will fill the record’s stream. Gate it on the change that is worth remarking on.

email

Renders an existing Axelor Message email template for the workflow record and queues the result with the sender configured on that template.

<email id="notify" template="id:42" next="done"/>
Attribute Required Description

template

yes

opaque template reference selected in the workflow designer. Existing definitions may contain a template name for compatibility; new selections use a stable id:…​ reference.

next

yes

the node to continue with after the message is queued

async

no

false to send inside the walk; defaults to true, see Running in the background

The template must be an email template configured for the workflow’s entity or custom model. The Axelor Message add-on, or another WorkflowEmailProvider implementation, must be available at runtime.

A missing or incompatible template and an expected generation or queueing failure fail the node and may be handled by an error boundary on it. An unexpected provider defect is not catchable; it fails the run, like an unexpected exception from any other node.

split

This section is the attribute reference. For a walkthrough of every shape a split and a join make — a choice, a fan-out, a race, a quorum, a rejection that stops everything — and the rules behind them, see Branching and Joining.

Where the path diverges — the only node that branches. mode is required and says how its ordered <flow> children are chosen: a split has one role, so the author says which kind up front, rather than the same word meaning something different depending on how many flows lead out.

<split id="check" mode="one">
  <flow id="f1" when="self.totalAmount &gt; 10000" next="approve" title="Large order"/>
  <flow id="f2" when="self.customer.blocked" next="reject" title="Blocked customer"/>
  <flow id="f3" default="true" next="notify"/>
</split>
Attribute Required Description

mode

yes

how the flows are chosen — see below

flow/@id

yes

the flow’s stable identity, independent of its next target. A token is keyed by (activation, flow id), not by next, since two flows may legally target the same node; the id survives reorder and re-targeting.

flow/@when

yes*

JavaScript condition. Required on every flow of a one or some split that has more than one and is not the default; rejected on an all split’s flows, which are unconditional, and on the default flow.

flow/@next

yes

branch target

flow/@default

no

marks the fallback flow, taken when nothing matched. At most one per split, and it carries no when.

flow/@title

no

branch label, shown on the arrow in the designer

The three modes, in BPMN’s terms:

mode Glyph Behaviour

one

✕

the first flow whose condition holds wins; nothing else is evaluated. Its alternatives converge again at an ordinary node — there is nothing to synchronise, so a one split needs no join.

some

○

every flow whose condition holds is taken, or the default when none match. Its branches meet at the join naming this split.

all

+

every flow is taken, unconditionally. Its flows carry no when and no default. Its branches meet at the join naming this split.

Unlike a plain routing element, mode has no default — the author always states which kind of split this is.

Without a default flow, a one or some split that matches nothing fails the run rather than ending it quietly. The symptom is misleading, so it is worth recognising: the run fails at the split, the trigger fires again on the next update, and every node before the split runs a second time — so what you notice is duplicated work upstream, not a split that did not match. The error names both fixes, and they mean different things:

  • add a <flow default="true"> — "carry on anyway";

  • put a <wait on="condition"> before the split — "this has not happened yet". A split decides on what the record says now; it never waits.

Give a default flow to any one or some split whose conditions do not provably cover every case.

join

For worked examples of any and quorum races, nested splits, and the complete list of validation rules with their fixes, see Branching and Joining.

Where a some/all split’s branches come back together. for names the split this join synchronises — exactly one join names each such split, and it must be a some or all one: naming a one split (whose alternatives already converge at an ordinary node) is rejected as join-for-invalid, since there is nothing to synchronise.

<split id="checks" mode="all" title="Run every check">
  <flow id="f1" next="credit" title="Credit"/>
  <flow id="f2" next="stock" title="Stock"/>
</split>
<script id="credit" next="merge"><![CDATA[ /* ... */ ]]></script>
<script id="stock" next="merge"><![CDATA[ /* ... */ ]]></script>
<join id="merge" for="checks" wait="all" next="done"/>
Attribute Required Description

for

yes

the id of the some/all split this join synchronises

wait

yes

the completion policy — see below

quorum

with wait="quorum"

how many arrivals that policy waits for

next

yes

the node to continue to once the policy is satisfied

wait is the completion policy:

wait Behaviour

all

every selected branch must arrive before the join continues.

any

the first arrival continues, cancelling the rest.

quorum

the first quorum="N" arrivals continue, cancelling the rest.

The count is a separate attribute rather than a third kind of value wait could hold, so each attribute means one thing — the same reason a split’s mode is a closed vocabulary. quorum is required by wait="quorum" and rejected on the other two (join-quorum-required, join-quorum-unexpected) rather than quietly ignored: a count sitting on an all join would read as a threshold that was being honoured. quorum="1" is allowed and means what any means.

A join has no mode, no when, no default: it names a split and a policy, nothing else.

Racing branches: any and N

wait="any" is the "either of these will do" shape — two approvals where one suffices, or a review racing a deadline:

<split id="reviews" mode="all" title="Ask both">
  <flow id="mgr" next="manager" title="Manager"/>
  <flow id="adm" next="admin" title="Administrator"/>
</split>
<task id="manager" user="alice" next="approved" title="Manager approval"/>
<task id="admin" user="bob" next="approved" title="Admin approval"/>
<join id="approved" for="reviews" wait="any" next="publish"/>

The first approval carries the run past the join. The other branch is cancelled and its approval withdrawn, so it disappears from that person’s inbox rather than sitting there for work that is over.

wait="quorum" is the same shape with a threshold — "any two of the three directors", where it does not matter which two:

<join id="approved" for="reviews" wait="quorum" quorum="2" next="publish"/>

That is what a count is for, and it is the only way to say it: a threshold is evaluated against the branches the split activated, so it cannot be rebuilt by nesting joins. Nesting gives you conditions over distinct branches (all of X plus any of Y and Z); a quorum is a threshold over interchangeable ones, and writing "any 2 of 3" as a tree of joins would need the same approval to appear twice — two tasks for one person.

Only arrivals count towards the threshold. A branch that reaches an <end> before the join is neither an arrival nor a rejection — it just means one fewer branch that could still arrive. So a rejected approval counts if its flow leads to the join; route it to an <end> if it should not.

Two rules keep a race from waiting for something that can never come:

  • A quorum above the split’s flow count is refused when the workflow is saved (join-quorum-invalid) — two flows can never produce three arrivals.

  • A threshold the day’s branches can no longer meet fails the run, with a message naming the join and the counts. A barrier that can never release would otherwise leave the record waiting for ever.

A non-cancelling boundary (cancel="false") may not appear inside a race (boundary-cancel-invalid). Its escape branch is a separate run that no branch owns, so cancelling a losing branch could not stop it — and "the remaining branches are cancelled" would not be true. Use cancel="true", move the boundary outside the race, or make the join wait="all".

Branches run one after another, in declaration order, against the same record and the same variables — a split is about which paths are taken, not about running them at the same time.

A branch of a some/all split may wait — on a task or a wait — because such a split always has exactly one join, so there is always a barrier for a woken branch to arrive at. When one branch parks, the engine writes down where every branch of that activation got to, and the join counts them back in as they finish: the run stays waiting until the last branch it is owed reports back, and the join then runs exactly once. Completing one approval never carries the run past a barrier its siblings have not reached.

A branch may wait more than once before its join — two approvals in a row is an ordinary shape — and each branch that waits on a timer keeps its own wake time, so branches with different delays wake independently.

A split may also sit inside a branch of another split, and wait there. Joining the inner join carries that branch on towards the outer one, so the outer join waits for it exactly as it waits for a branch that parked directly.

If a branch fails, the fork fails with it: the run is failed and the approvals its siblings were holding are withdrawn, rather than left clickable on a run that is already over. Deleting the record does the same for every branch: there is nothing left to approve, so the run is cancelled and its approvals go with it.

Branches may not share work before the join

One structural rule: two branches of the same split may not share any node before their join, except the join itself — or an <end>, since an end does no work. Settling on the same end from two branches is fine, including a shared terminate end: reaching it from either side ends the whole run, so there is nothing to run twice. That is the shape behind "a rejection on either side stops the process":

<split id="both" mode="all" title="Both approvals">
  <flow id="f1" next="manager" title="Manager"/>
  <flow id="f2" next="finance" title="Finance"/>
</split>

<task id="manager" role="sales.manager" next="managerOutcome"/>
<split id="managerOutcome" mode="one" title="Manager says">
  <flow id="f1" when="task.rejected" next="stop" title="Rejected"/>
  <flow id="f2" default="true" next="merge" title="Approved"/>
</split>

<task id="finance" role="finance.team" next="financeOutcome"/>
<split id="financeOutcome" mode="one" title="Finance says">
  <flow id="f1" when="task.rejected" next="stop" title="Rejected"/>
  <flow id="f2" default="true" next="merge" title="Approved"/>
</split>

<!-- reaching this cancels whatever the other approver was still holding -->
<end id="stop" terminate="true" title="Stopped"/>

<join id="merge" for="both" wait="all" next="note" title="Both approved"/>

Anything with real work in it — a script, another task — must instead be given its own node past the join, or moved into its own <call>, rather than shared between branches ahead of it.

Persist once after the join

Every branch of one run shares a single in-run record, so a fork should accumulate its changes and persist them once past the join rather than save in each branch:

<split id="route" mode="some" title="On confirm">
  <flow id="f1" when="self.amount &gt; 1000" next="flag-value"/>
  <flow id="f2" when="self.customer.vip" next="flag-vip"/>
  <flow id="f3" default="true" next="merge"/>
</split>
<script id="flag-value" next="merge"><![CDATA[ self.needsApproval = true; ]]></script>
<script id="flag-vip" next="merge"><![CDATA[ self.priority = 'high'; ]]></script>
<join id="merge" for="route" wait="all" next="persist"/>
<script id="persist" next="done"><![CDATA[ save(); ]]></script>

Saving in each branch instead writes the record once per matching branch: every write runs the record’s save hooks and can re-trigger a workflow watching a field that branch just set, and it exposes partly-updated states to anything watching. One save past the join goes from the old state to the final one.

This applies to some/all. A one split runs exactly one branch, so the save() in the branch that acts is already a single write and needs no join.

wait

Parks the run durably until a timer elapses, a condition on the record becomes true, or a message or signal arrives from elsewhere.

<!-- resume after 2 days -->
<wait id="cooldown" on="timer" delay="2" unit="day" next="remind"/>

<!-- resume at a date carried by the record -->
<wait id="due" on="timer" until="self.reviewDate" next="review"/>

<!-- resume when the record reaches a state -->
<wait id="paid" on="condition" when="self.status == 'PAID'" next="close"/>

<!-- resume when another workflow says this order was paid -->
<wait id="paid" message="order-paid" key="self.id" next="close"/>

<!-- resume when the books are closed, whichever order this run is about -->
<wait id="closed" signal="books-closed" next="report"/>

<!-- ...but do not wait for it forever -->
<wait id="paid" on="condition" when="self.status == 'PAID'" next="close"/>
<boundary id="chase" host="paid" on="timer"
          delay="7" unit="day" next="chaseAccounts"/>
Attribute Required Description

on

one of these

timer or condition

delay / unit

timer

relative wake-up (minute, hour, day)

until

timer

expression producing the wake-up date/time (alternative to delay); read again whenever the record changes, so the wake-up follows the field

when

condition

JavaScript condition; if already true when the node is reached, the walk continues immediately without parking

message

one of these

the message name to listen for, matching the message on the <send> that announces it — a literal, not an expression

signal

one of these

the signal name to listen for, matching the signal on the <send> that broadcasts it

key

with message

which occurrence of that name to wait for, as an expression on the record. Read once, when the run parks. It must not be empty — see send. Refused with signal: a signal is addressed by name alone

maxVisits

no

how many times the run may park here before it is failed — see Bounding a loop that comes back to a park

next

yes

the node to continue with after the wait

A timer wait is woken by the scheduler; a condition wait is re-evaluated when the record is updated; a message or signal wait is woken by a send elsewhere. Any of them can be watched by a boundary.

Neither correlating wait has a deadline of its own, and neither does a condition wait on a record nobody touches. They wait forever unless you give them one — attach a <boundary on="timer"> and say where to go instead.

send

Announces that something has happened, by name, to whoever is waiting for it. With message, every run parked on that name and that key continues; with signal, every run parked on that name does (see Sending a signal). The walk passes straight through.

<!-- the payment workflow, on Invoice -->
<send id="tell" message="order-paid" key="self.order.id" next="done"/>

<!-- with data along for the ride -->
<send id="tellMore" message="order-paid" key="self.order.id" next="done">
  <payload><![CDATA[ ({ amount: self.amountPaid, method: self.paymentMode.code }) ]]></payload>
</send>
Attribute Required Description

message

one of these

the message name, spelled the same way as the message on whatever waits for it. A literal, not an expression

signal

one of these

a signal name, in place of message — see Sending a signal

key

with message

which occurrence of that name this is, as an expression on the sender’s record. It must not evaluate to empty: a message nothing can be matched to is a send that silently does not happen, so the node fails instead

<payload>

no

a child element, not an attribute: an expression returning an object, carried on the message and read by whoever it wakes as message.data — see What a message carries

expect

no

any (default, including none) or one — see below

ttl

no

how long to keep offering this, in seconds. Empty takes workflow.message.ttl (five minutes); 0 means never give up

next

yes

the node to continue with

A send never waits for a receiver and never fails because there was none. That is what lets one workflow tell another something without either knowing the other exists — the difference between this and a call, which calls a named child and is coupled to what it does.

The name and the key

The name says what happened; the key says what it happened to. Both sides have to agree on both, and nothing checks them across definitions — a <send message="order-paid"> and a <wait message="order_paid"> will simply never meet. Because runs are anchored to a record, the key is usually an id:

<!-- on Order: wait to hear that this order was paid -->
<wait id="paid" message="order-paid" key="self.id" next="ship"/>
<boundary id="giveUp" host="paid" on="timer" delay="7" unit="day" next="cancel"/>

<!-- on Invoice, a different workflow: say that it was -->
<send id="tell" message="order-paid" key="self.order.id" next="done"/>

The boundary is the point. Nothing about a message park is special to the deadline that bounds it, because a message park is a park.

What a message carries

A <payload> is an expression returning an object, evaluated as the sending node runs and delivered with the message. The receiver reads it as message.data:

<!-- on Invoice: say what was paid, not only that something was -->
<send id="tell" message="order-paid" key="self.order.id" next="done">
  <payload><![CDATA[ ({ amount: self.amountPaid, on: self.paymentDate }) ]]></payload>
</send>

<!-- on Order, woken by it -->
<script id="record" next="e"><![CDATA[
  self.settledAmount = message.data.amount;
  save();
]]></script>

A payload is a snapshot; the record is the present. Where both could answer, the record is right.

The payload records what was true when the sender committed. The record carries what is true now, at the moment the receiver wakes — and those are different moments, because delivery is asynchronous and a park may have waited days in between.

So send what a receiver could not work out later: an amount before a later credit note, which branch the sender took, a total as it stood at the time. Do not send a copy of fields it can read for itself — a status copied into a payload is a stale answer that looks authoritative, and it will be wrong eventually.

The payload is checked only for parsing when the definition is saved. What the expression returns is not knowable until it runs, so the shape of a payload is an agreement between the two sides, exactly like the name they correlate on. A receiver reading a field the sender never sent gets undefined, not an error.

Two things a payload may not contain, both refused at run time with the node failing rather than sending something misleading:

  • A record. ({ order: self.order }) or ({ all: self }) fails naming the path. Send the fields the receiver needs, or an id for it to read the record by.

  • More than workflow.message.payload-max characters of serialised JSON (8192 by default). A payload quietly cut in half is worse than a send that says it was too big.

Dates and times arrive as ISO-8601 text ("1980-05-17"), and message.data is an empty object when the sender wrote no payload — never null, so message.data.amount can be read without guarding first.

Delivery, and sending before the receiver is ready

The row is written in the sending run’s own transaction and delivered afterwards, by the scheduler. That gap is deliberate: it is what makes sending before the receiver has parked work. A message that matches nothing yet stays pending and is offered again on the next poll, so a run that parks a moment later is still woken.

Two consequences worth knowing:

  • A send is not rolled back by a later failure in the same walk. The row is already committed, and it will be delivered. Put a send after the work it reports, not before.

  • ttl bounds how long the sender keeps retrying, and nothing else. How long a receiver waits is bounded the way every park is: with a timer boundary on the waiting node.

Expecting exactly one listener

By default a message reaches every run listening for that name and key — several is ordinary, because runs are anchored to records and more than one workflow may care about the same one.

expect="one" says that was not meant to happen. It reports rather than prevents: delivery is asynchronous, so by the time the count is known every match has already been woken and nothing can be taken back. What it buys is that the message says so.

Messages that went nowhere are kept rather than deleted, and their status is where a correlation failure becomes visible at all:

Status Meaning

pending

not matched yet, and offered again by a later poll

delivered

matched at least one listener, which was woken

expired

never matched, and past its ttl

failed

expect="one" and that is not what happened: none by the time it expired, or several at once. Whatever it did wake was woken all the same

Sending a signal

A <send> with signal in place of message announces that something has happened, by name, to everyone waiting for it. Every run parked on that name continues, whichever record each of them is about; the walk passes straight through.

<!-- on FiscalPeriod: tell every order run that the quarter is closed -->
<send id="tell" signal="books-closed" next="done"/>
Attribute Required Description

signal

yes

the signal name, spelled the same way as the signal on whatever waits for it. A literal, not an expression

key

no

refused: a signal is addressed by name alone

<payload>

no

as for a message. Having no key a signal cannot address a record, but it can still describe one — and a receiver reading the payload is reading about the broadcaster’s record, never its own

expect

no

any (default, including none) or one — as for a message

ttl

no

how long to keep offering this, in seconds. Empty takes workflow.message.ttl (five minutes); 0 means never give up

next

yes

the node to continue with

Everything else it shares with a message: the row is committed with the rest of the node’s work and delivered by a later poll, so a signal sent before its receivers have parked still reaches them; it never waits for a receiver; and it is not undone by a later failure in the same walk.

A signal is not a message with the key left out

They are separate address spaces that happen to share a name column. A signal reaches only the runs waiting for a signal of that name, and a message only those waiting for a message of it — even when the name is the same:

<!-- these two never meet, though both name order-paid -->
<wait id="one" message="order-paid" key="self.id" next="a"/>
<send id="all" signal="order-paid" next="b"/>

This is why a signal is named by its own signal attribute rather than by a message with key omitted, on the sender and the receiver alike. A missing attribute that quietly widened who is reached is the one mistake nothing here would report: the runs you did not mean to wake simply wake.

Use a message when the news is about one record — "invoice 4711 was paid". Use a signal when it is about none in particular — "the books are closed for the quarter".

call

Calls another workflow — once for the current record (a call activity), or once per element of a collection (multi-instance). Which one you get depends only on whether you asked for an iteration.

<!-- delegate to another workflow for this record -->
<call id="delegate" ref="order-billing" next="done"/>

<!-- run the child for each produced record (multi-instance) -->
<call id="each-task" ref="task-init" each="vars.newTasks" next="done"/>

<!-- run the child for each record matched by a query -->
<call id="late-lines" ref="line-escalation"
  filter="self.order.id = :id AND self.status = 'late'"
  maxSize="500" batchSize="50" next="done"/>
Attribute Required Description

ref

yes

name of the called workflow — author it as a manual workflow meant to be reused; a create/update/timer workflow fires on its own trigger and is not intended as a call target

record

no

expression yielding one saved record to run on. A collection is refused — use each

each

no

expression yielding a collection to run on, one child per element. A scalar is refused — use record

filter

no

JPQL filter selecting records on the called workflow’s model, one child per match

—

record, each and filter are mutually exclusive, and all three are optional: writing none of them is what makes this a call activity — see below

maxSize

no

upper bound of iterated elements, with each or filter; defaults to the workflow.iteration.max-size option (1000 — see Engine options)

batchSize

no

with filter only: how many matching records are loaded and run per page, so a large iteration does not hold them all in memory

next

yes

the node to continue with once the call is done

Inside the called workflow, the calling run is visible as the read-only parent binding.

Waiting: for one child, or for every element

A <call> with no selector at all calls the child once, on this record. Because there is exactly one child, the caller can wait for it:

<call id="approval" ref="order-approval" next="ship"/>

If order-approval parks — on a task, or a wait — then this run parks here too, at the call node, and resumes at ship when the child reaches its end. A child that fails fails the caller, at the call node, with the child’s own error attached, unless an error boundary on the call node catches it.

Because the caller parks, it can be interrupted like any other park: attach a boundary to give the call a deadline or a get-out condition.

<call id="approval" ref="order-approval" next="ship"/>
<boundary id="too-slow" host="approval" on="timer"
          delay="7" unit="day" next="escalate"/>

Everything else — any of record, each or filter — is multi-instance: the elements run one after another inside the caller’s walk.

An element may park, which is how one approval per line is written:

<call id="approve-lines" ref="line-approval" each="self.lines" next="invoice"/>

Each element becomes its own child run, and each opens its own approval. The caller waits at the call node until every element has come back, then carries on to next — once, however many elements there were. Elements that run to completion without parking behave exactly as they always did, and nothing is written down for them.

A failing element ends the iteration: the approvals its siblings were holding go back, their runs are cancelled, and the caller fails at the call. There is no partial result to reason about.

maxSize caps how many elements a node may run, and it is worth setting when they park — each one is a run row and an approval in somebody’s inbox, not just a step in a long transaction.

A filter source does not support custom models, and still refuses a child that can park: its records are read a page at a time, so they are not all in hand when the barrier has to be written — use each over a collection instead. And an iteration inside a branch of a split refuses too.

A call activity inside a branch of a split fork parks like any other branch: the park belongs to the branch rather than to the run, and the child’s completion brings that branch back to walk on to its join. A failed child fails the branch, which takes the activation down with it — the siblings' approvals go back, the same as any branch that fails.

subflow

Runs the graph written inside it, rather than naming one to run. Same selectors, same waiting, same next — the only difference is where the child graph comes from, and everything else follows from that.

<subflow id="per-line" each="self.lines" model="com.axelor.sale.db.SaleOrderLine"
         next="invoice">
  <start id="s" next="check"/>
  <script id="check" next="approve"><![CDATA[
    vars.total = vars.total + self.price;
  ]]></script>
  <task id="approve" user="admin" next="e"/>
  <end id="e"/>
</subflow>
Attribute Required Description

child nodes

yes

the graph to run, written inside the element the way a <split>’s flows are: a `start and the whole node vocabulary, but never another <subflow>. Takes the place of a <call>’s `ref, and the two are mutually exclusive

model

no

the model the selected records are, checked against what the selector yields. Omit it and the elements are not type-checked — here the model is a check, not an identity

jsonModel

no

the custom (JSON) model name, when model is MetaJsonRecord

record, each, filter

no

exactly as on a <call> — see above. Writing none of them runs the body once, on this record

maxSize, batchSize, next

exactly as on a <call>

Which of the two to write

A <call> names another definition. That definition has an identity, a model and a revision of its own, anyone may call it, and it is isolated: it starts a run of its own, with its own variables, reading the caller’s only through the read-only parent.

A <subflow> carries its graph, and that graph runs inside the caller’s run. It has no identity — nothing else can run it, it is deployed and revised with its caller, and a run parked inside it stays on the revision its caller was pinned to rather than drifting onto a newer one. It shares the caller’s variables, and everything that happens in it belongs to the run: its parks are the run’s parks, and an approval it raises appears on the run’s record whatever the body is standing on.

That last part is the one to read twice. A <subflow each="self.lines"> runs its body over each line, so self inside it is a line — but the approvals it opens are filed against the order, and only there. Forty lines out for approval are forty approvals on one order, told apart by the line each one names: a node’s title is static, so the approval carries the element it was raised over and the inbox shows that beside the title. A model with no name field has nothing to show there, and those rows do read alike. A <call each="self.lines"> does the opposite: forty runs, each about a line, with its approval on that line.

So: reuse across definitions is a <call>; a region of this process is a <subflow>. They are BPMN’s callActivity and subProcess, and mean the same things — a call activity starts a new process instance, an embedded sub-process is part of the one it is written in.

Its start carries no trigger

A <subflow> is entered from its own node and from nowhere else, so on, message, watch, repeat, delay and unit are all refused on its <start>. when is allowed and is the useful one: it gates each element, exactly as a called workflow’s own start condition does.

Node ids are scoped to it, so it may reuse ids its caller already has — handy when the same shape is written twice.

And a <subflow> may not contain another. One level is what a diagram can show and a reader can hold; a region that wants a region of its own is a workflow of its own to <call>.

Sharing the caller’s variables

There is one rule, and it is the one every scope in the engine follows:

A read walks outward. A write lands where the name is already found, and is local to the body when it is found nowhere.

So an author says which they meant by where they first wrote the name, and declares nothing:

<script id="init" next="per-line"><![CDATA[ vars.total = 0; ]]></script>
<subflow id="per-line" each="self.lines" model="com.axelor.sale.db.SaleOrderLine"
         next="report">
  <start id="s" next="add"/>
  <script id="add" next="e"><![CDATA[
    vars.total = vars.total + self.price;  // the caller's — it named it first
    vars.note = 'seen';                    // its own — scratch
  ]]></script>
  <end id="e"/>
</subflow>
<script id="report" next="e"><![CDATA[
  self.checked = vars.total;   // the sum of every line
  // vars.note is not here: it went with the body
]]></script>

Both halves survive a pause. A body that parks on an approval finds its own names again when it resumes, and the caller sees what the body wrote to its names — but never what the body kept to itself.

A deadline on the whole region

This is the reason the node exists.

A boundary watches a stretch of process, not a node. Attach one to a <subflow> and it is armed from the moment the walk enters the region until the moment it leaves — however many threads are inside, and wherever each of them has stopped.

<subflow id="per-line" each="self.lines" model="com.axelor.sale.db.SaleOrderLine"
         next="invoice">
  <start id="s" next="approve"/>
  <task id="approve" role="sales.manager" next="e"/>
  <end id="e"/>
</subflow>
<boundary id="chase" host="per-line" on="timer" delay="7" unit="day"
          next="escalate"/>

"Every line approved within seven days, or escalate." The seven days are measured from when the order reached the node, not from when any one line happened to park. If the deadline passes with two of forty still out, the boundary fires once: the two approvals still open are withdrawn, nothing is left waiting, and the run continues from escalate. A non-interrupting boundary (cancel="false") runs its own branch beside the region and leaves it going.

This is the one thing a <call> cannot do. Its elements are runs of their own with nothing holding them together, so a deadline on the node could only ever mean a deadline per element — which is a boundary inside the child, and what BPMN prescribes there. boundary-host-invalid still says so for a <call>.

A timer or condition boundary needs a region that can stop: a body that runs straight through has no more to interrupt than a script does, and boundary-host-invalid says so. An error boundary needs no park and catches a failure raised anywhere inside.

Waiting, and the limits

A body parks in the run: when it stops, the run stops at the subflow node, and an iterating body opens one approval per element and waits for all of them before the run carries on past next.

One limit is worth knowing: a filter source needs model, since a query has to be a query against something.

task

A human gate: the run parks on a durable task until a person completes (or rejects) it, then continues through its single next. To route on the outcome, follow it with a split on the task binding.

<task id="approve" title="Approve large order"
  role="sales.manager" comment="always" next="after"/>

<boundary id="chase" host="approve" on="timer"
          delay="3" unit="day" next="escalate"/> (1)

<split id="after" mode="one">
  <flow id="f1" when="task.rejected" next="cancel" title="Rejected"/>
  <flow id="f2" default="true" next="confirm"/>
</split>
1 nobody answered in three days: the approval is withdrawn and the run leaves by this edge instead. See boundary.
Attribute Required Description

assignee

no

expression resolving to a user (e.g. self.salespersonUser); the task is assigned and auto-claimed

user

no

a user login; assigned and auto-claimed

role

no

a role name; its members form the candidate pool

group

no

a group code; its members form the candidate pool

comment

no

which action(s) require a comment: none, complete, reject or always; defaults to reject

allowReject

no

default true; false refuses a rejection on this task — see Restricting how a task may be closed

allowForceComplete

no

default true; false refuses even an admin’s force-complete — see Restricting how a task may be closed

allowCancel

no

default true; false refuses even an admin’s cancel — see Restricting how a task may be closed

maxVisits

no

how many times the run may park here before it is failed — see Bounding a loop that comes back to a park

when

no

JavaScript condition — see Gating approval on a condition

autoComplete

no

default false; lets when resolve the task by itself — see Gating approval on a condition

<blocked>

no

a child element: the message shown to someone who tries to approve while when is still false; a ${…​} span is evaluated against the record. Requires when

next

yes

the node the run resumes with after completion

A deadline on an approval is a boundary attached to it, not an attribute here.

An assignee (assignee/user) and a candidate pool (role/group) may coexist.

At least one of them must resolve to somebody, otherwise the run fails rather than parking a task nobody can see. Pooled tasks must be claimed before acting.

Pending tasks surface in the My Approvals inbox and on the record form’s toolbar approvals badge — see User Tasks & Approvals.

Gating approval on a condition

when, on its own, only gates completion: a person can still always decide, but Approve is refused while it is false and allowed once it holds. autoComplete is a separate, opt-in step further — letting the condition resolve the task itself, with nobody deciding at all:

<task id="approve" role="sales.manager" next="ship"
  when="self.amount &lt;= self.approver.limit" autoComplete="true">
  <blocked><![CDATA[
    Amount ${self.amount} exceeds your approval limit of ${self.approver.limit}.
  ]]></blocked>
</task>
  • False when someone tries to approve — the approval is refused with <blocked>, rendered against the record as it is now, or with a generic statement that the condition does not hold where the node authored none; the task stays open and pending. Reject and an admin’s Force complete make no such claim, so neither is affected.

  • Already true when the node is reached, with autoComplete set — the task is never opened for anyone. A real, completed MetaWorkflowTask is still recorded (outcome condition, nobody assigned), so a downstream split or script reads task.* exactly as it would after a person answered.

  • Becomes true while the task is still pending, with autoComplete set — the record is re-evaluated on every update the same way a condition wait is, and a task whose condition now holds completes itself (outcome condition) without anyone acting.

Without autoComplete, the last two never happen: the condition only ever gates a person’s own decision, and a pending task stays pending — however true it becomes — until somebody clicks Approve. autoComplete and <blocked> both require when, and are rejected without it.

Restricting how a task may be closed

Some approvals should only ever be completed, never rejected — a step that is a formality once reached, or one where "no" has to be escalated rather than recorded. allowReject="false" refuses a rejection on the task entirely; the person can still act, just not that way. allowForceComplete="false" similarly withdraws the one override even an admin normally has, for a task where nobody should be able to skip the check it exists to make, and allowCancel="false" withdraws the other: the run cannot be abandoned from an approval that has to be answered. All three default true, and none is related to when, which never gates any of them.

Note what allowCancel="false" does not do. A run parked on such a task can still be ended — by a boundary that withdraws the task on a deadline or an event, which is a route the workflow itself declares. What it refuses is the unplanned exit: an administrator discarding the run from the approval screen.

Each setting is snapshotted onto the task when it parks, so the approval screens draw only the buttons the node allows — a withdrawn action is absent rather than offered and then refused. The service checks again when the action is attempted: the snapshot shapes the screen, it never authorizes.

Bounding a loop that comes back to a park

A rework loop — reject, fix, submit again — is an ordinary shape, and nothing stops it going round for ever. maxVisits on a task or a wait is the one guard the engine can honour: when the run reaches the node for the maxVisits+1-th time, it fails there instead of parking again, with a message naming the node and the limit.

<task id="review" role="reviewers" maxVisits="3" next="outcome"/>

Only the nodes that park carry a limit, deliberately: their count is durable across resumes at every trace level, while an ordinary node’s is known only within the current segment of the walk, and the engine does not promise what it cannot keep. Scripts can read the same count as visit. When a node declares no maxVisits, the workflow.node.max-visits option applies; unset, there is no limit.

boundary

A way out of a park that the park’s own trigger may never provide. A <wait> for a condition is re-evaluated only when its record changes, so if nothing ever changes it waits forever; an approval waits as long as the person does. A <boundary> gives either of them a deadline, or a second condition, and somewhere to go.

It is a node of its own, attached to the node it watches by host. The run never arrives at it — it holds no token. While its host is parked, it races the host’s own trigger.

<task id="approve" role="sales.manager" next="ship"/>

<boundary id="chase" host="approve" on="timer"
          delay="7" unit="day" next="escalate"/>              (1)
<boundary id="expire" host="approve" on="timer"
          until="self.quoteValidUntil" next="abort"/>         (2)
<boundary id="abandon" host="approve" on="condition"
          when="self.status == 'CANCELED'" next="abort"/>    (3)
1 a deadline counted from the moment the run parked
2 a deadline the record already carries
3 a condition on the record
Attribute Required Description

host

yes

the node this boundary watches. For timer, condition, message and signal, one that parks — a <wait>, a <task>, a <call> that calls once (no each or filter), or a <subflow> whose body can stop. For error, one that runs — a <script>, an <agent>, an <email>, or any <call> or <subflow>

on

one of these

timer, condition or error. The first two are vocabulary a <wait> shares; error is not, because a run cannot park waiting to fail. A message or a signal is written with message or signal instead, never on="message"

delay / unit

timer

how long after the run parked (minute, hour, day). One of this pair or until is required on a timer; both together is an error

until

timer

an expression naming the instant the deadline falls, such as self.dueDate, for a deadline the record carries rather than one counted from the park

when

condition

a JavaScript condition on the record, re-read whenever the record changes

message / key

one of these

the message to watch for while the host is parked — the same message and key a message wait takes. key is required with message

signal

one of these

the signal to watch for, as a signal wait takes it; refuses key

code

no

error only: catch just the failure raised under this name. Absent catches any failure of the host, a script error included

next

yes

the node to continue from

cancel

no

true (default) closes the park when the boundary fires; false leaves it open and starts an escape branch instead. Rejected on an error boundary, which has no park to close

repeatCount

no

how many times a reminder fires, each occurrence one delay further from the park than the last; once by default. At least 2, and only alongside cancel="false" and a delay/unit pair. Rejected on an error boundary

title

no

a label for the designer and the run viewer

Attachment is stated rather than inferred. An edge into a node that can park reads equally as "watch this" and as "wait, then carry on", so only host tells the two apart.

By default (cancel true), taking a boundary closes the park: a waiting approval is withdrawn — nobody answered it — and the run continues from the boundary’s own next. Which way the run went is therefore visible from the edge it left by, with no condition to test afterwards.

On a <call> call activity, closing the park means cancelling the call: the child workflow is ended, along with its own approvals and anything it called in turn. "Wait for the approval sub-process, but give up after a week" is one boundary and no bookkeeping.

Boundaries on one host are tried in the order they are declared, and before the host’s own trigger, so one declared first wins over a later one and over the wait’s own condition in the same pass. A cancelling boundary ends the pass there. A non-cancelling one fires and the pass goes on: a condition wait’s own when is still tested in that same pass, a timer wait’s own deadline is re-tested only when the scheduler wakes it, and an approval has no trigger of its own to test.

A boundary’s next may lead anywhere, including back into the main path. It may not lead back to its own host: that would close the park and open a new one, replacing the approval somebody is holding with a fresh one that has lost its assignee, comments and history. For a non-cancelling boundary the host is never closed either way, but a next back to it would still open a second, independent park on the same node inside the branch.

A withdrawn approval still records why it closed, so a deadline stays distinguishable from an approval an administrator cancelled: a timer boundary leaves outcome as timeout and sets timedOut.

A delay is measured from when the run parked, on the revision it pinned — so it neither drifts when the record is touched repeatedly, nor moves if a later revision changes the delay. An until follows its field instead: it is read when the run parks and again whenever the record changes, so pushing self.dueDate out grants an extension and moving it into the past says the deadline has gone by. If the expression stops resolving to a date — the field was cleared, say — the instant already armed is kept rather than failing a park that was waiting quite happily. The same holds for the until of a wait.

A reminder that does not close the park

A boundary declared cancel="false" fires without closing anything: the park stays open, and the run starts a second, independent path from the boundary’s own next alongside it — see Monitoring runs for where that path shows up.

<task id="approve" role="sales.manager" next="ship"/>

<boundary id="remind" host="approve" on="timer" delay="2" unit="day"
          cancel="false" next="notify-manager"/>
<boundary id="expire" host="approve" on="timer"
          delay="7" unit="day" next="abort"/>

A timer boundary like remind fires once per park. To chase somebody more than once, give it a repeatCount rather than writing one boundary per reminder — each occurrence falls one delay further from the park than the last.

<boundary id="remind" host="approve" on="timer" delay="1" unit="day"
          cancel="false" repeatCount="3" next="notify-manager"/>

That nudges the approver a day after the run parked, again on days two and three, and then goes quiet. Every occurrence starts its own escape branch. Occurrences already overdue — after downtime, say — are taken one per scheduler pass rather than all at once.

repeatCount describes a reminder, so it needs cancel="false" and a delay/unit pair, and must be at least 2. A cancelling boundary ends the park the first time it fires, and an until names a single instant that cannot recur, so the validator rejects repeatCount on either.

A condition boundary is edge-triggered instead: it fires when its when becomes true, and is armed again once the condition has dropped and comes true a second time. It needs no count.

A run whose own walk has already finished holds at waiting — never completed or failed — while a branch it spawned is still going, and settles once the last one ends.

A non-cancelling boundary may not sit inside a race — a some/all split whose join is wait="any" or wait="quorum". Its escape branch is a run of its own that no branch owns, so cancelling the losing branch could not stop it, and "the remaining branches are cancelled" would not be true (boundary-cancel-invalid). See Branching and Joining.

Catching a failure

on="error" is the odd one out. Its host is a node that runs rather than one that parks, nothing arms it, and it is consulted where the failure is raised rather than by the scheduler. It is how a path that did not work out becomes a route rather than the end of the run:

<script id="reserve" next="ship"><![CDATA[
  if (self.stock < self.qty) fail('NO_STOCK', 'only ' + self.stock + ' left');
]]></script>

<boundary id="backorder" host="reserve" on="error"
          code="NO_STOCK" next="orderMore"/>            (1)
<boundary id="anyFailure" host="reserve" on="error"
          next="raiseTicket"/>                          (2)
1 catches one named failure
2 catches any failure; written after the named one, because boundaries are tried in declaration order

Three things to know before relying on it.

A code catches only a failure raised under that name; no code catches any. fail() and an <end error="…​"> raise a named failure, and an agent that did not answer or a send the email provider refused raise one without a name. A boundary without a code also catches what merely escaped — a null dereference in a script, a bad field name — so a mistake there takes the boundary’s path instead of failing the run. The node’s visit still records the error, so check the run history when a handler fires more often than expected. A failure that left the transaction unable to commit, such as a constraint violation on save, is never caught: nothing the handler wrote could be saved.

Nothing is rolled back. A failing node’s writes are committed with the rest of the dispatch, so the handler resumes from a record the failed node may already have changed. Treat a handler as compensation, not as a retry: it should assume the work was partly done. The one exception is a multi-instance call, whose elements run inside the caller’s own transaction — there a failing element does take the caller back with it, as the warning under call says.

A failure reaches no run that is not an ancestor of the failing one. It travels up — a <call> child’s uncaught failure is offered to an error boundary on the <call> node that called it — and never sideways to an unrelated run. That is the whole difference from a broadcast.

The node is still recorded as failed, whether or not a boundary catches it: a boundary changes where the run goes next, not what happened.

The boundary’s path reads what it caught as error: error.message is the failure’s text and error.code its name, or null when it has none. Like agent, it stays bound along that path, across any park, until another caught failure replaces it or a join ends the branches:

<script id="report" next="done"><![CDATA[
  log.info('reserve failed: ' + error.message);
]]></script>

What the validator refuses

Each of these is caught when the workflow is saved or loaded, naming the boundary and its host.

Code Meaning and fix

boundary-host-required / boundary-host-unresolved

Every boundary names an existing node in host.

boundary-host-invalid

A timer, condition, message or signal boundary whose host is a script, agent, email, send, split, join or end: it runs to completion inside the walk, so there is no park to interrupt. Attach the boundary to the wait, task, the call that runs once, or a subflow whose body can stop. For a subflow the rule is read against the region: a body containing nothing that waits is refused for the same reason a script is.

boundary-host-invalid

An error boundary whose host is a wait, task, send, split, join or end: it runs nothing that could fail. Attach it to a script, agent, email, call or subflow.

boundary-host-invalid

A timer or condition boundary whose host is a call with each or filter: its elements are runs of their own with nothing holding them together, so a deadline here would be a deadline per element — which is what a boundary inside the child already is, and what BPMN prescribes. Put it there, drop the selector to make this a single call, or use a <subflow>, whose elements run as one region a boundary can watch. An error boundary is fine here — every element is something that can fail. Does not apply to a subflow.

boundary-on-required / boundary-on-duplicate / boundary-on-invalid

A boundary says what it watches for exactly once: on (timer, condition or error), or message, or signal. on="message" and on="signal" are refused — write message="…​" or signal="…​".

boundary-until-unexpected / boundary-delay-required

A timer boundary carries delay+unit or until — one of them, not both, not neither.

boundary-delay-invalid

delay is at least 1.

boundary-when-unexpected

A timer boundary carries no when; a condition is on="condition".

boundary-when-required / boundary-delay-unexpected

A condition boundary carries when and no deadline; a deadline is on="timer".

boundary-next-required

Every boundary has a next.

boundary-next-invalid

next points at the host. On a park that closes it and opens a fresh one, losing the approval’s assignee, comments and history; on an error boundary it re-runs the node that just failed, for ever. Point at the node after it, or at another path.

boundary-delay-unexpected / boundary-cancel-unexpected / boundary-repeat-count-unexpected

An error boundary carries delay, unit, until, when, cancel or repeatCount. It has no park to time, to watch or to cancel, and a failure happens once. Drop them.

boundary-repeat-count-unexpected / boundary-repeat-count-invalid

repeatCount describes a reminder: it needs cancel="false", a delay/unit pair (not until), and a value of at least 2.

boundary-cancel-invalid

A cancel="false" boundary inside an any/quorum race; see the caution above.

boundary-when-parse-error / boundary-until-parse-error

The expression does not parse as JavaScript.

agent

Invokes an AI agent and exposes its answer to the following nodes. The <prompt> child is the prompt template: literal text with ${…​} placeholders, each a JavaScript expression evaluated against the record — self, old, the vars bag, and the other bindings, the same scope as script and when. Wrap it in <![CDATA[…]]> (like a script body) so <, >, and & in the prompt are safe.

<agent id="classify" ref="support-triage" next="route">
  <prompt>
  <![CDATA[
    Classify the urgency of this ticket for customer ${self.customer.name}:

    ${self.description}
  ]]>
  </prompt>
</agent>

<split id="route" mode="one">
  <flow id="f1" when="agent.result.urgency == 'high'" next="escalate"/>
  <flow id="f2" default="true" next="queue"/>
</split>

Only ${ is special — write \${ for a literal one; everything else, including backticks and backslashes, passes through verbatim. A predefined context line is prepended automatically, so the agent knows which workflow, step, and record it is acting on without you restating it.

Attribute Required Description

ref

yes

the code of the AI agent to invoke

next

yes

the node to continue with

async

no

false to ask inside the walk; defaults to true, see Running in the background

The agent receives the record identity and the run’s variables. Its answer is bound as agent.text (raw text) and agent.result (parsed JSON when the answer is JSON, else the raw text). Agents are provided by the built-in AI service.

Declaring the answer you need

The split above reads agent.result.urgency, which is a promise nobody made. If the agent answers in prose instead of JSON, agent.result is that prose, urgency is absent, and the split quietly takes its default branch: the run succeeds and the ticket is routed wrongly. Nothing distinguishes the agent did not answer in the shape we wanted from the field was absent.

An <output> child closes that gap. It is described to the agent as part of the prompt, and the answer is checked against it before any later node can read it.

<agent id="classify" ref="support-triage" next="route">
  <output>
    <field name="urgency" type="string" required="true">
      <option value="high"/>
      <option value="normal"/>
      <option value="low"/>
    </field>
    <field name="score" type="number"/>
    <field name="reasons" type="array"/>
  </output>
  <prompt><![CDATA[ Classify this ticket: ${self.description} ]]></prompt>
</agent>
Attribute Required Description

name

yes

the key the workflow reads off agent.result

type

no

string (the default), number, boolean, array or object

required

no

when true, an answer omitting the field fails the node

description

no

what the field means, shown to the agent; never checked

A string field may also list the values it accepts:

<field name="urgency" type="string" required="true">
  <option value="high"/>
  <option value="normal"/>
  <option value="low"/>
</field>

The list is sent to the agent and checked on the way back. Matching is exact and case-sensitive: High is not high, and nothing is trimmed or repaired — a near-miss is precisely what a split would route on wrongly rather than notice. Options are for strings only; on any other type they would read as a constraint and do nothing, so they are rejected.

A model that has called tools often wraps its answer in a code fence or leads with a sentence. Where a shape is declared, the one JSON object the answer holds is read out of that wrapping; text with no object in it still fails the node. Three things fail the node, each naming what went wrong: an answer that is not a JSON object at all, a missing required field, and a field whose value has the wrong type. Failing is the point — the alternative is routing a record on a field the agent never gave. An optional field may simply be absent, and the workflow decides what that means.

There is no nesting: a nested shape is type="object", and what you read inside it is at your own risk. Declaring nothing keeps the old behaviour exactly — whatever the agent said is bound as-is.

The shape is an instruction to the agent, not a constraint on how it generates. It is checked on arrival, which is why a mismatch is an error you can read rather than a silent one.
This call runs with nobody watching it. The agent never sees a CLIENT tool (there is no browser behind this step) or any tool that would need a person’s approval, including one whose requiresApproval is off by default — there is nobody here to grant it. See Security for the full rule. Grant this agent only tools safe to run unattended, and expect its answer, not a side effect, to be what the rest of the workflow acts on.

end

Terminates the walk. A workflow may have several end nodes.

<end id="done"/>
<end id="stop" terminate="true"/>
<end id="giveUp" error="UNFULFILLABLE"/>
Attribute Required Description

terminate

no

stop the whole run here, not just this path

error

no

end the run as a failure under this name rather than as a success. Contradicts terminate, so writing both is rejected

A plain <end> ends the path that reached it; inside a fork, its siblings carry on. terminate="true" is BPMN’s terminate end event — the run stops, every branch still in flight is cancelled, and the approvals they were holding are withdrawn from the inboxes holding them. It is the shape behind "a rejection anywhere stops the process":

<task id="review" role="reviewers" next="outcome"/>
<split id="outcome" mode="one">
  <flow id="f1" when="task.rejected" next="stop" title="Rejected"/>
  <flow id="f2" default="true" next="merge"/>
</split>
<end id="stop" terminate="true"/>

A terminated run is not a failure — it did what it was asked — and is recorded with the status terminated, so it can be told apart from one that simply ran out of graph (completed).

error is the opposite: the run ends failed, under a name of the author’s choosing. It is BPMN’s error end event, and the declarative half of fail(code, message) — a path that means the process could not be completed, said in the graph rather than inside a script:

<split id="outcome" mode="one">
  <flow id="f1" when="self.stock == 0" next="giveUp"/>
  <flow id="f2" default="true" next="ship"/>
</split>
<end id="giveUp" error="UNFULFILLABLE"/>

Nothing inside the same run can catch it: a boundary attaches to a host, and an end is not something anything attaches to. Its reader is whoever called this workflow — a <boundary on="error" code="UNFULFILLABLE"> on their <call> node. See Catching a failure.