From 204109199dde3d78ed70a157b5a7f5a63107d172 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Tue, 28 Jul 2026 16:20:59 +0000
Subject: [PATCH] feat(leads): qualify on public buying intent, not resemblance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Feedback from someone who had already built a business on this premise:
the expensive part of cold outreach is not assembling a list, it is that
writing to people who have shown no intent converts badly however good
the copy is. Firmographic targeting does not fix that — it selects for
people who look like buyers rather than people who are buying.
So a campaign can now require evidence that somebody actually asked, and
those leads rank ahead of everything found by resemblance.
Intent is scored as recency times explicitness. A named budget outranks a
request for suggestions, which outranks a bare complaint, and someone
leaving their current supplier ranks near the top because the budget
already exists and the decision to spend it has been made. All of it
decays with a half-life rather than falling off a cliff at a threshold: a
25-hour-old post is not meaningfully worse than a 23-hour-old one.
Not only Reddit. The same sentence turns up on Hacker News, Stack
Exchange, Quora, Indie Hackers, X, LinkedIn and contract boards, and a
pipeline watching one community inherits its demographics as the entire
market. Everything except Reddit is read through the search index rather
than a platform API — no per-platform auth, rate limits or review, at the
cost of an indexing lag that makes hours-old intent reliable and
minutes-old intent occasional.
Three things the scorer refuses. Intent on somebody else's topic: a
flawless "anyone recommend an accountant, happy to pay" is worth nothing
to a load-testing company. Anyone who wrote "no vendors" or "don't DM
me", whatever else they scored — answering them is the fastest way to
lose a channel for everybody. And job ads, which read exactly like budget
and are not.
Signals are stored as signals rather than prospects. A prospect is a
company with a domain you can email; this is a person on a platform. My
first pass filed them as prospects and gave every Reddit thread the
target key "reddit.com", collapsing the queue into a single lead.
Off unless switched on, so no running campaign is silently emptied by a
feature it never opted into.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/(app)/projects/[id]/leads/page.tsx | 16 +-
app/(marketing)/lead-generation/page.tsx | 39 ++-
app/actions/leads.ts | 12 +
components/leads/campaign-panel.tsx | 42 +++
components/leads/intent-panel.tsx | 79 ++++++
lib/outreach/intent.ts | 266 ++++++++++++++++++
lib/outreach/intentSources.ts | 254 +++++++++++++++++
lib/outreach/runner.ts | 61 +++-
.../20260729060000_intent_qualification.sql | 84 ++++++
tests/intent.test.ts | 183 ++++++++++++
10 files changed, 1033 insertions(+), 3 deletions(-)
create mode 100644 components/leads/intent-panel.tsx
create mode 100644 lib/outreach/intent.ts
create mode 100644 lib/outreach/intentSources.ts
create mode 100644 supabase/migrations/20260729060000_intent_qualification.sql
create mode 100644 tests/intent.test.ts
diff --git a/app/(app)/projects/[id]/leads/page.tsx b/app/(app)/projects/[id]/leads/page.tsx
index 0469689a..26130eec 100644
--- a/app/(app)/projects/[id]/leads/page.tsx
+++ b/app/(app)/projects/[id]/leads/page.tsx
@@ -12,6 +12,7 @@ import { SeedLogins } from "@/components/leads/seed-logins";
import { FunnelPanel } from "@/components/leads/funnel-panel";
import { RepliesPanel, type ReplyRow } from "@/components/leads/replies-panel";
import { ContactsPanel } from "@/components/leads/contacts-panel";
+import { IntentPanel, type IntentSignalRow } from "@/components/leads/intent-panel";
import { contactNiches } from "@/lib/outreach/contactsExport";
import { campaignFunnels, projectFunnel } from "@/lib/outreach/funnel";
import { listSeedCredentials, type StoredSeedCredential } from "@/lib/outreach/seedCredentials";
@@ -91,7 +92,7 @@ export default async function LeadsPage({
supabase
.from("outreach_campaigns")
.select(
- "id, name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note, auth_required_hosts, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects, angle, sender_name, reply_to",
+ "id, name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note, auth_required_hosts, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects, min_intent, angle, sender_name, reply_to",
)
.eq("project_id", projectId)
.order("updated_at", { ascending: false })
@@ -180,6 +181,17 @@ export default async function LeadsPage({
.limit(20);
const replies = (replyData as ReplyRow[] | null) ?? [];
+ // Public requests to buy, strongest and freshest first. A separate queue
+ // from the pipeline: these are people on a platform, not domains to email.
+ const { data: intentData } = await supabase
+ .from("outreach_intent_signals")
+ .select("source, url, title, snippet, score, tier, reasons, posted_at, status")
+ .eq("project_id", projectId)
+ .neq("status", "dismissed")
+ .order("score", { ascending: false })
+ .limit(25);
+ const intentSignals = (intentData as IntentSignalRow[] | null) ?? [];
+
let seedCredentials: StoredSeedCredential[] = [];
if (orgId) seedCredentials = await listSeedCredentials(orgId);
@@ -239,6 +251,8 @@ export default async function LeadsPage({
+
+
diff --git a/app/(marketing)/lead-generation/page.tsx b/app/(marketing)/lead-generation/page.tsx
index bc561970..71416dd7 100644
--- a/app/(marketing)/lead-generation/page.tsx
+++ b/app/(marketing)/lead-generation/page.tsx
@@ -5,7 +5,7 @@ import { LEADS_PER_CHARGE } from "@/lib/outreach/billing";
export const metadata = {
title: "Lead generation & cold outreach",
description:
- "Point CrawlProof at a directory or a search, and it finds the companies, finds the people, finds the addresses, writes the email, and reads the replies — from your own mailbox, grounded in facts you supply. 3 credits a run.",
+ "Watch for people publicly asking to buy what you sell, and work them first. Then find the companies, the people and the addresses, write the email and read the replies — from your own mailbox, grounded in facts you supply.",
alternates: { canonical: "/lead-generation" },
openGraph: {
title: "Lead generation & cold outreach · CrawlProof",
@@ -85,6 +85,43 @@ export default function LeadGenerationPage() {
+
+
Start with people who already asked
+
+ The hard part of cold outreach is not building the list. It is that writing to people who
+ have shown no intent converts badly however good the email is, and targeting by
+ resemblance — right industry, right headcount, right tech stack — does not fix that. It
+ selects for people who look like buyers, not people who are buying.
+
+
+ So a campaign can watch for the opposite signal: somebody saying, in public and recently,
+ that they want to buy what you sell. Those leads are ranked ahead of everything found by
+ resemblance, because they are a different kind of lead rather than a better-matched one.
+
+
+
+
+
+
+
+
+ Reply where they asked, in public, as a reply — these are people on a platform, not
+ addresses to mail. The email pipeline below is for the companies you go and find.
+
+
+
It reads the directory like a person would
diff --git a/app/actions/leads.ts b/app/actions/leads.ts
index 7a31e350..9ab0aa0e 100644
--- a/app/actions/leads.ts
+++ b/app/actions/leads.ts
@@ -505,6 +505,12 @@ export async function saveCampaignAction(input: {
pitchAsk?: string;
pitchFacts?: string;
scanProspects?: boolean;
+ /**
+ * Minimum public buying intent a lead must show, 0-100. Null or absent
+ * leaves the campaign selecting on resemblance alone, which is what every
+ * campaign did before intent existed.
+ */
+ minIntent?: number | null;
}): Promise | Err> {
const auth = await requireLeadAccess(input.projectId);
if (!auth.ok) return auth;
@@ -572,6 +578,12 @@ export async function saveCampaignAction(input: {
max_score: input.maxScore,
daily_send_limit: input.dailySendLimit,
auto_send: input.autoSend,
+ // Clamped rather than rejected: a bar above 100 silently matches
+ // nothing, which looks like the sweep being broken.
+ min_intent:
+ input.minIntent === null || input.minIntent === undefined
+ ? null
+ : Math.max(0, Math.min(100, Math.round(input.minIntent))),
angle: input.angle?.trim() || null,
sender_name: input.senderName?.trim() || null,
reply_to: input.replyTo?.trim() || null,
diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx
index 5e411ed9..774cace4 100644
--- a/components/leads/campaign-panel.tsx
+++ b/components/leads/campaign-panel.tsx
@@ -35,6 +35,7 @@ export type CampaignSummary = {
pitch_ask: string | null;
pitch_facts: string[];
scan_prospects: boolean;
+ min_intent: number | null;
auth_required_hosts: string[];
runs: CampaignRun[];
};
@@ -83,6 +84,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);
+ // Null means "don't qualify on intent" — the behaviour every campaign had
+ // before this existed, and the default for anything already running.
+ const [minIntent, setMinIntent] = useState(null);
const [goal, setGoal] = useState("");
const [generating, setGenerating] = useState(false);
const [pitchNote, setPitchNote] = useState(null);
@@ -159,6 +163,7 @@ export function CampaignPanel({
setPitchAsk(c.pitch_ask ?? "");
setPitchFacts((c.pitch_facts ?? []).join("\n"));
setScanProspects(c.scan_prospects);
+ setMinIntent(c.min_intent ?? null);
// Every field the form submits has to be loaded, not just the ones that
// changed recently: save() sends the whole form, so anything left blank
// here is written back as blank and silently wipes the column.
@@ -191,6 +196,7 @@ export function CampaignPanel({
pitchAsk,
pitchFacts,
scanProspects,
+ minIntent,
});
if (result.ok) setEditing(null);
return result;
@@ -492,6 +498,42 @@ export function CampaignPanel({
>
)}
+
+
+ {minIntent !== null && (
+
+ )}
+