Authoring a wizard
A wizard is one *.wizard.xml file: a <Wizard> root, a flat list of <Node> elements, and a
flat list of <Link> edges connecting them. Every example below is drawn from the canonical sample,
NewProductRequest.wizard.xml.
The XML is a lossless serialisation of the visual designer model — you can hand-author it, paste it into the Wizard Designer, or round-trip between the two.
Root — <Wizard>
Section titled “Root — <Wizard>”<Wizard Name="NewProductRequest" Label="New Product Request" Slug="new-product-request" Schema="Inventory"> ...</Wizard>| Attribute | Required | Meaning |
|---|---|---|
Name |
yes | Unique PascalCase identifier. The immutable lookup key; resolvable in the API by name. |
Label |
yes | Human-readable display title. |
Slug |
no | URL slug for the runner route /form/{slug}. Omitted → derived (kebab-cased) from Name. |
Schema |
no | Database schema context for the wizard’s SQL. |
Nodes — <Node>
Section titled “Nodes — <Node>”Every node shares the same envelope; the Type selects which children and extra attributes apply.
<Node Id="details" Type="Input" Label="Product Details" X="220" Y="-12" Width="200" Height="160"> ...</Node>| Attribute | Meaning |
|---|---|
Id |
Node identifier, unique within the wizard; referenced by <Link>. |
Type |
One of Startup, Input, Decision, Action, Terminal. |
Label |
Display label (step heading in the runner, node caption in the designer). |
X, Y |
Canvas position (designer layout only). |
Width, Height |
Optional canvas size. |
Startup node
Section titled “Startup node”Initialises global variables then auto-advances. Globals are visible to every later step’s SQL and Decision expressions.
<Node Id="start" Type="Startup" Label="Start" X="-60" Y="38" Width="160" Height="60"> <Variables> <Variable Name="Channel" Type="String" Default="Wizard" /> </Variables></Node><Variable> attributes: Name, Type (String | Int | Decimal | Bool | Date |
DateTime), and optional Default.
Input node
Section titled “Input node”Collects user data. Holds a <Fields> block, an optional <Layout>, and an optional
<SubmitSql>.
<Node Id="details" Type="Input" Label="Product Details" X="220" Y="-12" Width="200" Height="160"> <Fields> <Field Name="ProductName" Type="String" Label="Product Name" Required="true" /> <Field Name="CategoryId" Type="Select" Label="Category" Required="true" ControlType="Select2"> <OptionsSql> SELECT Id AS Value, Name AS Label FROM Inventory.Categories WHERE IsDeleted = 0 AND IsActive = 1 ORDER BY Name </OptionsSql> </Field> <Field Name="UnitPrice" Type="Number" Label="Unit Price" Required="true" /> <Field Name="ReorderLevel" Type="Number" Label="Reorder Level" Default="10" /> <Field Name="Justification" Type="Text" Label="Justification" /> </Fields></Node>Fields — <Field>
Section titled “Fields — <Field>”| Attribute | Meaning |
|---|---|
Name |
Variable name. Must be PascalCase and unique across all Input nodes in the wizard. |
Type |
String | Text | Number | Date | DateTime | Boolean | Select | Attachment. Text renders multi-line. |
Label |
Field label. |
Required |
true to require a value before advancing. |
Indexed |
true to store this field as a real, indexable column on the response table — see Storage and indexes. Not available for Text or Attachment. |
Disabled / Readonly |
Render the control disabled / read-only. |
Default |
Seeded value if the field is empty when the step is first shown. |
Mask |
Input mask. |
ControlType |
For Select: Select2 (searchable) or ModalSelector. |
MaxSize, MaxFiles, Accept |
For Attachment: byte cap, file count, accepted MIME/extensions. |
Field children:
<Options>— static select options:<Option Value="…" Label="…" />.<OptionsSql>— dynamic select options; the query must returnValueandLabelcolumns.<SuggestionsSql>— autocomplete suggestions for aTextfield (@SearchTextis injected).<ExtraMappings>— copy extra columns from the picked dataset row into other variables:<Map Column="Email" Variable="ContactEmail" />(e.g. autofill from a chosen record).
Layout — <Layout> (optional)
Section titled “Layout — <Layout> (optional)”An Input step may arrange its fields with the same layout grammar as views —
<Section> / <Grid> / <Row> / <Column Span> / <Item Name Width> / <Line>. See
Form layout. Layout is pure presentation: it is stored in the XML and
consumed by the React runner; fields not placed in the layout fall through in declaration order.
Decision node
Section titled “Decision node”Branches on the current variables. A Decision has no children — it routes purely through its
outgoing <Link Expression="…"> edges (see Links).
<Node Id="creditCheck" Type="Decision" Label="Over 50k?" X="480" Y="30" />Action node
Section titled “Action node”A side effect that runs during the auto-advance burst. The ActionType attribute selects the flavour:
Alert — pause and show a message the user must acknowledge before the flow resumes:
<Node Id="notify" Type="Action" Label="Heads up" ActionType="Alert"> <Subject>Note</Subject> <Message>This request will need manager approval.</Message></Node>ExecuteSQL — run a SQL statement for its side effect:
<Node Id="touch" Type="Action" Label="Log" ActionType="ExecuteSQL"> <SQL>UPDATE Inventory.Requests SET TouchedAt = SYSDATETIMEOFFSET() WHERE Id = @RequestId;</SQL></Node>SetVariable — compute a variable from a static value or a SQL scalar. Use the SQL form with a
trailing SELECT <scalar> to capture something (e.g. a new row’s SCOPE_IDENTITY()) into a
variable for later steps:
<Node Id="register" Type="Action" Label="Create Draft" X="480" Y="30" ActionType="SetVariable"> <TargetVariable>ProductId</TargetVariable> <ValueSource>SQL</ValueSource> <SQL> INSERT INTO Inventory.Products (Sku, Name, CategoryId, UnitPrice, UnitCost, ReorderLevel, IsActive, Status, IsDeleted, CompanyId, CreatedById, CreatedAt) VALUES (@SkuFormat, @ProductName, @CategoryId, @UnitPrice, @UnitCost, @ReorderLevel, 0, 'Draft', 0, @SessionCompanyId, @SessionUserId, SYSDATETIMEOFFSET()); SELECT CAST(SCOPE_IDENTITY() AS BIGINT); </SQL></Node><ValueSource> is Static (use <StaticValue>) or SQL (use <SQL>, executed as a scalar).
Terminal node
Section titled “Terminal node”Finishes the wizard and, optionally, hands off to a workflow.
<Node Id="done" Type="Terminal" Label="Submitted" X="740" Y="38" Width="160" Height="60" SubmissionStatus="Pending" WorkflowName="ProductApproval" ResubmissionTransition="Resubmitted"> <SuccessMessage>Your product request has been submitted for approval.</SuccessMessage></Node>| Attribute / child | Meaning |
|---|---|
SubmissionStatus |
Status stamped on the saved response (default Submitted). |
WorkflowName |
Workflow definition to start on submit, bound to the response. Omit for no handoff. |
ResubmissionTransition |
Transition label fired when an edited response continues its existing workflow instance (default Resubmitted). |
<TerminalSql> |
SQL to run at submission time (before the workflow starts). |
<SuccessMessage> |
Message shown on the completion screen. |
Links — <Link>
Section titled “Links — <Link>”Directed edges between nodes. The runner follows them from the start node onward.
<Link From="start" To="details" /><Link From="details" To="register" /><Link From="register" To="done" />| Attribute | Meaning |
|---|---|
From, To |
Source and target node Id. |
Expression |
On a Decision node, the branch condition. First true link wins; Expression="else" (or an expression-less link) is the catch-all. |
FromSide, ToSide |
Which node port the edge attaches to (designer routing only). |
SQL substitution
Section titled “SQL substitution”SQL blocks (<OptionsSql>, <SuggestionsSql>, SetVariable/ExecuteSQL <SQL>, <SubmitSql>,
<TerminalSql>) are string-substituted before execution — this is not a parameterised command:
@FieldNameand@NodeId__FieldNameare replaced with the collected value; strings are auto-quoted, booleans become1/0, andNULLis emitted for empty values.- Session parameters are injected:
@SessionUserId,@SessionCompanyId,@SessionCartId. - Dataset/options queries also receive
@SearchTextfor optional filtering. - Any declared field or Startup variable the user didn’t supply is substituted as SQL
NULL, so referencing an optional blank field never raises “Must declare the scalar variable @X”. - T-SQL locals you
DECLAREinside the SQL body are not wizard variables and are left untouched.
Storage and indexes
Section titled “Storage and indexes”Each wizard’s submissions go into its own table, Wizard.Resp{Name}. Every response keeps its full
set of values as JSON, and any field you mark Indexed additionally gets a real column that can be
filtered, sorted and searched.
<Field Name="SKU" Type="String" Label="SKU" Indexed="true" />Marking a field Indexed does three things: it becomes an indexable column, it becomes searchable, and
it appears in the generated response grid. Fields you leave alone stay in the JSON
payload — still submitted, still readable, just not queryable. Text and Attachment fields cannot be
indexed at all, because their values are unbounded in length.
<Indexes>
Section titled “<Indexes>”Declare database indexes in an <Indexes> block at the <Wizard> root, using the same grammar as
entity indexes:
<Wizard Name="NewProductRequest" Label="New Product Request"> <Indexes> <!-- Enforce one product request per SKU --> <Index Name="Sku" Unique="true"> <FieldRef Name="SKU" /> </Index>
<!-- Composite index for a category-filtered, newest-first grid --> <Index Name="CategoryRecent"> <FieldRef Name="CategoryId" /> <FieldRef Name="CreatedAt" Sort="Desc" /> </Index>
<!-- Partial index: only the approved rows --> <Index Name="ApprovedSku" Filter="Status = 'Approved'"> <FieldRef Name="SKU" /> </Index> </Indexes>
<Node Id="details" Type="Input" ...>| Attribute | Meaning |
|---|---|
Name |
Author-facing name. The physical name is prefixed with the table (IX_Resp{Name}_{Name}), because PostgreSQL scopes index names to the schema rather than the table. |
Unique |
true to enforce uniqueness. |
Filter |
Optional predicate for a partial index. Must be a single boolean expression. |
<FieldRef Name Sort> |
One key column, in order. Sort="Desc" for descending. |
A <FieldRef> may name a wizard field or a built-in response column — Id, Status,
ReferenceId, CreatedAt, CreatedById, UpdatedAt, CompanyId, IsDeleted, UpdatedTo,
VersionHash, SchemaHash. Referencing a built-in is what makes a company-scoped, recency-ordered
index possible. A field referenced by an index is treated as Indexed whether or not you set the
attribute — you cannot index a column that was never created.
Indexes are declared at the root because a wizard can have several Terminal nodes but has exactly one response table; per-Terminal declarations could disagree with no principled way to merge them. The designer surfaces the editor in the Terminal pane, under Storage & Indexes, since that is where the whole flow’s fields are already in view — but every Terminal edits the same root-level list.
Genie also creates a few indexes on every response table regardless: latest-in-lineage lookups, status filtering, and company-scoped recency ordering.
The response grid
Section titled “The response grid”Once provisioned, a wizard’s submissions are browsable at /table/Resp{Name} — a normal Genie table
view, generated for you, with the full RBAC and row-security stack. It shows the indexed fields plus the
response metadata. The object is registered as a permission resource automatically, but no role is
granted it: grant Resp{Name} to the roles that should see the submissions.
See Runtime & designer for how the table is provisioned, and how
SchemaHash keeps a cosmetic edit from triggering DDL.