Skip to content

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.

<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.
<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:

  1. the comment the user typed on the transition (so a rejection reason lands on the record),
  2. the authored RemarkText,
  3. 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.

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.

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 (for AppNotification).

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.

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 &gt; 2000?"><Position X="660" Y="220" /></Node>
…
<Transition From="ThresholdCheck" To="AdminReview" Expression="@TotalAmount &gt; 2000" Order="1" FlowStatus="Awaiting Admin Approval" />
<Transition From="ThresholdCheck" To="Approve" Expression="true" Order="2" />

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 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 &gt; 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.

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.

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.

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, both AppNotification and SendEmail actions, a Start-node <Binding> that keeps the order’s own Status/Notes columns in step (with EntityStatus mapping 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).