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
| Reference | Resolves 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)
| Status | Meaning |
|---|---|
pending | Queued — upstream nodes have not yet completed |
running | Actively executing — LLM call, tool call, or waiting on external I/O |
waiting | Paused durably — a wait node sleeping, or user-input awaiting the contractor |
completed | Finished successfully; output available to all downstream nodes |
failed | Error occurred; error policy determines next step (retry, pause, skip, or abort) |
skipped | Node 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 engine | Claude (LLM) reads context and picks the best matching route based on a classification prompt | Deterministic rule evaluation — field / operator / value triples |
| Number of routes | Two or more named routes defined in the routes array | Always exactly two: condition-true and condition-false |
| Config keys | classificationPrompt + routes[] | rules[] (field / operator / value) + combinator: and|or |
| Deterministic? | No — same input may produce a different route in edge cases | Yes — identical inputs always produce the same output |
| Use when | Nuanced judgment: sentiment, intent, content appropriateness, best-match classification | Hard rules: invoice.amount_due > 0, contact.tag == "vip", reply == null |
| Cost | One LLM call per execution | Free — 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.
| Trigger | Template Name | Description | Nodes |
|---|---|---|---|
project.completed | Post-Completion Follow-Up | wait 2h → agent drafts SMS → tool-call sends (48h reply window) → router classifies sentiment → sub-workflow (review funnel) on positive, create_task on negative / no reply | wait, agent, tool-call, router, sub-workflow |
call.missed | Missed Call Follow-Up | parallel: ack SMS + create callback task → merge → wait 4h → condition (contact replied?) → if no: second SMS + desktop-notification to contractor | parallel, tool-call ×2, merge, wait, condition, desktop-notification |
new_lead | New Lead Welcome Sequence | agent writes personalized intro SMS → send → wait 48h → condition (reply?) → if no: agent writes follow-up email → send. Loop continues 2 weeks with decreasing frequency | agent, tool-call, wait, condition, loop |
estimate.sent | Estimate 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.booked | Appointment 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 not | wait ×2, parallel, merge, condition, desktop-notification |
invoice.overdue | Invoice Overdue Reminder | 3 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-In | function 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 sequence | function, loop, agent, tool-call, router, sub-workflow |
review.video_submitted | Video Production Pipeline | Collects 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 captions | agent ×3, sub-workflow, parallel, merge |
estimate.approved | Onboarding Flow Kickoff | Send 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 previous | tool-call ×4, wait ×3, condition ×3 |
schedule (weekly) | Birthday & Anniversary Check-Ins | function 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 module | function, 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/.
| Capability | How It Works |
|---|---|
| Enable / Disable | Toggle between draft and production. In-progress runs complete before the workflow stops accepting new triggers. |
| Version History | Every save creates an immutable snapshot. Diff any two versions. Rollback creates a new version — history is never overwritten. |
| Run History | Every execution logs: run ID, trigger payload, each node's input + output, timestamps, duration, and final status. Full audit trail, drillable per run. |
| Checkpoint Recovery | If execution crashes mid-run, the engine resumes from the last completed node checkpoint. Long-running workflows survive restarts and deploys. |
| Live Debug Mode | Trigger 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. |
| Clone | Any 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 Authoring | Describe 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
| Status | Workflow · Contact | Current Node | Duration |
|---|---|---|---|
| completed | Post-Completion Follow-Up · Sarah M. | Start Review Funnel | 2h 4m |
| completed | Missed Call Follow-Up · Dave T. | Create Follow-Up Task | 4h 1m |
| waiting | Estimate Follow-Up · Karen L. | wait · 3 days | — |
| running | Video Production Pipeline · Marcus R. | Higgsfield Render | 8m 12s |
| failed | Annual Check-In · Bill F. | Send Email (retry 3/3) | 12s |