Skip to content

fix(leads): let a campaign say what it is pitching - #129

Merged
ralyodio merged 1 commit into
masterfrom
fix/campaign-pitch
Jul 28, 2026
Merged

ralyodio merged 1 commit into
masterfrom
fix/campaign-pitch

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Campaigns were project-scoped in who they contacted but not in what they said.

pipeline.ts opened its draft prompt with:

You write cold outreach email for CrawlProof, which scans websites for
how they read to AI answer engines and fixes what it finds.

Every layer below assumed the same offer — drafting refused without a scan score ("no findings and no score on file"), guidance hardcoded "one ask: a link to their free report", subjects read "scores 42/100 with AI answer engines".

So a campaign built to reach game studios about a 3D modelling role found game studios correctly, then sent them an AEO audit pitch. The existing angle column couldn't help: it was injected as "Emphasis requested: ..." inside the fixed pitch. It shifts tone; it can't replace a premise.

Campaigns can now describe their own pitch

pitch_mode defaults to 'audit', so existing campaigns keep the old path exactly. A custom campaign supplies who is writing, the one ask, and the facts a draft may state.

Grounding survives the change of subject

This was the part worth getting right. The audit pitch earns its claims from a scan — that guard is what stops the machinery becoming a confident-false-statement generator pointed at strangers. A custom pitch has no scan, so the campaign declares its facts and the same class of guard runs against those.

It's deliberately narrow: proving a sentence is entailed by a fact list isn't something a regex can do, and pretending otherwise would give false confidence. What it catches is checkable fabrication — numbers and links that appear nowhere in the declared facts, and claimed prior contact. Tone stays the prompt's job.

A custom campaign with no declared facts is refused at save time, since every draft would be rejected anyway.

Scanning is now optional

Off by default for a custom pitch. Scanning someone in order to email them about something unrelated spends worker time and reads as surveillance.

Two assumptions had to be unpicked, both of which would have stranded every prospect:

  • research waited for an audit that would never arrive (if (!audit) continue)
  • the score gate rejected everything, because isWeakEnough returns false for a null score

Both now apply only when scanning is on.

Bonus: the tick summary was lying

r.sent ? `${r.sent} SENT` : `${r.dryRuns} drafted (auto_send off)`

(auto_send off) printed whenever zero were sent, regardless of the setting. Confirmed against the live database — both campaigns have auto_send: true while their ticks reported auto_send off, sending the user to check a switch that was already flipped. It now distinguishes "sending is off" from "sending is on but the funnel stalled".

Migration

20260728020000_outreach_campaign_pitch.sql — additive, with defaults chosen so existing campaigns are untouched. Already applied to prod ahead of this deploy, since the runner selects the new columns.

Checks

  • tsc --noEmit clean
  • 699/699 tests pass, 15 new
  • production build compiles

🤖 Generated with Claude Code

Campaigns were project-scoped in who they contacted but not in what they
said. The draft prompt opened "You write cold outreach email for
CrawlProof, which scans websites for how they read to AI answer
engines", and every layer below it assumed the same offer: drafting
refused without a scan score, the guidance hardcoded "one ask: a link to
their free report", and subjects read "scores 42/100 with AI answer
engines".

So a campaign built to reach game studios about a 3D modelling role
found game studios correctly and then sent them an audit pitch. The
angle column could not help: it was injected as "Emphasis requested:"
inside the fixed pitch, which shifts tone but cannot replace a premise.

A campaign can now describe its own pitch. pitch_mode defaults to
'audit', so existing campaigns keep the old path exactly.

Grounding survives the change of subject rather than being dropped with
it. The audit pitch earns its claims from a scan; a custom pitch has no
scan, so the campaign declares the facts a draft may state and the same
class of guard runs against those. It stays deliberately narrow --
proving entailment is not a regex's job -- and catches the checkable
species of fabrication: numbers and links appearing nowhere in the
declared facts, and claimed prior contact. A campaign with no declared
facts is refused at save time, because every draft would be rejected
anyway.

Scanning is now optional, and off by default for a custom pitch.
Scanning someone in order to email them about something unrelated spends
worker time and reads as surveillance. That required unpicking two
assumptions: research waited for an audit that would never arrive, and
the score gate rejected everything, since an unscanned prospect has no
score. Both now apply only when scanning is on.

Separately, the tick summary printed "(auto_send off)" whenever nothing
was sent, regardless of the setting -- so a campaign with sending
enabled but a stalled funnel reported the opposite of the truth and sent
people to check a switch that was already flipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit ea8cf7a into master Jul 28, 2026
8 checks passed
@ralyodio
ralyodio deleted the fix/campaign-pitch branch July 28, 2026 02:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant