act · sequences and steps
Next-best-action engine
One ranked move per lead.
In Sequences and steps
Instead of marching every lead through fixed steps, the next-best-action engine picks one move per lead at a time: generate the moves that are possible, score each, drop the ones the contact policy blocks, rank, and hand the founder a short list for today. This page opens up the engine's internals and the one thing most engines forget to log: the odds of each choice.
sequence library → steps · the parts → templates · the words → next-best-action · the choice → contact policy · the limits
One move per lead — Each lead gets at most one recommended move at a time, so nothing collides.
People waiting come first — Anyone who asked you something outranks every new prospect.
Log the menu, not just the choice — The candidates, their scores and the odds of the pick are what make the engine improvable.
in industry terms
A next-best-action engine is a policy: given a lead's state, which action now.
CRM and marketing platforms call this next best action or next best offer; in machine learning it's a decision policy, and with logged odds it becomes a contextual bandit. The difference from a static sequence: the order of steps isn't fixed, it's chosen each time from what is allowed and likely to work.
static — Fixed sequence · Day 0 reply, day 3 DM, day 7 nudge, for everyone. Easy to read, ignores what each person did.
dynamic — Next best action · Every day, for each lead, the best allowed move given their signals, replies and history.
learning — Bandit · The same engine, with a small random slice and logged odds, so it can learn which moves work.
how it works
Six stages, run for every eligible lead.
The output is today's ranked list for the founder. Everything in between is logged so the ranking can be judged and improved later.
- Candidates — possible moves
- Features — frozen per lead
- Score — value × chance
- Policy filter — caps, never
- Rank + cap — daily limit
- Log — menu, pick, odds
- Founder — act, skip, snooze blocked
- Blocked — rule logged
1 · candidates — What could happen next · The next step of an active sequence, starting a play whose trigger fits, waiting, stopping, answering them.
2 · features — Frozen at decision time · Tier, trigger, signal age, fit lines, replies so far, days since last touch, channel open.
3 · score — Expected value · Chance it gets a reply or a join, times what that's worth, minus founder minutes. Rules today; a learned model later.
4 · policy — Hard limits · The contact policy removes any move it doesn't allow, and logs why.
5 · rank — Daily cap · Highest first, people waiting first, overflow waits for tomorrow in rank order.
6 · log — The whole menu · Candidates with scores, the pick, its probability, and later the outcome via the touch_id.
the score
A transparent v1, then a learned v2.
Phase 1 ranks with rules. The proposal below makes the rule explicit so it can be logged and later replaced by a model trained on the outcomes it produced.
score(move, lead) = p_success(move, lead) · value(move) − cost(move) / founder_minute_value
p_success v1: lookup by (move type, tier, signal age bucket) v2: reply-propensity model
value join = 1.0 · positive reply = 0.3 · brief opened = 0.2 · public reply = 0.05
cost founder minutes for the move
boosts waiting on you: rank first · fresh (< 24 h): ×1.5 · stacked signals: ×1.2
exploration: with probability ε = 0.1, pick uniformly among allowed moves; log probability either wayThese weights are a starting point for v1, written down so they can be logged and tested.
the object
The move, as logged.
One move record per lead per decision, with the whole menu.
| Field | Type | What it holds |
|---|---|---|
move_id | id | One per decision. |
lead_id | id | Who it's for. |
decided_at | timestamp | When the engine ran. |
features | json | The frozen inputs, with scorer version. |
candidates | list | Every possible move with its score, including blocked ones. |
blocked | list of {move, rule} | Moves the contact policy removed, and why. |
chosen | move | The pick shown to the founder. |
propensity | 0 to 1 | The probability the chosen move had of being picked. With ε-greedy: 1 − ε + ε/k for the top move, ε/k for others. |
founder_action | enum | accepted · edited · skipped · snoozed |
touch_id | id | Set if a message went out, joining the move to its outcome. |
outcome | json | Reply, click, join within the window. |
one record, as data
{
"move_id": "move_…",
"lead_id": "lead_…",
"decided_at": "2026-10-21T12:00:00Z",
"scorer_version": "nba.v1",
"features": {
"tier": "Hot",
"trigger": "public_pain",
"signal_age_h": 20,
"replied": true,
"dms_sent": 0
},
"candidates": [
{
"move": "answer_them",
"score": 0.82
},
{
"move": "dm_offer",
"score": 0.41
},
{
"move": "wait",
"score": 0.05
},
{
"move": "stop",
"score": 0
}
],
"blocked": [],
"chosen": "answer_them",
"propensity": 0.925,
"founder_action": "edited",
"touch_id": "touch_…",
"outcome": null
}the catalog
Thirteen move types the engine chooses between.
Reach moves contact the person; convert moves make the ask; hold moves wait or hand work to the founder; end moves stop or exclude.
move.public_reply — Reach
Reply in public
Answer their post. The default first move on a fresh cold signal.
- when
- fresh signal, thread open
- value
- starts a conversation others can see
- cost
- 10 to 20 founder minutes
move.light_touch — Reach
Follow up in the thread
They answered your reply.
- when
- positive or question reply
- value
- moves toward a private conversation
- cost
- a few minutes
move.dm_offer — Reach
DM with the offer
The one private message, with the brief and the waitlist.
- when
- after a public exchange, or a hand-raise
- value
- the step that makes offers
- cost
- spends the one-DM allowance
move.nudge — Reach
Nudge
One follow-up to an unanswered DM.
- when
- +3 to 4 days, under the cap
- value
- recovers some silent leads
- cost
- risk of annoyance
move.answer_them — Reach
Answer them
They asked you something. Always ranks first.
- when
- an unanswered question from the lead
- value
- highest: they're waiting
- cost
- a few minutes
move.waitlist_offer — Convert
Offer the waitlist
Ask for the join once value was given.
- when
- brief opened, positive reply
- value
- the goal metric
- cost
- none
move.start_play — Convert
Start a play
Enroll the lead in a playbook's sequence.
- when
- new lead fits a play's trigger
- value
- whatever the play produces
- cost
- per play
move.wait — Hold
Wait
Doing nothing is a move, and it gets scored too.
- when
- too soon after the last touch
- value
- protects the relationship
- cost
- the signal ages
move.snooze — Hold
Snooze
The founder pushes it to later.
- when
- founder choice
- value
- label: not now
- cost
- none
move.task — Hold
Founder task
Needs a person: find an email, check a profile.
- when
- a step needs information
- value
- unblocks the next move
- cost
- founder minutes
move.stop — End
Stop
End the sequence for this lead.
- when
- out of steps, stale, not now
- value
- saves time and goodwill
- cost
- none
move.never — End
Mark never-contact
They asked to stop, or they're a customer's staff or a competitor.
- when
- stop request, lead rules
- value
- permanent exclusion
- cost
- none
move.rescore — End
Re-score
New signals arrived; recompute before choosing.
- when
- a new signal on an existing lead
- value
- fresher ranking
- cost
- compute
illustrative example
One lead, one morning.
The scores are invented to show the shape.
state — Hot tier, posted the pain 20 hours ago, you replied in the thread yesterday, they answered with a question
candidates — answer them 0.82 · DM with the brief 0.41 · wait 0.05 · stop 0.00
policy — DM allowed (no DM sent yet); nothing blocked
chosen
answer them, propensity 0.93 (ε = 0.1, 4 allowed moves)
People waiting on you rank first.
founder — edits the draft and sends; the touch_id joins tomorrow's reply to this move
failure modes
What goes wrong, and how to catch it.
How each one shows up, and the check that catches it before it costs a week.
| Failure | How it shows up | How to catch it |
|---|---|---|
| Stale moves | Yesterday's list acted on today, after they already replied | Moves expire; re-score on any new signal or reply |
| Always the same play | No exploration, so the engine never learns alternatives | A small random slice with logged odds |
| Policy bypass | A move shown that the policy should have blocked | Policy runs after scoring and before ranking, every time; blocks are logged |
| Feedback loop | Only high scorers get contacted, so the model only learns about them | Exploration slice, plus a holdout in campaigns |
| Unjoinable outcomes | Can't tell which move earned a reply | touch_id on every message the move produced |
data to capture
Record it so a model can learn from it later.
Follows the guide's eight data-spine rules: log the decision, version what decided, freeze the inputs, keep the negatives, log the odds, treat every human touch as a label, keep one chain of ids, keep the raw.
record
- the full candidate list with scores, the pick and its propensity
- the frozen features and scorer version
- blocked moves with the rule that blocked them
- the founder's action on the move, and the outcome via the touch_id
label
reply or join after the move; the founder's accept, edit, skip or snooze
trains
the move policy itself (contextual bandit) and a reply-propensity model for p_success
test
new scoring rules, judged offline on logged decisions before going live · off-policy evaluation, then a live exploration slice