fix(leads): let a campaign say what it is pitching - #129
Merged
Merged
Conversation
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>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Campaigns were project-scoped in who they contacted but not in what they said.
pipeline.tsopened its draft prompt with: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
anglecolumn 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_modedefaults 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:
if (!audit) continue)isWeakEnoughreturns false for a null scoreBoth now apply only when scanning is on.
Bonus: the tick summary was lying
(auto_send off)printed whenever zero were sent, regardless of the setting. Confirmed against the live database — both campaigns haveauto_send: truewhile their ticks reportedauto_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 --noEmitclean🤖 Generated with Claude Code