There's a design decision we made early in SpurIQ's outbound delivery engine that seemed completely correct. It felt architecturally clean. It matched how most signal-based tooling works. And it caused us a surprising amount of grief before we understood why it was wrong.
The decision: map each signal to its own campaign.
A VP of Sales hire → campaign A. Active SDR job postings → campaign B. New funding round → campaign C. The signal fires on an account, the account gets routed into the campaign associated with that signal, and the campaign runs. Logical. Modular. Easy to build and easy to reason about.
Wrong.
This post is about what broke, why it broke, and the routing logic we replaced it with. I'll show the pseudocode because I think the failure mode is clearest when you see where the routing gets stuck.
What the Original System Looked Like
The first version of the delivery engine worked roughly like this:
def route_contact(contact, account, signals):
active_signals = detect_signals(account, signals)
if not active_signals:
return None # No signal → no send
top_signal = rank_signals(active_signals)[0]
campaign = SIGNAL_CAMPAIGN_MAP[top_signal.type]
enroll(contact, campaign)
The SIGNAL_CAMPAIGN_MAP was a lookup table: signal type → campaign ID. Each campaign had its own copy, its own sequence length, its own subject lines. Everything was built around the signal as the primary organiser.
For a single contact at an account with a single relevant signal, this worked fine. The problem arrived when the accounts got more realistic.
Where It Started Breaking
The first crack showed up on accounts with multiple active signals simultaneously. A company that just raised a Series B and is actively hiring SDRs hits two signals at the same time. In the original design, we'd pick the top-ranked signal and enroll into that campaign. But the second signal was real too, and now we had to decide: do we ignore it, enroll in both campaigns, or somehow merge them?
Enrolling in both campaigns meant the same contact got two separate sequences running in parallel. That's a coordination nightmare, the sequences can collide on timing, contradict each other in tone, and produce the kind of outreach that makes a prospect wonder if anyone is paying attention.
Ignoring the secondary signal meant leaving real buying context on the table.
Merging them required logic we hadn't built, and the merge output was always slightly incoherent, a first email that tried to reference both events and ended up referencing neither properly.
But that was a solvable problem. The deeper problem was something we missed entirely until we looked more carefully at the contacts we were routing.
Signals belong to accounts. Copy belongs to personas.
A funding event at a company is visible across the entire account, it applies equally to the VP of Sales, the Head of RevOps, the CFO, the founders. But what the funding event means to each of those people is completely different.
To a VP of Sales, a Series B might mean: now I have the runway to build the team I've been trying to build for eighteen months, and I'm going to make tooling decisions in the next 60 days.
To a Head of RevOps, the same signal might mean: we're about to double headcount and the systems that worked at 20 reps are going to crack at 40, and I need to get infrastructure in place before the new reps arrive.
Same signal. Completely different conversation. Completely different email.
Our original design had a single campaign per signal. So for any given account, every contact, regardless of role, regardless of seniority, regardless of what the signal actually meant to their specific job, went into the same campaign and got the same copy.
The messages weren't inaccurate. They referenced a real event that had actually happened. But they were addressed to nobody in particular, because the campaign was built around the event rather than the person receiving the message.
What We Rebuilt Around
The insight that reorganised everything: a signal is a timing mechanism, not a messaging mechanism.
It tells you when to reach out. It doesn't tell you what to say. That second job belongs to the persona, specifically, to what the signal means for someone in that role, facing that kind of problem, at that stage in their company's growth.
Once we understood that, the routing logic inverted:
def route_contact(contact, account, signals):
# Step 1: persona drives campaign selection
persona = classify_persona(contact.title, contact.seniority)
campaign = PERSONA_CAMPAIGN_MAP[persona]
if campaign is None:
return None # Persona not in scope, skip
# Step 2: signals shape only the opening line
active_signals = detect_signals(account, signals)
if active_signals:
top_signal = rank_signals_for_persona(active_signals, persona)[0]
opening_line = generate_signal_opener(top_signal, persona)
else:
opening_line = generate_baseline_opener(persona, account)
enroll(contact, campaign, overrides={"email_1_opener": opening_line})
The differences from the original:
The persona determines the campaign. PERSONA_CAMPAIGN_MAP replaces SIGNAL_CAMPAIGN_MAP as the primary routing table. A VP of Sales at any account with any signal goes into the VP of Sales campaign. A Head of RevOps goes into the RevOps campaign. The campaign structure, sequence length, subject lines, body copy, call-to-action, is built around what someone in that role cares about, not around what event occurred at their company.
Signals rank differently by persona. rank_signals_for_persona rather than a generic rank_signals. A VP of Sales hire at the account is highly relevant to the new VP themselves, moderately relevant to the sales ops function, and largely irrelevant to the CFO. An SDR job posting spike is a strong signal for the VP of Sales and the RevOps leader but probably not the right opener for the CMO. The signal weighting is persona-aware.
No signal still gets an email. The else branch in the signal block is the one that changed our thinking most. When there's no active signal, we don't skip the contact, we generate a baseline opener derived from their persona and the account's ICP fit. If someone matches the persona profile and the company meets the qualifier criteria, that's reason enough. We stopped requiring a newsworthy event as the condition for sending at all.
Signals only write the first line. generate_signal_opener produces a single opening sentence or phrase for email 1. Not a subject line. Not a campaign. Not a new sequence. The rest of the email is the standard persona campaign copy. The signal provides specific timing context; the campaign provides the substantive argument.
The Multi-Signal Account Problem, Resolved
With persona-driven routing, the multi-signal account problem largely dissolves.
If an account has both a funding announcement and active SDR hiring, those signals don't compete for campaign ownership anymore. They go into rank_signals_for_persona for each contact, and the most relevant one for that persona wins the opening line. The VP of Sales contact might get the SDR hiring signal as the opener (because it's directly related to their immediate priorities); the Head of RevOps contact might get the funding signal (because systems scaling is their concern).
Same account. Same signals. Different persona. Different opener. Same underlying campaign structure for each.
def rank_signals_for_persona(signals, persona):
scored = []
for signal in signals:
base_score = signal.strength
persona_weight = SIGNAL_PERSONA_WEIGHTS[signal.type][persona]
scored.append((signal, base_score * persona_weight))
return [s for s, _ in sorted(scored, key=lambda x: x[1], reverse=True)]
SIGNAL_PERSONA_WEIGHTS is a matrix: signal type × persona → weight modifier. Building this table was the most time-consuming part of the rebuild, and it's still being refined. But having it explicit means the weighting is inspectable, adjustable, and consistent across every contact processed.
The Part That Still Requires Judgment
I want to be honest about where the pseudocode runs out of road.
Signal interpretation, translating a detected event into a business hypothesis that's specific enough to be a real conversation opener, is still the step that requires the most human judgment to set up well. The code can detect that a VP of Sales hire happened. It can check that the hire was external. It can confirm it was within the last 90 days. What it can't do on its own is decide that the right framing for that signal, for a RevOps audience, is "your new VP is going to want to show the board a scalable outbound motion before the next business review, and the window to put infrastructure in before headcount doubles is shorter than it looks."
That framing has to be written by someone who understands the buying psychology of the persona. The code then slots it in as the opener when the signal fires. So the output of generate_signal_opener is better understood as a template library than an AI generation step, curated openers for each signal-persona combination, chosen by the routing logic.
For a good treatment of how signal scoring and signal interpretation differ and why running them as separate steps matters, the source post goes into this in more depth than I can here. Worth reading before you build the interpretation layer.
What I'd Want to Hear From You
The routing logic above works well for the cases we've tested it against, but I'm aware it's one solution to a problem that probably has several valid approaches.
A few things I'm genuinely curious about:
Multi-signal accounts with unclear persona overlap. What happens when the same contact has multiple titles or responsibilities that span more than one persona bucket? We've handled this by defining a primary persona and routing to that campaign, but there's a case for a different approach.
Signal decay and campaign re-enrollment. If a contact was enrolled on signal A three months ago and signal B fires now, should they re-enter the campaign at email 1 with the new opener? We currently suppress re-enrollment for 90 days after any enrollment, but that's an arbitrary window.
Baseline opener quality for no-signal contacts. The baseline opener is the weakest part of the system right now. Without a signal to work from, the opener defaults to something ICP-derived and competency-based, which works but doesn't have the same specificity as a signal-backed opener.
If you've solved any of these differently, or if you think the persona-first structure breaks down in a case I'm not seeing, genuinely interested. Drop it in the comments.
Top comments (0)