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 saleclick— contact clicked a link (v1.1)open— contact opened an email (v1.1)appointment_booked— appointment created linked to contacttag_added— a tag was added to the contactcall_answered— a call log was recorded withpickedUp=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:
- Pick a template (only templates assigned to your org show)
- Fill in variables (
{{name}},{{offer}}, etc.) - Pick the audience (filters by buyerStage / segment / tags)
- 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
waitUntilSignalJSON +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,openaren'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:
ContactSignalis append-only. A contact who replied 6 months ago still counts unless you set awithinHourswindow on the branch. - Max-wait ceiling: always set a
maxWaitHourson 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.