Skip to main content

Workflows

The CRM workflow engine is a direct port of the Sulla Desktop routines system. Workflows are YAML files stored in a versioned library and executed by a stateful playbook engine that checkpoints every node's input and output to the database. Every automatic behavior in the CRM — missed call follow-up, onboarding gating, the review funnel, the video pipeline — is a workflow the contractor can inspect, clone, pause, and extend.

The visual canvas is React Flow-based. Contractors build and edit workflows through a drag-and-drop UI or by editing YAML directly. The AI agent can also create and modify workflows from plain language instructions.

Same engine, same schema, same template syntax. Routines written for the CRM follow the exact same YAML format and execution model as Sulla Desktop. Every node you learn here is the same node you will find in any Sulla system. Routines can be shared between platforms.

How a Workflow Works

A workflow is a YAML file with three top-level sections: nodes (the work to execute), edges (connections between nodes), and a header containing the workflow ID, name, and status. Every node carries type: "workflow" — behavior is determined entirely by data.subtype. One node must be a trigger; it starts execution when its event fires.

The engine passes the full workflow context to every node. Each node adds its output under its label, and downstream nodes pull values using {{Node Label}} template syntax. State is persisted to the database so runs survive process restarts — a workflow that waits 3 days actually waits, undisturbed, through any number of deploys or crashes.

YAML Structure

id:      my-workflow-id         # unique slug, kebab-case
name: My Workflow Name
_status: draft # draft | production | archive

nodes:
- id: trigger # node IDs are kebab-case
type: workflow # always "workflow" for every node
data:
subtype: chat-app # determines the node behavior
label: Project Completed # human name used in {{templates}}
config:
event: project.completed

- id: draft-message
type: workflow
data:
subtype: agent
label: Draft Follow-Up
config:
additionalPrompt: "Write a warm SMS for {{trigger.contact.first_name}}."

edges:
- source: trigger
target: draft-message

Template Syntax

ReferenceResolves to
{{trigger}}Full trigger payload — CRM event data, webhook body, or schedule context
{{trigger.contact.first_name}}Nested field access into the trigger payload using dot notation
{{Node Label}}Full output of the node with that label (case-insensitive, spaces OK)
{{node-id}}Same as label reference, using the node's kebab-case id
{{loop.current}}The current item during a loop node's iteration
{{loop.index}}Current 0-based iteration index inside a loop node

Node Status Lifecycle (per run)

StatusMeaning
pendingQueued — upstream nodes have not yet completed
runningActively executing — LLM call, tool call, or waiting on external I/O
waitingPaused durably — a wait node sleeping, or user-input awaiting the contractor
completedFinished successfully; output available to all downstream nodes
failedError occurred; error policy determines next step (retry, pause, skip, or abort)
skippedNode was not on the execution path (e.g., router chose a different branch)

Example: Post-Completion Follow-Up

Trigger fires when a job is marked complete. Wait 2 hours. A Claude agent drafts a personalized SMS. A tool-call sends it and waits up to 48 hours for a reply. A router node classifies the sentiment — positive customers get routed into the Review Funnel sub-workflow; negative or silent contacts get an urgent task created for the contractor.

id:      post-completion-follow-up
name: Post-Completion Follow-Up
_status: production

nodes:
- id: trigger
type: workflow
data:
subtype: chat-app
label: Project Completed
config:
event: project.completed

- id: wait-2h
type: workflow
data:
subtype: wait
label: Wait 2 Hours
config:
delayAmount: 2
delayUnit: hours

- id: draft-sms
type: workflow
data:
subtype: agent
label: Draft SMS
config:
additionalPrompt: "Write a warm 1-sentence SMS thanking
{{trigger.contact.first_name}} for the {{trigger.project.service_type}} job.
Ask how everything went. Max 160 chars."

- id: send-sms
type: workflow
data:
subtype: tool-call
label: Send SMS
config:
toolName: crm/send_sms
defaults:
to: {{trigger.contact.phone}}
body: {{Draft SMS}}
wait_for_reply: true
reply_timeout_hours: 48

- id: sentiment-router
type: workflow
data:
subtype: router
label: Sentiment Router
config:
classificationPrompt: "Read the customer reply: '{{Send SMS.reply}}'.
Classify sentiment as: positive, negative, or no_reply."
routes:
- id: route-0
label: positive
- id: route-1
label: negative
- id: route-2
label: no_reply

- id: start-review-funnel
type: workflow
data:
subtype: sub-workflow
label: Start Review Funnel
config:
workflowId: review-testimonial-funnel
awaitResponse: false # fire-and-forget; don't block this run

- id: create-task
type: workflow
data:
subtype: tool-call
label: Create Follow-Up Task
config:
toolName: crm/create_task
defaults:
title: "Follow up — {{trigger.contact.first_name}} (needs attention)"
priority: urgent
project_id: {{trigger.project.id}}

edges:
- { source: trigger, target: wait-2h }
- { source: wait-2h, target: draft-sms }
- { source: draft-sms, target: send-sms }
- { source: send-sms, target: sentiment-router }
- { source: sentiment-router, target: start-review-funnel, sourceHandle: route-0 }
- { source: sentiment-router, target: create-task, sourceHandle: route-1 }
- { source: sentiment-router, target: create-task, sourceHandle: route-2 }

Canvas flow:

[Trigger: chat-app] Project Completed

[Flow Control: wait] Wait 2 Hours — delayAmount: 2, delayUnit: hours — durable state persisted to DB

[Agent: agent] Draft SMS — Claude receives full trigger context, returns ≤160-char message

[Agent: tool-call] Send SMS — crm/send_sms, body: {{Draft SMS}}, wait_for_reply: true, reply_timeout_hours: 48

[Routing: router] Sentiment Router — LLM classifies {{Send SMS.reply}} → positive / negative / no_reply
├── positive → [Flow Control: sub-workflow] Start Review Funnel — workflowId: review-testimonial-funnel
└── negative / no_reply → [Agent: tool-call] Create Follow-Up Task — crm/create_task, priority: urgent

Trigger Types

Every workflow starts with exactly one trigger node. Multiple workflows can listen to the same event and run concurrently. The trigger payload becomes {{trigger}} for every downstream node.

schedule — cron-based timer: daily at time · weekly on day · monthly on date · hourly · every-N-minutes · raw cron expression

chat-app — CRM events & inbound webhooks: project.created · project.completed · invoice.sent · invoice.paid · invoice.overdue · estimate.sent · estimate.approved · estimate.declined · new_lead · contact.created · review.submitted · call.received · call.missed · change_order.signed · appointment.booked · contract.signed · inbound webhook

manual — on-demand, contractor-initiated: from contact record · from project record · from dashboard · voice command via AI agent

Node Types

All nodes share the same base schema: id, type: "workflow", data.subtype, data.label, data.config. The subtype determines behavior. Labels are stable runtime identifiers — downstream nodes reference them as {{Label}}.

Trigger Nodes

schedule (subtype: schedule) Cron-based timer. Supports daily, weekly, monthly, hourly, every-N-minutes, or a raw cron expression. Trigger payload includes the scheduled run time.

chat-app (subtype: chat-app) Fires on CRM events (project.completed, invoice.paid, call.missed, etc.) or inbound webhooks. The full record — contact, project, invoice — becomes the trigger payload.

manual (subtype: manual) Contractor launches on-demand from the dashboard, a contact or project record, or via voice command to the AI agent. Accepts optional input at launch time.

Agent Nodes

agent (subtype: agent) Spawns Claude with the full workflow context plus an additionalPrompt. The AI can reason, generate content, call tools, and return structured output. Most powerful node in the system.

tool-call (subtype: tool-call) Calls a CRM tool directly — crm/send_sms, crm/send_email, crm/create_task, crm/update_record, etc. No LLM. Deterministic, fast, and cheaper than agent.

function (subtype: function) Runs a custom reusable function by functionRef with typed inputs. Functions are shared across workflows. Supports timeoutOverride. Use for: data transforms, DB queries, calculations.

desktop-notification (subtype: desktop-notification) Sends a push alert to the contractor's phone or an in-app notification. Used for escalations that require human judgment before the workflow continues.

Routing Nodes

router (subtype: router) LLM-based multi-class classification. Provide a classificationPrompt and a routes array — Claude picks the best-matching route. For nuanced judgment: sentiment, intent, content fit.

condition (subtype: condition) Boolean rule branching. Define rules (field / operator / value) with combinator: and|or. Always routes to condition-true or condition-false. No AI — fully deterministic.

Flow Control Nodes

wait (subtype: wait) Pauses execution for a fixed duration. Config: delayAmount (number) + delayUnit (seconds / minutes / hours / days). State persisted to DB — fully durable across restarts and deploys.

loop (subtype: loop) Iterates in three modes: for-each over a list, fixed iterations count, or ask-orchestrator (Claude decides when to stop). Exposes {{loop.current}} and {{loop.index}} inside the loop body.

parallel (subtype: parallel) Splits execution into concurrent branches (branch-0, branch-1, ...) that run simultaneously. Use when steps are independent — e.g., send SMS and email at the same time.

merge (subtype: merge) Collects parallel branch outputs. Strategy: wait-all (hold until every branch completes) or first (continue when any branch finishes). Always paired with a preceding parallel node.

sub-workflow (subtype: sub-workflow) Launches a child workflow by workflowId. awaitResponse: true blocks until the child completes; false is fire-and-forget. Enables large automations built from smaller, testable routines.

IO Nodes

response (subtype: response) Sends a formatted reply back to the originating user or calling system. Config: responseTemplate string with {{node}} references interpolated at runtime.

user-input (subtype: user-input) Pauses execution and surfaces a promptText question to the contractor in the dashboard. The workflow resumes when they respond. Their answer is available to all downstream nodes.

Router vs. Condition

Router (subtype: router)Condition (subtype: condition)
Decision engineClaude (LLM) reads context and picks the best matching route based on a classification promptDeterministic rule evaluation — field / operator / value triples
Number of routesTwo or more named routes defined in the routes arrayAlways exactly two: condition-true and condition-false
Config keysclassificationPrompt + routes[]rules[] (field / operator / value) + combinator: and|or
Deterministic?No — same input may produce a different route in edge casesYes — identical inputs always produce the same output
Use whenNuanced judgment: sentiment, intent, content appropriateness, best-match classificationHard rules: invoice.amount_due > 0, contact.tag == "vip", reply == null
CostOne LLM call per executionFree — no LLM involved

Common Patterns

Parallel multi-channel send

Fan out to SMS and email simultaneously using a parallel node, then re-join the flow with a merge node. Both messages arrive within seconds of each other instead of sending sequentially.

- id:   fan-out
type: workflow
data:
subtype: parallel
label: Send Both Channels
# edges fan out:
# fan-out --branch-0--> send-sms
# fan-out --branch-1--> send-email
# then both converge:
# send-sms --> re-join
# send-email --> re-join

- id: re-join
type: workflow
data:
subtype: merge
label: Re-join
config:
strategy: wait-all # continue only after both branches finish

Loop over a list of contacts

A schedule trigger runs weekly. A function node queries contacts with birthdays this week. A loop node iterates that list. Inside each iteration an agent node personalizes the message using {{loop.current.first_name}} and a tool-call sends it.

- id:   get-contacts
type: workflow
data:
subtype: function
label: Get Birthday Contacts
config:
functionRef: crm/query_birthday_contacts

- id: birthday-loop
type: workflow
data:
subtype: loop
label: Birthday Loop
config:
loopMode: for-each
items: {{Get Birthday Contacts.contacts}}
maxIterations: 500
# inside the loop body:
# agent node uses {{loop.current.first_name}}, {{loop.current.phone}}
# tool-call sends crm/send_sms for each contact

Sub-workflow composition

The Video Production Pipeline is five separate workflows chained with sub-workflow nodes. Each child can be tested, paused, or swapped independently. The parent only cares that each child completes — not how it works internally.

- id:   render-video
type: workflow
data:
subtype: sub-workflow
label: Higgsfield Render
config:
workflowId: higgsfield-render-job
awaitResponse: true # block until render is done

- id: distribute
type: workflow
data:
subtype: sub-workflow
label: Distribute Video
config:
workflowId: video-distribution
awaitResponse: false # fire-and-forget

Pre-Built Routine Templates

These ship with every account, ready to enable immediately. Each is a complete YAML workflow — trigger, nodes, edges, and error policies included. Clone and customize, or enable as-is.

TriggerTemplate NameDescriptionNodes
project.completedPost-Completion Follow-Upwait 2h → agent drafts SMS → tool-call sends (48h reply window) → router classifies sentiment → sub-workflow (review funnel) on positive, create_task on negative / no replywait, agent, tool-call, router, sub-workflow
call.missedMissed Call Follow-Upparallel: ack SMS + create callback task → merge → wait 4h → condition (contact replied?) → if no: second SMS + desktop-notification to contractorparallel, tool-call ×2, merge, wait, condition, desktop-notification
new_leadNew Lead Welcome Sequenceagent writes personalized intro SMS → send → wait 48h → condition (reply?) → if no: agent writes follow-up email → send. Loop continues 2 weeks with decreasing frequencyagent, tool-call, wait, condition, loop
estimate.sentEstimate Follow-Up (3-Touch)wait 24h → reminder SMS → wait 3 days → agent writes follow-up email → wait 7 days → agent writes final email. Each step gated by condition (still pending?)wait ×3, tool-call, agent ×2, condition ×3
appointment.bookedAppointment Reminder (24h + Day-Of)wait until 24h before → parallel sends confirmation SMS + email → merge → wait until day-of → send arrival window + crew details → condition (confirmed?) → notify contractor if notwait ×2, parallel, merge, condition, desktop-notification
invoice.overdueInvoice Overdue Reminder3 days → polite reminder → 7 days → firmer follow-up → 14 days → notify contractor → 21 days → escalation task. Each step gated: condition (still unpaid?)wait ×4, condition ×4, tool-call, desktop-notification
schedule (annual, 1yr after project.completed_at)Annual Check-Infunction queries contacts whose anniversary falls this week → loop iterates each → agent writes personalized check-in referencing the original project → tool-call sends email → router routes replies to booking sub-workflow or nurture sequencefunction, loop, agent, tool-call, router, sub-workflow
review.video_submittedVideo Production PipelineCollects project photos + testimonial video → agent (Gemini) builds storyboard → agent (Claude) writes narrative → sub-workflow (Higgsfield render) → parallel distributes to portfolio + social + YouTube with per-platform captionsagent ×3, sub-workflow, parallel, merge
estimate.approvedOnboarding Flow KickoffSend invoice email → wait (reviewed) → send card-on-file link → condition (card authorized?) → send contract → wait (signed) → condition (signed?) → mark onboarding complete. Every step gated on the previoustool-call ×4, wait ×3, condition ×3
schedule (weekly)Birthday & Anniversary Check-Insfunction queries contacts with birthdays or anniversaries this week → loop iterates each → agent personalizes message using {{loop.current}} → tool-call sends SMS. Dates sourced from Relationship Intelligence modulefunction, loop, agent, tool-call

Workflow Lifecycle & Management

Workflows live at one of three statuses: draft (being built, not executing), production (live, accepting triggers), or archive (disabled, all data preserved). Status is stored in the database and reflected in the YAML header as _status. Source files live under ~/sulla/workflows/.

CapabilityHow It Works
Enable / DisableToggle between draft and production. In-progress runs complete before the workflow stops accepting new triggers.
Version HistoryEvery save creates an immutable snapshot. Diff any two versions. Rollback creates a new version — history is never overwritten.
Run HistoryEvery execution logs: run ID, trigger payload, each node's input + output, timestamps, duration, and final status. Full audit trail, drillable per run.
Checkpoint RecoveryIf execution crashes mid-run, the engine resumes from the last completed node checkpoint. Long-running workflows survive restarts and deploys.
Live Debug ModeTrigger manually against a specific contact or project. Nodes highlight as they execute. All external actions (SMS, email) are intercepted into previews — nothing is actually sent.
CloneAny workflow can be duplicated. The clone starts as draft with a new name and ID. The original is untouched and remains in production.
Error Policy (per node)Each node declares one of: retry with exponential backoff, notify + pause (requires human resume), skip + continue, or abort run. Errors are always logged regardless of policy.
AI AuthoringDescribe a workflow in plain language to the AI agent. It generates valid YAML, registers it as draft, and opens it in the canvas for review before you enable it.

Example Run History

StatusWorkflow · ContactCurrent NodeDuration
completedPost-Completion Follow-Up · Sarah M.Start Review Funnel2h 4m
completedMissed Call Follow-Up · Dave T.Create Follow-Up Task4h 1m
waitingEstimate Follow-Up · Karen L.wait · 3 days
runningVideo Production Pipeline · Marcus R.Higgsfield Render8m 12s
failedAnnual Check-In · Bill F.Send Email (retry 3/3)12s