Authoring a workflow
A workflow definition is a single XML document with a <Workflow> root, a <Nodes> block, and a
<Transitions> block. This page is the full element and attribute reference, with real snippets from
the Inventory samples. For the mental model, start with the overview.
Root — <Workflow>
Section titled “Root — <Workflow>”<Workflow Name="ProductApproval" Slug="product-approval" Label="Product Approval" EntityType="WizardFormResponse" Category="Inventory"> <Nodes> … </Nodes> <Transitions> … </Transitions></Workflow>| Attribute | Required | Meaning |
|---|---|---|
Name |
yes | Unique, immutable lookup key. Wizard/view hooks reference the workflow by this name; it is not changed on save. |
Label |
yes | Human-readable display name. |
Slug |
no | URL slug; omit to derive a kebab-case slug from Name. |
EntityType |
no | The kind of entity this workflow runs over (e.g. WizardFormResponse, or a view name like PurchaseOrders). |
Category |
no | Grouping label for the designer/listing. |
Nodes — <Node>
Section titled “Nodes — <Node>”<Node Key="PurchasingReview" Type="Approval" Label="Purchasing Review"> <Position X="360" Y="200" /> …</Node>| Attribute / child | Meaning |
|---|---|
Key |
Unique node identifier, referenced by transitions’ From/To. |
Type |
One of Start, Standard, Approval, Action, Decision, Assignment, End. |
Label |
Display label. |
<Position X Y/> |
Canvas coordinates for the designer. Samples also carry Width/Height on Start/End; those are designer layout hints and are ignored by the engine. |
<Description> |
Optional free text. |
Activity attributes (ActivityType, ActivityRoute, ActivityTitle, ActivityFormStyle) point
a node’s task at a form or view — e.g. what opens when a user works the node in a task list.
ActivityFormStyle is Modal (default) or Redirected. Approval nodes default their activity to the
built-in WF_ApprovalForm, so you rarely set these by hand.
Every workflow needs exactly one Start node — saving fails otherwise.
Start node — binding a record with <Binding>
Section titled “Start node — binding a record with <Binding>”The Start node can carry an optional <Binding> that ties the whole workflow to the record it runs
over. With one, that record’s own state column is kept in step with the flow automatically — no
Action node, no hand-written UPDATE per edge, and portable across SQL Server and PostgreSQL:
<Node Key="Start" Type="Start" Label="Submitted"> <Position X="60" Y="220" /> <Binding Entity="Inventory.PurchaseOrders" PrimaryKey="Id" StateColumn="Status" RemarkColumn="Notes" /></Node>| Attribute | Required | Meaning |
|---|---|---|
Entity |
yes | The entity’s table — Schema.Table or Table. |
PrimaryKey |
no (default Id) |
Primary-key column matched against the instance’s EntityId. |
StateColumn |
yes | Column that receives the status. |
CompanyColumn |
no (default CompanyId) |
Tenant column. The update is scoped with AND {CompanyColumn} = @SessionCompanyId, so a flow can never stamp another tenant’s row. Author CompanyColumn="" to opt a non-tenant table out. |
RemarkColumn |
no | Column that receives the transition comment (or the default remark below). |
RemarkText |
no | Replaces the default remark. Handlebars over the workflow context, plus {{WorkflowLabel}} / {{WorkflowName}}. |
Every transition that carries a status writes it to the bound record. What gets written is the
transition’s EntityStatus when set, else its FlowStatus. A transition
carrying neither writes nothing, so the record is only touched when the flow actually says so.
What lands in RemarkColumn, in order of precedence:
- the comment the user typed on the transition (so a rejection reason lands on the record),
- the authored
RemarkText, - otherwise a generated line — “Updated automatically by the Purchase Order Approval workflow.”
(built from the workflow
Label).
Binding is optional. Leave it out and transitions only move the instance — exactly as before.
Approval node
Section titled “Approval node”An Approval node pauses the flow until its gate is satisfied. It carries an optional <Sla> and a
required <ApprovalConfig>.
<Node Key="PurchasingReview" Type="Approval" Label="Purchasing Review"> <Position X="440" Y="220" /> <Sla TimeoutMinutes="1440" Escalation="Notify" ReminderMinutes="720" /> <ApprovalConfig> <Type>Any</Type> <Approvers> <Approver Type="Role" Value="Purchasing" /> </Approvers> <OnApprove Transition="PurchasingApproved" /> <OnReject Transition="Rejected" /> </ApprovalConfig></Node><ApprovalConfig>
| Element | Meaning |
|---|---|
<Type> |
Gate policy: Any, Sequential, or Parallel. See Engine — approvals. |
<Approvers> / <Approver> |
Who may decide. Type="Role" with Value="RoleName", Type="User" with a numeric user id, or Type="FieldValue" with a @ContextVar holding a user id. An <Approver> may also carry an <Sql> child (reserved for custom resolution). |
<OnApprove Transition="…"/> |
Transition Label to follow when the gate approves. |
<OnReject Transition="…"/> |
Transition Label to follow when the gate rejects. |
<Sla> — a deadline hint on the node:
| Attribute | Meaning |
|---|---|
TimeoutMinutes |
Minutes until the approval is considered breached. |
Escalation |
Escalation action label (e.g. Notify). |
ReminderMinutes |
Optional — minutes after which a reminder is due. |
<EscalationConfig> |
Optional child for extended escalation settings. |
Action node
Section titled “Action node”An Action node runs a side effect and then auto-advances through its single outgoing transition.
<Node Key="NotifyPurchasing" Type="Action" Label="Notify Purchasing"> <Position X="240" Y="220" /> <ActionConfig> <ActionType>AppNotification</ActionType> <Payload> <To>Role:Purchasing</To> <Message>Purchase Order {{Number}} for {{TotalAmount}} was submitted and is awaiting review.</Message> </Payload> </ActionConfig></Node><ActionType> is case-insensitive and one of:
| ActionType | <Payload> shape |
Effect |
|---|---|---|
ExecuteSql |
Raw parameterised SQL text. | Runs the SQL against the database. |
AppNotification |
Child elements <To> and <Message>. |
Queues an in-app notification (delivered over SignalR/web-push, no extra config). |
SendEmail |
Child elements <To>, <CC>, <Subject>, <MailBody>. |
Queues an email; only sends when SMTP (Genie:Notifications:Email) is configured. |
Token interpolation. Subject/MailBody/Message are Handlebars templates rendered over the
workflow context — e.g. {{ProductName}}, {{Number}}, {{TotalAmount}}, and the special
{{FlowStatus}}. Plus {{EntityId}}, {{InstanceId}}, {{EntityType}}.
Recipients. <To> and <CC> are Handlebars templates too — they are rendered over the
workflow context first, then split on commas/semicolons and each entry resolved as one of:
Role:RoleName— every member of the role,@ContextVar— a context variable holding a user reference (e.g.@AssignedToUserId),- a literal email address (for
SendEmail) or username (forAppNotification).
Because the template renders first, a recipient captured on the entity works directly —
<To>{{Email}}</To> emails the address stored in context (e.g. a newsletter subscriber’s own
address), and mixed lists like <To>Role:Sales, {{Email}}</To> combine both:
<ActionConfig> <ActionType>SendEmail</ActionType> <Payload> <To>{{Email}}</To> <Subject>Welcome!</Subject> <MailBody>Thanks for subscribing, {{Email}}.</MailBody> </Payload></ActionConfig>AppNotification resolves recipients to usernames; SendEmail resolves them to email addresses.
ExecuteSql payloads run parameterised with @RecordId, @EntityId, @InstanceId, @FlowStatus,
the session parameters, and every context variable (@ContextVar). Example:
<Node Key="Activate" Type="Action" Label="Activate Product"> <Position X="700" Y="200" /> <ActionConfig> <ActionType>ExecuteSql</ActionType> <Payload>UPDATE Inventory.Products SET Status = 'Active', IsActive = 1, UpdatedAt = SYSDATETIMEOFFSET() WHERE Id = @ProductId;</Payload> </ActionConfig></Node>Action SQL runs through the same provider-agnostic path as transition <Sql>,
so the same statement works on SQL Server and PostgreSQL and literal { / } need no escaping.
Decision node
Section titled “Decision node”A Decision node has no config of its own — it auto-advances down the first outgoing transition (by
Order) whose Expression passes. Give the fallback branch Expression="true" and the highest
Order:
<Node Key="ThresholdCheck" Type="Decision" Label="Total > 2000?"><Position X="660" Y="220" /></Node>…<Transition From="ThresholdCheck" To="AdminReview" Expression="@TotalAmount > 2000" Order="1" FlowStatus="Awaiting Admin Approval" /><Transition From="ThresholdCheck" To="Approve" Expression="true" Order="2" />Assignment node
Section titled “Assignment node”An Assignment node sets the instance’s assigned role/user (so it lands in the right queue). Its
<AssignmentConfig> accepts <RoleName> / <UserName> (static, looked up by name) or
<RoleFromContext> / <UserFromContext> (a @ContextVar that takes precedence), plus <ClearUser>
(default true).
Transitions — <Transition>
Section titled “Transitions — <Transition>”<Transitions> <Transition From="Start" To="NotifyPurchasing" Label="Submit" Order="1" FlowStatus="Submitted" /> <Transition From="PurchasingReview" To="ThresholdCheck" Label="PurchasingApproved" FlowStatus="Purchasing Approved" /> <Transition From="PurchasingReview" To="NotifyRejected" Label="Rejected" RequireComment="true" FlowStatus="Rejected" /></Transitions>| Attribute / child | Meaning |
|---|---|
From |
Source node Key. |
To |
Target node Key. |
Label |
Human name; also the value OnApprove/OnReject and view hooks reference. |
Expression |
Optional guard (see below). On a manual/Decision edge it must pass for the edge to fire. |
Order |
Evaluation order for Decision branching (lowest first). |
RequireComment |
true forces a comment when this transition is taken (e.g. a rejection). |
FlowStatus |
Business status stamped on the instance when this transition fires (sticky — see below). |
EntityStatus |
Status written to the bound record instead of FlowStatus. Ignored without a <Binding>. |
<Sql> |
SQL run when this transition fires (see below). |
EntityStatus — when the record’s vocabulary is narrower
Section titled “EntityStatus — when the record’s vocabulary is narrower”A flow’s statuses are usually richer than the record’s own column, which is often a fixed <Select>.
EntityStatus lets each edge say what the record should read while FlowStatus says what the flow
reads:
<Transition From="ThresholdCheck" To="AdminReview" Expression="@TotalAmount > 2000" FlowStatus="Awaiting Admin Approval" EntityStatus="Submitted" /><Transition From="AdminReview" To="NotifyRejected" Label="Rejected" RequireComment="true" FlowStatus="Rejected" EntityStatus="Cancelled" />Where the two agree, leave EntityStatus out.
Transition <Sql> — updating other objects
Section titled “Transition <Sql> — updating other objects”A transition can carry a <Sql> child that runs when that edge fires — for driving objects the
binding doesn’t cover:
<Transition From="AdminReview" To="NotifyApproved" Label="AdminApproved" FlowStatus="Approved"> <Sql> UPDATE Inventory.StockMovements SET Status = 'Reserved' WHERE PurchaseOrderId = @EntityId AND CompanyId = @SessionCompanyId; </Sql></Transition>It is parameterised with the same values as Action-node SQL — @RecordId, @EntityId, @InstanceId,
@FlowStatus, the session parameters (@SessionUserId, @SessionCompanyId, @SessionRoles, …) and
every context variable — plus this edge’s own: @TransitionLabel, @TransitionFrom,
@TransitionTo and @Comment.
Ordering and failure. The bound-record update runs first, then the transition SQL (so it can
extend or override what the binding just wrote), then the instance state is saved — all in one
transaction. If any statement fails the whole transition is rolled back: the instance does not advance
and no transition log is written. Because the instance row is not yet committed while your SQL runs,
use the parameters above rather than re-reading Workflow.Instances.
Transition expressions
Section titled “Transition expressions”The expression evaluator supports:
- comparisons
==,!=,>,<,>=,<=, IS NULL/IS NOT NULL,- boolean
AND/OR(quotes are respected when splitting), - the literals
true/false.
Operands are @ContextVar references or literals. Numbers are compared numerically; strings are
compared case-insensitively. @FlowStatus is available as an operand. An expression the evaluator
can’t parse returns false (fail-safe). Full details in Engine — expressions.
FlowStatus is sticky
Section titled “FlowStatus is sticky”A transition’s FlowStatus only overwrites the instance’s current business status when the value is
non-empty. A Decision fallback like Expression="true" with no FlowStatus therefore leaves the
prior status untouched. FlowStatus is distinct from the engine’s lifecycle Status
(Active/Completed/Cancelled). Each workflow defines its own status vocabulary — there is no
global enum.
Complete sample
Section titled “Complete sample”The two end-to-end references are in sample/Inventory/models/workflows/:
ProductApproval.workflow.xml— a wizard-started approval that activates a product on approval.PurchaseOrderApproval.workflow.xml— a view-started approval with a threshold Decision, an Admin sign-off branch, bothAppNotificationandSendEmailactions, a Start-node<Binding>that keeps the order’s ownStatus/Notescolumns in step (withEntityStatusmapping the flow’s richer vocabulary onto the order’s fixed<Select>), and a transition<Sql>that re-totals the header.
In the designer, the binding is edited in the Start node’s Bound record category — set Bind
Record to true and the Entity / column rows appear — and EntityStatus / transition <Sql> sit in
the transition’s Status and SQL categories beside Flow Status.
Import either through the Workflow Designer (/workflow-designer → paste → Save, keeping the
Name). See Versioning & company scoping for what happens on save.
The designer’s toolbar search box finds any node by its key, label, or activity title — debounced, with the matched text highlighted in yellow, a highlight ring on matching nodes, and a live match count. Press Enter to hop through the matches one at a time (Shift+Enter for the previous) — the canvas centres on each hit and marks the current one in a distinct colour. The canvas is tuned to stay responsive on large graphs: drag and pan re-render only the affected nodes and transitions, not the whole diagram, and zoom goes down to 5% to fit big flows.
A selection-mode toggle sits beside the zoom controls: turn it on to drag a marquee over the empty canvas and multi-select nodes (Shift+click adds/removes one), then grab any selected node to move the whole group together; Delete removes them all. In selection mode the mouse wheel pans the canvas (Figma-style — Shift scrolls horizontally, Ctrl/⌘+wheel still zooms); outside it, the wheel zooms. Dragging with the middle mouse button grabs and pans the whole frame from anywhere (in either mode).