modules

Campaigns (v2) — branching, signals, and the tree builder

How to design campaigns with conditional branching based on real customer signals (replies, purchases, clicks).

Updated 2026-04-19

What changed in v2

Campaigns used to be a linear list of steps. v2 makes them a tree of nodes with branches based on real customer behavior.

Four node types:

Node What it does
Message Sends one message on one channel (WhatsApp, SMS, or Email)
Wait Pauses the enrollment. Either for a fixed duration, or until a signal happens (with a max wait)
Branch Splits into "yes" / "no" rails based on whether a signal has been seen
Exit Ends the enrollment. Optional reason for analytics

Signals

Signals are the events that drive branching:

  • reply — contact replied to a message (inbox, SMS, WhatsApp, email)
  • purchase — contact made a sale
  • click — contact clicked a link (v1.1)
  • open — contact opened an email (v1.1)
  • appointment_booked — appointment created linked to contact
  • tag_added — a tag was added to the contact
  • call_answered — a call log was recorded with pickedUp=true

Signals write to a dedicated ContactSignal table. Every time a customer does one of these things, a row gets inserted. Wait-until-signal and Branch nodes query this table to decide what to do next.

Building a template

Where to go

Super admin only (v1): /admin/campaign-templates. Click any template → tree builder.

Adding nodes

Each node has a + button below it that inserts a sibling. Click it, pick the type (Message / Wait / Branch / Exit).

For Branch nodes, the yes/no rails render as two indented columns. Each has its own + button at the bottom.

Converting a linear step to a branch

If you've got a message → wait → message chain and realize you want to branch on "did they reply?", click the dropdown on any message/wait node and pick Convert next step to branch. The downstream tail moves under the new branch's "yes" rail, and "no" stays empty for you to fill.

One-level-of-nesting rule

v1 allows one branch level. You can't put a branch inside a branch rail. Covers ~90% of real flows. Multi-level branching comes in v1.1 if we hit the ceiling.

Upgrading a legacy (v1) template

Any template that still uses the old linear step list shows an Upgrade to v2 (branching) banner. Click it. The linear steps get auto-converted to a message → wait → message chain with a terminal Exit. From there you can freely add branches and signals.

Idempotent: running the upgrade twice does nothing.

The reactivation starter template

A ready-made 11-node flow called Reactivate Dormant Customer — message email offer → wait for reply → branch on reply → yes (warm WhatsApp follow-up) → no (SMS last-call, wait 7d for purchase → branch → yes (congrats exit) / no (feedback email)).

Seed it by running (one time, super admin only):

npx tsx scripts/seed-reactivation-template.ts

Starting a campaign

Org users visit /campaigns/new. The wizard:

  1. Pick a template (only templates assigned to your org show)
  2. Fill in variables ({{name}}, {{offer}}, etc.)
  3. Pick the audience (filters by buyerStage / segment / tags)
  4. Review + launch

On launch, every node from the template is deep-copied into the campaign's own subtree. The enrollment's currentNodeId gets set to the root node. The cron picks it up from there.

How the cron runs it

The cron at /api/cron/campaigns fires every 5 minutes. For each active enrollment:

  • If on a message node: render variables, dispatch (email via Emailit, SMS via Arkesel, WhatsApp via Unipile), log as ConversationMessage, advance to first child.
  • If on a wait duration: first visit sets nextStepAt = now + hours; second visit advances.
  • If on a wait-until-signal: park with waitUntilSignal JSON + waitMaxAt. Re-check every 5 min. Signal found → advance. Past max → timeout-advance.
  • If on a branch: query ContactSignal per the condition. Yes child or no child.
  • If on exit or a node with no children: mark enrollment completed.

Variables available in every message node

  • {{name}} — contact's first name (auto-resolved per-contact)
  • {{agentName}} — Brand DNA agent name
  • {{businessName}} — org name
  • Plus any custom {{vars}} you add — filled by the user in the wizard

Common patterns

Reactivation

Email → wait-until-reply (48h max) → branch: yes=thank them + exit, no=SMS offer → wait-until-purchase (7d) → branch: yes=congrats exit, no=feedback email.

Post-purchase upsell

Wait 2 days after purchase signal → email offer → wait-until-click (24h) → branch: yes=WhatsApp details, no=one more SMS.

Appointment confirmation

Wait-until-appointment_booked → WhatsApp confirmation → wait 24h before appointment → SMS reminder.

Limits today (v1)

  • Branches support yes/no only (no 3-way splits yet)
  • No drag-drop canvas (linear+tree only)
  • Signals for click, open aren't wired yet (the tables are ready — just no ingestion)
  • Campaign detail page shows enrolled/replied/converted counts but not revenue attribution (coming soon — see the Positioning roadmap)

Gotchas

  • Stale signals: ContactSignal is append-only. A contact who replied 6 months ago still counts unless you set a withinHours window on the branch.
  • Max-wait ceiling: always set a maxWaitHours on wait-until-signal nodes. Without it, enrollments can hang forever.
  • Multi-channel messages: v1 is one channel per message node. Want email + SMS simultaneously? Add two message nodes in sequence.

Was this article helpful?