Skip to main content

Workflow Builder

The Workflow Builder lets you automate repetitive work — assignments, follow-ups, notifications, list sync, integrations — without code. Each workflow follows the same shape:

TRIGGER (When…)  →  FILTERS (If…)  →  ACTIONS (Then…)

A scheduler also fires cron-based Scheduled workflows for dormancy and clean-up jobs that aren't tied to a single record event.

Navigate to Admin → Workflows.

Workflow builder overview

1. How a Workflow Runs

  1. An entity service (leads, opportunities, etc.) fires a trigger event with the current entity, previous values, and any meta context.
  2. The runner loads every active workflow matching triggerModule + triggerType and evaluates its trigger filters (the "If" condition group).
  3. For each workflow that passes, a workflow_runs row is inserted and actions execute in order.
  4. Each action records a workflow_run_steps row. If a step fails, the runner continues to the next action and the run is marked completed_with_errors instead of aborting the whole chain.
  5. The runs page shows every step, its config snapshot, the payload going in, and the filter evaluation tree — so you can see exactly which condition matched and which value was used.
Run statuses
  • running — actions are still firing.
  • completed — every step succeeded.
  • completed_with_errors — one or more steps failed; downstream steps still ran.
  • failed — the run never started (rare; usually a missing-table or schema-load problem).

2. Creating a Workflow

  1. Click Create Workflow.
  2. Enter a name — e.g., "Auto-assign Enterprise Leads".
  3. Add an optional description to help teammates understand intent.
  4. The visual builder opens with three sections: Trigger, Filters, Actions.

Workflow visual builder

3. Triggers

A workflow has exactly one trigger. Trigger events come from two places:

  • Entity events — fired by the CRM the moment something happens (create, update, stage change, etc.).
  • Scheduled — a cron sweep that scans the entity table on a schedule.

3.1 Entity Event Triggers

ModuleEventFires WhenIncludes previousValues
leadslead_createdA lead is created via UI, API, form, or import (when import opt-in is on).
lead_updatedAny lead field changes.yes
lead_assignedowner_id changes.yes
lead_stage_changedstage_id changes (via change-stage endpoint or update).yes
lead_score_changedThe rule-based scorer recomputes and the value changes.yes
lead_qualification_score_changedThe qualification framework score recomputes and the value changes. Independent from lead_score_changed.yes
lead_convertedThe lead is converted into a contact/account/opportunity.
contactscontact_created / contact_updated / contact_assignedCreate / update / owner change.update + assign
accountsaccount_created / account_updated / account_assignedCreate / update / owner change.update + assign
opportunitiesopportunity_createdOpportunity created.
opportunity_updatedAny field changes.yes
opportunity_assignedOwner changes.yes
opportunity_stage_changedStage changes (also fires on Won / Lost).yes
opportunity_wonStage moves to a Won terminal stage.yes
opportunity_lostStage moves to a Lost terminal stage.yes
taskstask_created / task_updated / task_assigned / task_completedStandard lifecycle events.update / assign / completed
task_expiredThe hourly task-expiry cron flips a task to expired because its due date passed.
projectsproject_created / project_updated / project_assigned / project_status_changedStandard lifecycle events.update / assign / status
subscriptionssubscription_createdA Customer 360 subscription is created.

The runner passes a uniform payload to every action:

{
triggerModule, // 'leads', 'opportunities', etc.
triggerType, // 'lead_stage_changed', ...
entityId, // UUID
entity, // raw DB row (snake_case)
customFields, // entity.custom_fields JSONB
previousValues, // pre-update values for changed-_ operators
meta, // { scheduled?, workflowId?, retriedFromRunId?, ... }
}

previousValues is normalised in both key formsstageId and stage_id both resolve — so filters work regardless of how you typed the field name.

3.2 Scheduled Trigger (Cron)

Use Scheduled when you need to run a workflow on a clock rather than in response to a record change — re-engagement, dormancy detection, stale-opportunity cleanup, daily digests.

Configure:

FieldExampleNotes
Cron expression0 9 * * *Standard 5-field cron. Use Validate to preview the next 5 fire times.
TimezoneAsia/KarachiDefaults to UTC. Cron is evaluated in this timezone.
Entity scan — ModuleleadsThe table the sweeper scans.
Entity scan — FilterCondition groupSame builder as trigger filters; compiled to a SQL WHERE clause.
Entity scan — Limit1000Safety cap per run, hard-capped at 5000.

A background sweeper runs once per minute, finds every active scheduled workflow whose schedule_next_run_at has passed, runs the entity scan, and fires the workflow once per matched row.

After each scan the workflow records schedule_last_run_at, schedule_last_match_count, and schedule_next_run_at. If the scan errored, schedule_last_error captures the message.

Preview matches (dry run)

Open the workflow → Schedule → Preview matches. The endpoint runs the scan WHERE clause and returns the row count plus a sample (default 20 rows) so you can verify the filter before activating.

Run now

Schedule → Run now fires the workflow immediately for testing without waiting for the next cron tick. Returns the matched/fired counts.

Scheduled-only filter operators

The scheduled scan compiles filters to SQL, so on top of the standard operators it adds:

OperatorSQLUse case
older_than_dayscolumn < NOW() - INTERVAL 'N days'"Last activity older than 20 days."
within_last_dayscolumn >= NOW() - INTERVAL 'N days'"Created in the last 7 days."
is_null / is_not_nullcolumn nullabilityPair with last_activity_at to find "never touched" rows.

Allowed scan columns

To keep the scheduler safe (no SQL injection via field names, no scanning by columns we don't want indexed) each module has an allowlist. If a filter references a column that isn't on the list it is silently dropped from the WHERE clause — preview the matches and you'll see the row count widen if a filter quietly disappeared.

ModuleCommon scannable columns
leadssource, industry, country, city, state, score, qualification_score, status, stage_id, pipeline_id, owner_id, team_id, priority_id, last_activity_at, next_activity_at, created_at, updated_at, sla_breached, sla_escalated, escalated_at
contactsaccount_id, owner_id, lifecycle_stage, mailing_country, mailing_city, created_at, updated_at
accountsindustry, account_type, lifecycle_stage, owner_id, employees, annual_revenue, status, billing_country, billing_city, created_at, updated_at
opportunitiesamount, currency, stage_id, pipeline_id, priority_id, close_date, probability, owner_id, type, source, team_id, account_id, contact_id, created_at, updated_at
tasksstatus_id, priority_id, task_type_id, due_date, assigned_to, owner_id, completed_at, related_entity_type, related_entity_id
projectsstatus_id, priority_id, start_date, end_date, budget, owner_id, health, account_id, opportunity_id, template_id

Custom fields are always scannable via JSONB extraction.

Starter templates

Three pre-built scheduled templates ship with the system. Click Templates on the workflows list to install one — it's installed paused so you can review and activate.

TemplateScheduleModuleFilterWhat it does
Dormant Lead Re-engagementDaily 09:00 UTCleadslastActivityAt older_than_days 20Notifies the owner + creates a re-engagement task due in 2 days.
Dormant Account DetectionMondays 08:00 UTCaccountslastActivityAt older_than_days 90Notifies the owner, emails the owner, and creates a check-in task due in 7 days.
Stale Opportunity DetectionDaily 10:00 UTCopportunitieslastActivityAt older_than_days 30Notifies the owner + creates a "make a decision" task due in 3 days.

4. Trigger Filters (the "If")

Filters control which records the workflow applies to. The runner evaluates the trigger filter group before creating a run; if it doesn't match, the workflow is silently skipped.

4.1 Field types

TypeResolves fromExample
systemThe entity column (camelCase or snake_case — both work).stageId, amount, country
customentity.custom_fields JSONB.cf_lead_score, cf_account_tier
metaThe trigger meta envelope.scheduled, workflowId, retriedFromRunId

4.2 Groups and match modes

Conditions live in a group. Each group has a match mode:

  • all — every item must be true (AND).
  • any — at least one item must be true (OR).

Groups can nest, so you can express:

(source = "Website" AND amount > 50000)
OR
(source = "Referral" AND priority = "High")

An empty group is treated as "always match" — useful as a no-op trigger filter on scheduled workflows, where the entity scan already filtered the rows.

4.3 The complete operator list

Every operator below is supported when the workflow runs on an event. Operators marked (scheduled too) are also compiled into SQL by the cron scheduler.

Text & equality

OperatorTrue whenNotes
equals (scheduled too)value(field) === valueCase-insensitive on event runs.
not_equals (scheduled too)value(field) !== valueCase-insensitive on event runs.
contains (scheduled too)value(field).includes(value)Substring; case-insensitive.
not_contains!value(field).includes(value)Event-time only.
starts_with (scheduled too)value(field).startsWith(value)Case-insensitive.
is_empty (scheduled too)Field is null or empty string.
is_not_empty (scheduled too)Field has any non-empty value.

Numeric

OperatorTrue when
greater_than (scheduled too)parseFloat(field) > value
less_than (scheduled too)parseFloat(field) < value
greater_or_equal (scheduled too)parseFloat(field) >= value
less_or_equal (scheduled too)parseFloat(field) <= value

Set membership

OperatorTrue whenNotes
in (scheduled too)Value array contains field.Use for "Source in [Website, Referral, Partner]".
not_in (scheduled too)Value array does not contain field.

Change detection (event-time only)

These compare the new value to previousValues and only make sense on _updated, _stage_changed, _assigned, _score_changed:

OperatorTrue when
changed_toField is now value and it wasn't before.
changed_fromField was value before and it isn't now.
any_changeField's new value differs from the previous one.

Date deltas (scheduled scans only)

OperatorSQL
older_than_dayscolumn < NOW() - INTERVAL 'N days'
within_last_dayscolumn >= NOW() - INTERVAL 'N days'

4.4 The filter evaluation breakdown

Every run stores a filter_evaluation tree so the runs page can show you exactly which condition matched and which value was seen. Each node records:

  • field, fieldType, operator, expected (the configured value)
  • resolved (the value pulled from the entity at run time)
  • previous (the value from previousValues, when relevant)
  • matched (true / false)

This is the fastest way to debug "why didn't my workflow fire?".

5. Actions (the "Then")

Actions execute top-to-bottom. Each action returns a result that's persisted, and some return a payloadMerge that updates the in-memory entity so later actions see the new state (e.g., assign_owner writes the new owner, and the next create_task with assignedTo: 'owner' picks it up).

You can chain branches and routers around any action for conditional flows.

5.1 assign_owner

Resolve a user using a routing algorithm and write owner_id to the entity.

ConfigValue
poolModeusers (default) — pick specific users · team — every member of teamIds[] · all_active — every active user in the tenant
pool[]List of user IDs (when poolMode = users).
teamIds[]List of team IDs (when poolMode = team). Legacy single teamId is also accepted.
algorithmround_robin · weighted · load_based · territory · skill_match · sticky
weights[]{ userId, weight } pairs for weighted.
skipIfOwnerSetIf true, do nothing when the record already has an owner. Prevents accidental reassignment on _updated triggers.
keepPreviousOwnerAsMemberDefault true. The previous owner is added as a "Previous Owner" read-only member of the record so they don't lose visibility.

Pool exclusion (leads): any user with exclude_from_lead_assignment = true (set on the user record) is dropped from the pool — even when you've chosen all_active or a team. This lets you keep a manager or finance user inside a team without ever having a lead routed to them.

Routing algorithms

AlgorithmHow it picksState
round_robinLooks up the last assigned user in workflow_assignment_log and picks the next in the pool. Survives restarts.workflow_assignment_log (per action)
weightedRandom selection weighted by configured weights[]; unweighted users count as 1.Stateless
load_basedThe user in the pool with the fewest open records in the trigger module's table. Users at 0 open records are seeded so the very first lead doesn't always go to the same person.Stateless
territoryMatches entity.country_code/country then city against each user's territory_tags[]. Falls back to the first user in the pool if nothing matches.Stateless
skill_matchMatches entity.industry against each user's skill_tags[]. Case-insensitive. Falls back to the first user in the pool.Stateless
stickyIf the entity has a linked account (or contact) whose owner is in the pool, that owner wins. Falls back to the first user in the pool.Stateless
tip

Round Robin is what most teams want. The log gives you an auditable trail of "who got which lead" you can use to defend fairness disputes.

5.2 create_task

Insert a follow-up task linked to the trigger entity. Also mirrored to Google Calendar if the assignee has a sync connection (fire-and-forget — a Google outage never fails the workflow).

ConfigResolves to
title (interpolated)Task title — falls back to "Follow up".
description (interpolated)Task body.
assignedToowner (default — re-reads from DB if not yet in payload) · trigger_user · specific (uses specificUserId) · stage_owner (most recent record_stage_assignments row, else stage's configured owner, else record owner)
taskTypeIdFalls back to the default task type.
statusIdFalls back to the first open active status by sort order.
priorityIdFalls back to the default priority.
dueOffsetDaysDays from now for the due date.
startOffsetDaysDays from now for the start date.
estimatedMinutesStored as-is.
tagsString (comma-separated) or array; stored as a Postgres TEXT[].

If the INSERT fails (usually a missing FK), the error column on the run step captures the resolved values that were attempted, not just the SQL message — so you can see immediately whether the bad value was statusId, priorityId, the assignee, etc.

5.3 update_field

Patch a single field on the trigger entity (or a related entity).

ConfigNotes
entityThe module to update; defaults to the trigger module.
fieldKeycamelCase OK — converted to snake_case for system fields.
fieldTypesystem (top-level column, allowlisted) or custom (JSONB merge into custom_fields).
valueStatic value or {{trigger.fieldName}} template. The action sniffs the field type so booleans, numbers, dates and arrays are written correctly.

Allowlist: system updates are restricted to safe columns per module. Anything off-list is rejected (the step records skipped: true with a reason) — this prevents a stray workflow from corrupting created_at, tenant_id, soft-delete flags, etc., or from being used as an SQL-injection vector via a crafted fieldKey.

Per-module writable system columns (high-level):

ModuleWritable columns
leadsfirst_name, last_name, email, phone, mobile, company, job_title, website, industry, city, state, postal_code, country, source, source_details, tags, priority_id, stage_id, pipeline_id, owner_id, score, qualification_score, qualification, notes, next_activity_at
contactsfirst_name, last_name, email, phone, mobile, job_title, department, account_id, owner_id, mailing address fields, tags, description, lifecycle_stage
accountsname, website, industry, phone, email, employees, annual_revenue, account_type, owner_id, billing address fields, tags, description, lifecycle_stage
opportunitiesname, amount, currency, stage_id, pipeline_id, priority_id, close_date, probability, owner_id, type, source, tags, description, next_step, lost_reason
taskstitle, description, task_type_id, status_id, priority_id, due_date, start_date, assigned_to, owner_id, estimated_minutes, tags, completed_at
projectsname, description, status_id, priority_id, start_date, end_date, budget, owner_id, health, tags

5.4 add_tag

Append a tag to the entity's tags[]. Idempotent — re-running won't duplicate.

ConfigNotes
tagThe tag string.

5.5 send_notification

Create in-app notifications for one or more users.

to modeResolves to
ownerThe record's owner_id.
trigger_userpayload.userId or the entity's created_by.
managerThe owner's manager_id from the users table.
specificconfig.specificUserId.
teamEvery active member of config.teamId.
multiple_usersEvery ID in config.userIds[].
roleEvery active user in config.roleId.
all_adminsEvery active user whose role has level >= 100.

title and message are interpolated. The action returns { sentTo, recipientIds } so you can see exactly who was reached.

5.6 send_email

Send an email through the tenant's configured ESP (system default, custom SMTP, SendGrid, or AWS SES). Set senderProvider on the action to override which sender account the runner picks (otherwise it uses the tenant default).

to modeResolves to
record_email (default)The entity's email column.
owner_emailThe record owner's email.
manager_emailThe owner's manager's email.
assigned_user_emailentity.assignedTo / assigned_to's email.
account_emailThe linked account's email.
contact_email / primary_contact_emailThe linked contact's email. For opportunities, the primary opportunity contact (by opportunity_contacts.is_primary); for accounts, the first contact by creation date.
trigger_user_emailThe user who initiated the trigger.
specificconfig.toEmail, interpolated — so you can target any field with {{trigger.fieldName}}.

Other config: subject, body (HTML), cc, bcc, replyTo — all interpolated. The email template picker can pre-fill subject + body from the Email Templates module.

5.7 send_whatsapp / send_sms

Send via the tenant's Twilio config.

to modeResolves to
record_phoneentity.mobile or entity.phone.
owner_phoneThe owner user's mobile/phone.
specificconfig.toPhone, interpolated.

message is interpolated. If no phone resolves the step is skipped, not failed.

5.8 webhook

Make an outbound HTTP call. Useful for posting to Slack, kicking off external pipelines, or talking to ERP systems with no native integration.

ConfigNotes
urlTarget URL.
methodGET, POST, PUT, DELETE, etc.
bodyTypejson (default) · form-data · raw · none.
bodyJsonWhen bodyType: json — a JSON string, interpolated. Defaults to the entire payload.
formData[]{ key, value, enabled? } rows when bodyType: form-data.
bodyRawRaw body string when bodyType: raw.
headers[]{ key, value, enabled? } rows. Content-Type is auto-set for json/form-data if not provided.
params[]{ key, value, enabled? } rows appended as query string; existing ? is respected.
verifySslfalse to skip TLS verification (only use for self-signed dev endpoints).
timeoutSecondsDefault 30. The request aborts via AbortSignal.timeout.

Returns { statusCode, ok, responseBody }responseBody is truncated to 500 chars so a huge response doesn't blow up the run record.

5.9 wait

Pause before the next action.

ConfigNotes
hours / minutesTotal wait. Currently synchronous and capped at 5 seconds — anything longer is silently skipped. Long-running waits will move to the Bull queue in a future release.

5.10 branch

Two-lane Yes / No split. Children of this action are stored with parent_action_id = branch.id and branch = 'yes' | 'no'.

ConfigNotes
conditionA condition group (same operators as trigger filters).

The runner picks the lane, then executes its actions in order. Branches can be nested.

5.11 router

A switch statement with N labelled paths plus an implicit default.

ConfigNotes
paths[]Ordered list of { id, name?, condition }. First match wins — top to bottom. A path with an empty condition group always matches, useful as a catch-all before the default.

If no path matches, the children stored under branch = 'default' run. The result records matchedPathId, matchedPathName, and totalPaths so the runs page can show which lane fired.

5.12 create_opportunity

Insert an opportunity linked to the trigger entity's account/owner.

ConfigNotes
nameFalls back to "{{firstName}} {{lastName}} Opportunity" for leads.

5.13 create_project

Insert a project, optionally seeded from a template.

ConfigNotes
name (interpolated)Falls back to "{{entity.name}} Project".
description (interpolated)Optional.
templateIdIf set, copies the template's phases and tasks (with subtasks) into the new project.

If the trigger module is opportunities, the entity's ID is written to projects.opportunity_id automatically — that's how the "create project from won opportunity" pattern works.

5.14 create_account

Insert an account and fire account_created so any downstream account workflows pick it up.

ConfigNotes
nameInterpolated; falls back to entity.company or entity.name. Skips if no name resolves.
accountTypeDefaults to prospect.
accountClassificationDefaults to business.
industry / website / email / phoneAll interpolated; default to the trigger entity's values.
ownerSourceowner (default, from entity) · trigger_user · specific (with specificOwnerId).

Exposes payloadMerge.account_id so a follow-up create_contact can link to the just-created account.

5.15 create_contact

Insert a contact and fire contact_created.

ConfigNotes
firstName / lastName / email / phone / mobile / jobTitleAll interpolated; default to the trigger entity. Skips if no name resolves.
accountSourcetrigger_entity (default) · just_created (uses the account_id merged in by a previous create_account) · specific (with specificAccountId).
contactRoleDefaults to "Primary Contact" — written into contact_accounts.role.
ownerSourceSame options as create_account.

5.16 convert_lead

Run the standard lead conversion flow (the same one the Convert button on a lead uses). Only fires on leads triggers.

ConfigMaps to convert DTO
contactActioncreate_new (default) or link_existing (uses existingContactId).
accountActioncreate_new, link_existing (uses existingAccountId), or skip.
accountName (interpolated)When creating a new account.
createOpportunityDefault true.
opportunityName (interpolated)
opportunityAmountParsed as a number.
pipelineId / opportunityStageIdTarget pipeline + stage for the new opportunity.
newOwnerId / teamIdOverride the inherited owner.

If the lead is already converted, the action records { skipped, reason } rather than failing.

5.17 add_to_email_list / remove_from_email_list

Sync the entity's contacts to a marketing list at MailerLite, Mailchimp, or any configured ESP.

ConfigNotes
providermailerlite, mailchimp, etc. The chooser also tags lists with their provider so admins can pick the right list when multiple providers are connected.
listId / listNameIdentifier on the provider.
contactSelectorprimary · all · owner — which contacts on the entity get synced.

Returns the number added/removed plus the total resolved. Per-contact failures are logged and don't fail the step.

6. Variable Interpolation

Anywhere a config field is marked interpolated, you can embed {{trigger.fieldName}} placeholders and they'll be replaced with values from the entity at run time.

Subject: New deal: {{trigger.name}} — {{trigger.amount}}
Body: Hi {{trigger.primaryContactName}}, your account manager is {{trigger.ownerName}}.

Resolution order:

  1. entity[snake_case form of fieldName]
  2. entity[fieldName]
  3. customFields[fieldName]
  4. Empty string if none of the above resolve.

Both {{trigger.firstName}} and {{trigger.first_name}} work.

7. Run History, Debugging, and Retry

Open a workflow → Runs tab.

Each run row shows status, trigger record, duration, and the filter evaluation tree. Click into a run to see every step with:

  • the config that was used (configSnapshot — captured before the action ran),
  • the payload going in (payloadIn — same snapshot used by retry),
  • the result the action returned, or the exact error message.

7.1 Retry

ModeButtonWhat happens
Whole runRetry runA new run is created with the original trigger payload and all actions re-execute from the top.
From a stepRetry from this stepA new run is created with the failed step's payload_in snapshot — preserving any payloadMerge from earlier steps — and execution resumes at that action. The original run is linked via meta.retriedFromRunId.

configSnapshot is captured per-step, but retries re-resolve from the live workflow_actions table. That means if you fix a broken step's config and click retry, the new run uses the new config — usually what you want.

7.2 Common causes of failure

  • create_task FK error — the resolved statusId, taskTypeId, or priorityId doesn't exist. Check the error message: it includes the full resolved context, not just the SQL message.
  • update_field skipped — the field isn't on the allowlist for that module. Move the value into a custom field, or add the column to the allowlist (engineering change).
  • assign_owner returns "No user resolved" — the configured pool is empty after applying the leads exclusion list or because team membership changed.
  • send_email skipped — no email resolved (e.g., account_email mode but the linked account has no email). Try record_email or specific.

8. Best Practices

  1. Start with _created triggers. They fire once per record and are easier to reason about than _updated.
  2. Use skipIfOwnerSet on _updated assigns. Without it, every field edit can re-trigger ownership reassignment.
  3. Use changed_to / changed_from on update triggers to fire only on the specific transition you care about — not on every edit to that field.
  4. Validate scheduled crons before activating. The Validate Cron endpoint returns the next 5 fire times so you can sanity-check the cadence.
  5. Use Preview matches. Confirm the scheduled scan returns the rows you expect before the first sweep fires hundreds of actions.
  6. Install scheduled templates paused. They install with isActive = false so you can review and adjust before turning them on.
  7. Watch for loops. A workflow that updates a field then triggers itself on _updated is the most common foot-gun. Add a guard condition (field !== newValue) or use changed_to.
  8. Name descriptively. "Auto-assign high-value SMB leads to East team" beats "Workflow 5".
Common foot-guns
  • _updated triggers without filters — fires on every edit. Always pair with changed_to, any_change, or a value filter.
  • Webhook URL typos — failed webhooks still return a status code; the step is marked failed but the rest of the run continues. Watch the Runs tab.
  • Assigning tasks to deactivated users — the task is created but no one sees it. The assignment-log table makes this visible.
  • Scheduled scan with no filter — the workflow fires on every active row of the module on every cron tick. Always combine the cron with an entity filter (or a hard limit).

Next: Approval Rules — multi-step approval chains used by proposals, projects, and contracts.