Skip to content

Workflow Builder

The Workflow Builder is a step-based visual editor for automation rules. It renders the workflow as an auto-laid-out vertical sequence: there is no free-form canvas, no node dragging, and no manual connection drawing. Users add steps by clicking, and layout is derived.

The earlier free-form canvas (NodePalette / WorkflowCanvas / drag-to-connect, with pan/zoom and undo-redo stores) was removed in the 2026-07 redesign. The persisted shape (automation_rules.workflow_data = nodes + connections) is unchanged; positions are synthesized and ignored at render time.

Source files:

  • src/lib/components/automation/workflow/ - UI components
  • src/lib/automation/flow-model.ts - render model + graph rewrite planners
  • src/lib/stores/automation/workflow.svelte.ts, panels.svelte.ts - state

Component Architecture

┌────────────────────────────────────────────────────────────────────┐
│ WorkflowBuilder (root: orchestrates save/test, delete confirms)    │
├────────────────────────────────────────────────────────────────────┤
│ WorkflowTopBar (back, inline name edit, save state, enable toggle, │
│                 Test / Create / Save)                              │
├──────────────┬──────────────────────────────┬──────────────────────┤
│ NodeLibrary  │ FlowCanvas                   │ NodeConfigPanel      │
│ (click a     │ (auto-laid-out vertical flow │ (config form for the │
│  step type   │  with zoom + condition       │  selected node)      │
│  to append)  │  branch forks)               │                      │
│              │  └─ FlowNodeCard per step    │  └─ config/* forms   │
│              │  └─ AddStepMenu (tail +      │                      │
│              │     insert points)           │                      │
└──────────────┴──────────────────────────────┴──────────────────────┘
ComponentRole
WorkflowBuilder.svelteRoot; wires the page (/automations/new and /automations/[id]), save/test, dialogs
WorkflowTopBar.svelteName field ("Untitled automation"), save-state line, enable toggle, Test/Save
NodeLibrary.svelteLeft panel "Add a step": Triggers / Actions / Logic categories; click to append
FlowCanvas.svelteAuto-layout vertical flow; local zoom state (transform scale, 40-200%)
FlowNodeCard.svelteStep card: icon, title, When/Then/If badge, summary, ⋮ menu
AddStepMenu.svelteDropdown of addable step types (dashed tail buttons + + insert points)
NodeConfigPanel.svelteRight panel config editor for the selected node
step-definitions.tsStep catalogue (Event trigger, Send email, Webhook, Update field, Condition, Delay) + describeNode()

Config forms live in workflow/config/: TriggerConfig, ActionEmailConfig, ActionWebhookConfig, ActionFieldUpdateConfig, ConditionConfig, DelayConfig, plus MagicTagChips (clickable magic-tag chips).

Render model: flow-model.ts

buildFlowModel(nodes, connections) derives the render model with the same BFS-from-trigger walk the API uses when deriving automation_actions.execution_order. It also plans graph rewrites for edits: append, insert-on-connection, move up/down, duplicate, remove-with-splice, and attach-orphan.

  • Condition branches: condition nodes fork into True/False branch columns (recursive; connections carry sourcePort: 'true' | 'false'). Deleting a condition cascades to its branch steps behind a confirm dialog.
  • Unconnected steps: nodes unreachable from the trigger (legacy free-form graphs) render in an "Unconnected steps" section with attach/delete actions.

State Management

Two stores only (module-level Svelte 5 runes, not classes):

StoreFilePurpose
workflowStoreworkflow.svelte.tsNodes/connections Maps, selection, dirty state
builderPanelsStorepanels.svelte.tsSide-panel collapse state (persisted)
typescript
// workflow.svelte.ts - exported mutators operate on module $state
export function appendStep(type, options, anchor) { ... }
export function insertStepOnConnection(connectionId, type, options) { ... }
export function moveStep(nodeId, direction) { ... }
export function removeStep(nodeId) { ... }   // splices the chain
export function selectNode(nodeId) { ... }

Save state uses workflowStore.isDirty + markSaved(); the top bar renders "Unsaved changes" / "All changes saved".

Map reactivity: nodes and connections are SvelteMap instances (svelte/reactivity), so in-place .set() / .delete() / .clear() notify subscribers directly. The old clone-and-reassign pattern (nodes = new Map(nodes)…) is gone; nothing may rely on the map identity changing between operations.

Both side panels collapse to slim rails. Below the lg breakpoint they start collapsed and overlay the canvas when opened; selecting a node auto-expands the config panel.

Config data keys (contract with the API)

Config forms write fields at the top level of node.data; the API reads these exact keys when deriving automation_actions rows on save (src/api/routes/automations/rules.ts):

NodeKeys
send_emailtemplate_id, to_override, cc_emails
send_webhookwebhook_url, http_method, headers, payload_template
update_fieldentity, field, value (legacy target_table/target_field/new_value read as fallbacks)
delaydelayValue, delayUnit (plus delay_value/delay_unit seeds)
conditioncondition_field, condition_operator, condition_value

update_field is allowlisted

The builder offers only the curated catalog in workflow/update-field-catalog.ts (episode title/description, booking status = Completed only, guest private notes), each with a value-input kind and a magic-tag policy. The security boundary is the executor's ALLOWED_FIELDS allowlist in workers/automation-executor/src/executors/field-update.ts; keep catalog and allowlist in lockstep. The old { table, field, value, record_id_source } shape documented before the redesign never ships: UpdateFieldActionConfig is { entity: 'episode'|'booking'|'guest', field, value } (src/lib/types/automation.types.ts).

Conditions

Condition fields come from workflow/condition-fields.ts (each entry pins operators and the value input). Operators are the canonical evaluator vocabulary (equals, not_equals, contains, not_contains, is_empty, not_empty, greater_than, less_than); the evaluator also accepts the legacy is_not_empty spelling and the form normalizes it. Every field maps to a key both execution contexts populate (worker buildMagicTagContext + dev-mode buildSyncConditionContext in the rules API): adding a field means updating all three. Shared modules src/lib/automation/condition-eval.ts and branch-gate.ts are imported by the executor via relative path and must stay free of $lib aliases.

Magic tags in configs

The email config offers copyable email-address tags only (guest_email, guests_emails); the webhook config reuses MagicTagInserter to insert any tag at the payload cursor. public fields exclude private links and contact PII via the tagFilter prop.

Save and test

  • Create/Save: the API persists workflow_data, derives automation_actions rows (BFS order), and stamps branch rows with parent_node_id/branch_type.
  • Test: TestAutomationDialog queues a test execution against a chosen episode/booking ("Test execution queued"). canTest = !isNew; the disabled button explains itself ("Save the automation first to test it" / "Save changes before testing").

Accessibility

Every interactive element is a <button> (step cards, library items, add-step triggers, insert points, zoom controls). Cards expose aria-pressed; Escape deselects. Menus and dialogs are shadcn DropdownMenu / AlertDialog. Decorative connectors and forks carry aria-hidden="true".

Internal documentation - Not for public distribution