Nurture sequences
A sequence is a templated multi-channel journey for one contact. SequenceTemplate plus its ordered
SequenceTemplateStep rows define the journey; a ContactSequence is one contact travelling
through it, driven end-to-end by a single durable Inngest run.
Templates
| Field | Meaning |
|---|---|
|
Human description; |
|
|
|
Marks this template as the default for that campaign kind |
|
Only active templates are offered for enrollment |
Endpoints: GET|POST /api/sequence-templates,
GET|PATCH|DELETE /api/sequence-templates/{id},
POST /api/sequence-templates/{id}/duplicate. The builder UI lives in components/sequences/ and
app/(dashboard)/sequences/templates/.
Step channels
channel |
Effect |
|---|---|
|
Sends an email to the contact and records an |
|
Starts a Retell SMS chat and records an |
|
Creates a |
|
A durable delay — the only channel that pauses the run |
|
Evaluates BigQuery conditions and branches, or waits and re-checks |
|
Publishes |
|
Publishes a user-defined, template-interpolated event to the CRM queue |
Common step fields
stepIndex (unique per template), delayHours with delayUnit (hours or minutes, meaningful
only for wait), timeoutHours (how long a rep has on a call_task), name, description,
instructions, expectedOutcome, emailTone (warm, direct, social_proof, urgency,
breakup), emailSubjectHint, smsTemplate, isAiGenerated, fixedEmailId.
Only wait steps pause. Action steps execute immediately when reached — pauses are modelled
explicitly rather than being implied by a per-step delay, which makes a template’s timeline readable
from its step list.
Action semantics
| Channel | Behaviour and skip conditions |
|---|---|
|
Subject is |
|
Picks an SMS-enabled number for the workspace and starts a Retell chat with
|
|
Creates one |
|
Publishes |
|
Validates |
Data gates
A data_gate step evaluates conditions against a BigQuery view for the contact and routes
accordingly. All CRM, call and engagement data is mirrored into BigQuery, so a gate can ask
questions the application database cannot answer.
| Field | Meaning |
|---|---|
|
View name, e.g. |
|
|
|
|
|
Deprecated single-condition form, still read for old rows |
|
Where to jump when the gate passes |
|
Where to jump when it fails |
|
When set, a failure waits this long and re-checks instead of branching |
|
Cap on re-checks; |
Operators: is_not_null, is_null, equals, not_equals, gt, lt, is_in, is_not_in.
value is required for all but the null checks, and is a comma-separated list for the in
operators. Values may contain {{token}} references to contact columns, resolved before querying.
Safety
View, field and identifier names are SQL identifiers and cannot be bound as query parameters, so
lib/bigquery/client.ts validates them against a strict allowlist before interpolation. The
identifier value is always passed as a bound parameter.
A contact with no hubspotContactId has nothing to scope the query by, so every condition is
treated as not met — the gate fails closed rather than passing on missing data.
Routing
computeAdvance(step, totalSteps, gateResult, gateRetryCount) is pure:
| Situation | Decision |
|---|---|
Not a gate, or no gate result |
Advance to |
Gate passed |
|
Gate failed, |
|
Gate failed, retries exhausted |
|
Gate failed, no retry delay |
|
A gate with retries but no false branch and no remaining retries abandons the sequence. That is deliberate: "we waited as long as we agreed to and the condition never became true" is an outcome, not a step to skip past.
CRM event steps
extraConfigs holds { event_type, body }, validated by CrmEventConfigSchema.
-
event_typecomes from the registry inlib/sequences/crm-event.ts— currentlyCrm.TaskCreateandContact.Update. Adding a type is one entry inCRM_EVENT_TYPES. -
bodyis arbitrary JSON. The reserved keyevent_typeis rejected at validation and stripped at render time, so the selected type can never be overridden by user input.
Template variables
| Group | Tokens |
|---|---|
Contact columns ( |
|
Run context ( |
|
System ( |
|
The curated list is what the editor surfaces; at runtime buildContactVariables derives the map
from the actual contact row, so any column is usable via its snake_case name.
Type preservation is deliberate. A string that is exactly one token ("{{lead_score}}") resolves
to the variable’s natural JSON type, so a numeric-looking string id such as hubspot_contact_id
stays a string while an integer column stays a number. Tokens embedded in longer strings are
stringified; missing tokens render as empty string. System variables are computed at execution time
and applied last, so no contact column can shadow {{now}}.
Enrollment
POST /api/contact-sequences with { contact_id, template_id } and optional campaign_id,
campaign_target_id, email_campaign_target_id.
SequenceExecutionService.enrollContact enforces one active sequence per contact in application
code rather than with a unique constraint — a (contactId, status) unique index would wrongly block
a contact from having several completed or paused enrollments.
Lifecycle endpoints: POST /api/contact-sequences/{id}/start, /stop, /restart;
GET /api/contact-sequences/{id} returns the full detail including the step timeline.
Statuses
SequenceStatus |
Meaning |
|---|---|
|
Created, waiting for a trigger |
|
Running |
|
Manually paused |
|
Reached the end of the template |
|
Ran out of steps or gate retries with no response |
|
Pivoted to a different sequence |
Execution
The sequenceExecute Inngest function (retries: 1, concurrency: { limit: 50 }) runs a contact’s
entire journey in one durable run.
cancelOn: [{ event: "sequence/cancel", match: "data.contactSequenceId" }]
A sequence/cancel event tears the run down immediately, including an in-progress step.sleep.
That is why the loop does not need a per-iteration ownership re-check: stopping a sequence is
handled by the runtime, not by polling.
Shape
-
start-execution— one-time init. Loads the enrollment with its template steps and contact, refuses to restart a terminal sequence, claims the run by stampingexecutionId = runIdand marking itactive, and returns the immutable run context. -
Loop, indexing the once-loaded context in memory:
-
No step at this index →
complete-no-step-<i>and finish. -
waitchannel →step.sleepfordelayHoursindelayUnit, then a lightweightrecord-waitlog with no response payload. -
data_gateon a re-check → sleepgateRetryDelayHoursfirst, thencheck-gate-<tag>. -
Otherwise →
execute-action-<tag>. -
finish-<tag>applies thecomputeAdvancedecision.
-
-
abandon-runaway— aMAX_ITERATIONSceiling of 1000 as defence in depth. Gate retries are already bounded bygateMaxRetries; this guards against a mis-wired branch cycling forever.
Step ids embed the step index, retry count, channel and a slugified name — for example
execute-action-3-0-email-intro-follow-up — so the Inngest dashboard is readable and ids stay
stable across replays.
Each side effect is its own step.run, so it retries independently and is skipped on replay.
Sleeping runs consume no concurrency, so a template with a seven-day wait costs nothing while waiting.
Step logs
Every execution writes a ContactSequenceStep row sharing the run’s executionId:
| Field | Meaning |
|---|---|
|
Nullable with |
|
|
|
|
|
Re-check count for gate steps |
|
Links to whatever the step produced |
|
The full payload from the execution — provider ids, CRM event body, gate evaluation details. One row per execution, never overwritten |
|
When it ran |
response is what makes an enrollment debuggable months later: the exact body sent to SQS, the
exact BigQuery row a gate saw.