From c56686e2a90af186708d850fe30154c1d3017663 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 10:01:53 +0000 Subject: [PATCH] feat(leads): generate the pitch fields from a plain description of the goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The campaign form asks for three precisely-scoped inputs — who is writing, one small ask, and the checkable claims — and the grounding guard then refuses drafts that stray outside them. That is a lot to ask of someone who wants to describe what they are doing, and the failure it produces is silent: a live campaign had the same sentence pasted into both the intro and the ask, a facts list that omitted the URL the ask depended on, and five rejected drafts a tick for hours as a result. So the splitting is done here instead. Describe the goal in a sentence and the three fields are filled in. It restructures and does not author, which is the whole reason it can be trusted with this job. Drafts are checked against its output, so a fact invented here is not caught later — it is laundered into something a stranger is told is true. The prompt refuses new numbers, links, dates and credentials, and refuses to embellish a claim into a stronger one. Any URL the person wrote is forced into the facts afterwards rather than trusted to the model, because dropping one recreates precisely the failure this exists to prevent. The sender's own domain, when their goal mentions it, is read for how the site describes itself. That is the one extra fact available without guessing, and it is exactly what someone omits when describing their goal in a hurry. Also makes the two guard messages agree. One said a value was "not in the campaign's declared facts" while the other said the campaign "never mentions" it — and after the grounding set widened to include the intro and ask, the first was no longer true. Co-Authored-By: Claude Opus 5 (1M context) --- app/actions/leads.ts | 24 +++++ components/leads/campaign-panel.tsx | 56 ++++++++++++ lib/outreach/cold.ts | 4 +- lib/outreach/generatePitch.ts | 131 ++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 lib/outreach/generatePitch.ts diff --git a/app/actions/leads.ts b/app/actions/leads.ts index b3541f40..7f8d547d 100644 --- a/app/actions/leads.ts +++ b/app/actions/leads.ts @@ -16,6 +16,7 @@ import { addSuppression } from "@/lib/outreach/suppress"; import { loadAddressSettings } from "@/lib/outreach/postalAddress"; import { discoverProspects } from "@/lib/outreach/discover"; import { upsertContact } from "@/lib/outreach/contacts"; +import { generatePitch } from "@/lib/outreach/generatePitch"; import { runEmailCampaignTick, CAMPAIGN_COLUMNS, summarize, type CampaignRow } from "@/lib/outreach/runner"; type Ok> = { ok: true } & T; @@ -160,6 +161,29 @@ export async function findLeadsAction(input: { * only ever report sends, and a reply rate that is structurally zero is * worse than no reply rate at all. */ +/** + * Turn a rough goal into the three pitch fields. + * + * The form asks for precisely-scoped inputs and the grounding guard refuses + * drafts that stray outside them, which is a lot to ask of someone who just + * wants to describe what they are doing. This does the splitting, and + * deliberately does not author: a fact invented here is one the guard will + * pass straight through to a stranger, because drafts are checked against + * this output rather than against reality. + */ +export async function generatePitchAction(input: { + projectId: string; + goal: string; + senderName?: string; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + + const res = await generatePitch({ goal: input.goal, senderName: input.senderName }); + if (!res.ok) return { ok: false, error: res.error }; + return { ok: true, ...res.pitch }; +} + export async function markLeadOutcomeAction(input: { projectId: string; host: string; diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx index e3851403..5aeea23d 100644 --- a/components/leads/campaign-panel.tsx +++ b/components/leads/campaign-panel.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { + generatePitchAction, runCampaignAction, saveCampaignAction, toggleCampaignAction, @@ -81,6 +82,9 @@ export function CampaignPanel({ // scan of the prospect to contribute. Selecting the audit pitch turns it // back on, because that pitch is built from findings. const [scanProspects, setScanProspects] = useState(false); + const [goal, setGoal] = useState(""); + const [generating, setGenerating] = useState(false); + const [pitchNote, setPitchNote] = useState(null); const [senderName, setSenderName] = useState(""); const [replyTo, setReplyTo] = useState(""); @@ -95,6 +99,30 @@ export function CampaignPanel({ } else setError(res.error); }); + // Fills the three pitch fields from a plain description of the goal. + // Writing an intro, a single ask and a list of checkable claims is a + // precise task, and the grounding guard refuses drafts that stray outside + // them — which punishes anyone who describes their goal the way people + // actually describe goals. + const generate = () => + start(async () => { + setPitchNote(null); + setError(null); + setGenerating(true); + const res = await generatePitchAction({ projectId, goal, senderName }); + setGenerating(false); + if (!res.ok) { + setError(res.error); + return; + } + setPitchIntro(res.intro); + setPitchAsk(res.ask); + setPitchFacts(res.facts.join("\n")); + setPitchMode("custom"); + setScanProspects(false); + setPitchNote("Filled in below — read it before saving. Nothing was invented, but check it says what you meant."); + }); + // Runs one campaign-row action: marks that button busy, then reports the // outcome inline next to the campaign it belongs to. const rowAct = ( @@ -398,6 +426,34 @@ export function CampaignPanel({ {pitchMode === "custom" && ( <> +
+