From f8a286e7f114e49fd2cee3666e9df2b3b4804240 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 02:02:47 +0000 Subject: [PATCH] fix(leads): let a campaign say what it is pitching 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) --- app/actions/leads.ts | 42 ++++ components/leads/campaign-panel.tsx | 84 ++++++++ lib/outreach/cold.ts | 55 +++++ lib/outreach/pipeline.ts | 193 ++++++++++++++++++ lib/outreach/runner.ts | 62 +++++- ...20260728020000_outreach_campaign_pitch.sql | 49 +++++ tests/custom-pitch.test.ts | 98 +++++++++ 7 files changed, 574 insertions(+), 9 deletions(-) create mode 100644 supabase/migrations/20260728020000_outreach_campaign_pitch.sql create mode 100644 tests/custom-pitch.test.ts diff --git a/app/actions/leads.ts b/app/actions/leads.ts index a44e74d9..b2c378c0 100644 --- a/app/actions/leads.ts +++ b/app/actions/leads.ts @@ -402,6 +402,11 @@ export async function saveCampaignAction(input: { angle?: string; senderName?: string; replyTo?: string; + pitchMode?: "audit" | "custom"; + pitchIntro?: string; + pitchAsk?: string; + pitchFacts?: string; + scanProspects?: boolean; }): Promise | Err> { const auth = await requireLeadAccess(input.projectId); if (!auth.ok) return auth; @@ -412,6 +417,38 @@ export async function saveCampaignAction(input: { if (!queries.length && !seedUrls.length) { return { ok: false, error: "A campaign needs at least one search query or directory URL." }; } + const pitchMode = input.pitchMode ?? "audit"; + const pitchFacts = (input.pitchFacts ?? "") + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + if (pitchMode === "custom") { + // The same bar the audit pitch holds itself to: without a description + // and some declared facts there is nothing truthful for a draft to say, + // and the grounding guard would reject every draft anyway. + if (!input.pitchIntro?.trim()) { + return { ok: false, error: "A custom pitch needs a description of who is writing and why." }; + } + if (!pitchFacts.length) { + return { + ok: false, + error: + "A custom pitch needs at least one fact. Drafts may only state what you list here, so an empty list means every draft gets rejected.", + }; + } + } + + // Scanning is the audit pitch's evidence step; default it off for a custom + // pitch rather than scanning people we're emailing about something else. + const scanProspects = input.scanProspects ?? pitchMode === "audit"; + if (pitchMode === "audit" && !scanProspects) { + return { + ok: false, + error: + "The audit pitch is built from scan findings, so it can't run with scanning turned off. Switch to a custom pitch instead.", + }; + } + if (input.autoSend) { const postal = await loadAddressSettings({ projectId: input.projectId, ownerId: auth.userId }); if (!postal.address) { @@ -440,6 +477,11 @@ export async function saveCampaignAction(input: { angle: input.angle?.trim() || null, sender_name: input.senderName?.trim() || null, reply_to: input.replyTo?.trim() || null, + pitch_mode: pitchMode, + pitch_intro: input.pitchIntro?.trim() || null, + pitch_ask: input.pitchAsk?.trim() || null, + pitch_facts: pitchFacts, + scan_prospects: scanProspects, }, { onConflict: "project_id,name" }, ); diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx index 98f5b22a..efd74a63 100644 --- a/components/leads/campaign-panel.tsx +++ b/components/leads/campaign-panel.tsx @@ -47,6 +47,11 @@ export function CampaignPanel({ const [maxScore, setMaxScore] = useState(70); const [dailyLimit, setDailyLimit] = useState(10); const [angle, setAngle] = useState(""); + const [pitchMode, setPitchMode] = useState<"audit" | "custom">("audit"); + const [pitchIntro, setPitchIntro] = useState(""); + const [pitchAsk, setPitchAsk] = useState(""); + const [pitchFacts, setPitchFacts] = useState(""); + const [scanProspects, setScanProspects] = useState(true); const [senderName, setSenderName] = useState(""); const [replyTo, setReplyTo] = useState(""); @@ -75,6 +80,11 @@ export function CampaignPanel({ angle, senderName, replyTo, + pitchMode, + pitchIntro, + pitchAsk, + pitchFacts, + scanProspects, }), ); @@ -215,6 +225,80 @@ export function CampaignPanel({ onChange={(e) => setDailyLimit(Number(e.target.value))} /> + + + {pitchMode === "custom" && ( + <> +