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

name, goal, description

Human description; goal and description are also fed to the AI writer

type

SequenceType — onboarding, re_engagement, not_connected_outreach

callCampaignDefault, emailCampaignDefault, smsCampaignDefault

Marks this template as the default for that campaign kind

isActive

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

email

Sends an email to the contact and records an Activity

sms

Starts a Retell SMS chat and records an Activity

call_task

Creates a FollowUpTask of type call for a sales rep

wait

A durable delay — the only channel that pauses the run

data_gate

Evaluates BigQuery conditions and branches, or waits and re-checks

crm_task

Publishes Crm.TaskCreate to the CRM queue

crm_event

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

email

Subject is emailSubjectHint, else name, else "Following up"; body is description, else instructions. Logs to HubSpot when the contact has a hubspotContactId (best-effort) and writes an Activity. Skipped with no_contact_email when the contact has no address.

sms

Picks an SMS-enabled number for the workspace and starts a Retell chat with RETELL_SMS_AGENT_ID, passing customer_name, customer_phone, company_name, goal and initial_message. Skipped with no_contact_phone or no_sms_number.

call_task

Creates one FollowUpTask per enrollment: if a call task already exists for this contactSequenceId, the step is skipped with already_called. A sequence may loop back to a call step without generating a second task. The rep is resolved by the contact’s hubspotOwnerId, falling back to the oldest active rep in the workspace; no rep at all skips with no_sales_rep. dueAt is now + delayHours.

crm_task

Publishes Crm.TaskCreate with hubspotContactId, hubspotOwnerId, taskType (crmTaskType, default follow_up), description and a dueDate of now + crmTaskDueHours. Skipped with no_hubspot_contact when the contact is not in HubSpot.

crm_event

Validates extraConfigs against CrmEventConfigSchema, interpolates the body, then publishes with event_type forced from the config. Returns the exact published body so the Inngest step result is self-documenting.

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

gateBigqueryView

View name, e.g. outreach_app.contacts. Must expose a hubspotcontactid column

gateConditions

[{ field, operator, value }] — the current shape

gateMatchMode

all (AND) or any (OR)

gateField, gateOperator, gateValue

Deprecated single-condition form, still read for old rows

trueBranchStepIndex

Where to jump when the gate passes

falseBranchStepIndex

Where to jump when it fails

gateRetryDelayHours

When set, a failure waits this long and re-checks instead of branching

gateMaxRetries

Cap on re-checks; null is unbounded

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 stepIndex + 1; past the last step → complete

Gate passed

trueBranchStepIndex if set, else the next step; out of range → complete

Gate failed, gateRetryDelayHours set, retries remain

retry after gateRetryDelayHours

Gate failed, retries exhausted

falseBranchStepIndex if set, else abandon

Gate failed, no retry delay

falseBranchStepIndex if set, else the next step

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_type comes from the registry in lib/sequences/crm-event.ts — currently Crm.TaskCreate and Contact.Update. Adding a type is one entry in CRM_EVENT_TYPES.

  • body is arbitrary JSON. The reserved key event_type is 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 (CONTACT_TEMPLATE_VARIABLES)

first_name, last_name, email, phone, mobile_phone, company_name, job_title, disposition, lifecycle_stage, lead_status, hubspot_contact_id, hubspot_owner_id, hubspot_deal_id, hubspot_deal_stage, hubspot_deal_status

Run context (SEQUENCE_CONTEXT_VARIABLES)

sequence_created_at

System (SYSTEM_TEMPLATE_VARIABLES)

now (ISO-8601), now_int (epoch ms), now_date (UTC YYYY-MM-DD)

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

pending

Created, waiting for a trigger

active

Running

paused

Manually paused

completed

Reached the end of the template

abandoned

Ran out of steps or gate retries with no response

exited

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

  1. start-execution — one-time init. Loads the enrollment with its template steps and contact, refuses to restart a terminal sequence, claims the run by stamping executionId = runId and marking it active, and returns the immutable run context.

  2. Loop, indexing the once-loaded context in memory:

    • No step at this index → complete-no-step-<i> and finish.

    • wait channel → step.sleep for delayHours in delayUnit, then a lightweight record-wait log with no response payload.

    • data_gate on a re-check → sleep gateRetryDelayHours first, then check-gate-<tag>.

    • Otherwise → execute-action-<tag>.

    • finish-<tag> applies the computeAdvance decision.

  3. abandon-runaway — a MAX_ITERATIONS ceiling of 1000 as defence in depth. Gate retries are already bounded by gateMaxRetries; 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

templateStepId

Nullable with onDelete: SetNull — editing a template severs the link but keeps the history

status

pending, executed, skipped, failed

outcome

replied, opened, connected, no_answer, no_reply, or a channel-specific string such as email:<id>

gateRetryCount

Re-check count for gate steps

callId, emailId, followUpTaskId, activityId

Links to whatever the step produced

response

The full payload from the execution — provider ids, CRM event body, gate evaluation details. One row per execution, never overwritten

executedAt

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.

Enrollment list

GET /api/contact-sequences supports page, page_size, search and status, and returns the contact’s name, email and company plus the template’s name and type alongside each enrollment.