clickSomo

Funnels & triggers

A funnel is a graph.A run is a row.A wait is a time.

That is the entire model, and every difficult property this product has comes out of those three sentences. Editing a live funnel is safe because of them. A retry cannot double-send because of them. A person who unsubscribes on Tuesday hears nothing on Thursday because of them.

What actually happens when it runs.

Publishing a funnel writes an immutable version. Somebody enters, and a row appears: one run, for this contact, pinned to that version, holding the current node, a wake-up time and its own context.

A worker leases the runs whose wake-up time has passed, claiming them with SKIP LOCKED so two workers cannot take the same one. It executes nodes until it reaches a wait or a terminal, then writes the next wake-up time and lets go.

A node is a pure function of the run, the contact and the node — and its effects commit in the same transaction as the run's own advance. That single property is what makes a retry safe. A worker killed in the half-second between "send" and "advance" leaves either both or neither, never one.

And because the app supplies the time rather than the database, a row written now is due now. A scheduling column defaulting to the database clock is not yet due at the instant it is written, and misses the very tick that created it — a bug that looks like nothing at all until a message is an hour late.

One tick of the workerExecution
  • 1Leasewake_at <= now(), FOR UPDATE SKIP LOCKED. A queue without leases double-sends by default.
  • 2ExecuteNodes in order until a wait or a terminal. Each writes a step: what it decided, what it emitted.
  • 3CommitEffects and advance, one transaction. This is the whole trick.
  • 4SleepNext wake_at written. No downstream rows exist yet, and none need to.

One run per contact per version

Fourteen triggers

What can start a run.

Some of these are things you do. Most are things your customer does, or does not do — and the absence of an action is as good a trigger as the action itself.

They arrive

form_submit, list_join, contact_created, tag_added and segment_enter. A segment is a live predicate, so "enters the segment" covers every "has become" you can express — three orders, ninety days quiet, spends over a threshold.

They do something

page_visit from the site snippet, message_opened, link_clicked, booking_made and payment_made. A message being opened is itself an event on the contact, so one funnel can wait for a reaction to the last thing it sent.

Time, or you

date_field with an offset — a birthday, a renewal, "a year since", seven days before or three days after. schedule for the recurring one. manual for the announcement you send because you decided to.

Your own application

api_event is a custom event you post from your own code with your own token. Anything your product knows becomes a trigger: activated, invited a colleague, hit the limit, cancelled, exported. This is the one that turns a mailing tool into part of your product.

Re-entry, decided once

A funnel is either re-entrant or it is not, and the database enforces it: one run per contact per funnel unless you said otherwise. The same trigger firing twice in the same second cannot produce two runs by accident.

Entry conditions

A trigger fires and the entry spec decides whether this particular contact is eligible — a segment predicate in front of the door. Cheaper than a condition node on the first step, and it keeps the graph about what happens rather than about who is allowed in.

Fourteen things can start it.
Sixteen things can happen next.

Sixteen actions and seven flow nodes

What a funnel can do,
and how it decides.

Send something

send_email, send_sms, send_push, send_letter — four channels, one graph — plus notify_team when the person who needs telling works for you.

Change the contact

add_tag, remove_tag, add_to_list, remove_from_list, set_field. Marking somebody as you go is what makes the next funnel's segment simple.

Reach outside

http_request to anything you like, create_payment against your own connected account, and book_slot against a booking resource. The payer stays yours; nobody becomes our customer.

Move between funnels

enter_funnel and exit_funnel. Build one welcome sequence and call it from four places, rather than maintaining four copies that drift apart by March.

Generate at send time

ai_generate fills merge variables per contact, metered against your quota and cached by creative and contact-attribute hash — or you would pay per recipient on every single send.

Wait, branch, split, join

wait takes a duration, a datetime, a weekday and time, or a date field with an offset. condition is a segment predicate with a yes and a no. split is A/B/n with weights. join brings branches back together.

goal — the one that matters

A goal ends the run for anyone who reaches it. They booked, they bought, they replied: the rest of the sequence is cancelled for them specifically. Nobody gets "we miss you" the day after they came back, and this is the node that prevents it.

throttle and quiet_hours

Throttle paces a burst so a mailbox with a low rate cap is not hammered into suspension. Quiet hours hold a message until a civilised time — applied at the message layer, not copied into every branch of the graph.

Checked before it can run

The validator refuses the same things for you and for the AI.

A graph is checked before it can be published, and the check is not advisory. It is the same code path whether a person drew the funnel or a model generated it, which is the only reason generated funnels are trustworthy at all.

Exactly one entry

Two entry nodes is an ambiguity nobody notices until the wrong half of a list gets the wrong sequence.

Every node reachable

An orphaned branch is either a mistake or a message you think is going out and is not. Both are worth being told about.

A cycle must contain a wait

A loop with no wait in it is an infinite loop with a send in the middle. This rule is the difference between a funnel and an incident.

Every send has its creative

A send_email node without a creative and a subject cannot be published. The message with the right subject and an empty body is a real failure mode; it is refused here.

Every variable is known

Merge variables are validated at publish. {{frist_name}} is a typo you see while editing, not "Hi ," arriving in forty thousand inboxes.

Then simulate it

Dry-run a real contact through the whole graph and read back the path taken and every message that would be sent, with its day. Nothing leaves the building.

The awkward cases

Seven things that break a naive engine.

Every one of these is a test with a deterministic clock behind it, because each one breaks an implementation that looks perfectly correct until it meets reality.

What the engine is tested againstFalsification
  • 1A funnel that loops into itselfLegitimate, common, and fine — provided the cycle contains a wait, which the validator insists on.
  • 2Somebody unsubscribes mid-flightSuppression is checked at send, not at enqueue. Checking at enqueue mails the people who left during the wait.
  • 3The funnel is edited with ten thousand people insideThey keep walking the version they entered on, to the end. New entrants walk the new one.
  • 4The same trigger fires twice in one secondOne run, because uniqueness is an index rather than a check followed by an insert.
  • 5A contact is deleted mid-runThe run ends. An erasure that leaves a sequence still addressed to somebody is not an erasure.
  • 6A wait spans a daylight-saving change"Three days at nine in the morning" means nine in the morning on both sides of the clock change.
  • 7A worker is killed between send and advanceEffects and advance are one transaction. Either both happened or neither did.

Deterministic clockEach test breaks a plausible implementation

Campaigns are the other half.

A funnel reacts. A campaign is the one-shot broadcast: an audience, a schedule, a throttle, and a send. It is a different shape of thing and it is deliberately kept separate rather than pretending to be a funnel with one node.

Two rules make it safe. An audience is materialised once and refuses to run twice — re-resolving would quietly add everybody who joined since the send started, and the deduplication index would hide it by preventing the duplicates it created. And counters are recomputed from delivery events, never incremented in place, so a retried webhook cannot inflate a report.

Which to use: if it happens because of something the person did, it is a funnel. If it happens because of something you decided on a Tuesday, it is a campaign. A surprising amount of marketing automation goes wrong by forcing the second into the shape of the first.

Tell us what you send today,
and to how many.

We will show you what it looks like here — usually with the funnel drawn, because that is faster than a paragraph about it.

Open ClickSomo Talk to a person

Illustrations by Pixeltrue (Minimalistic pack).