leads.find
Find ranked social posts on Reddit, X, YouTube, and TikTok where users describe problems your SaaS solves. AI scores leads and provides outreach hints for your first 100 users.
Instructions
Find ranked social posts where people are describing the problem the user's SaaS solves, across Reddit, X, YouTube, and TikTok. Behavior: dispatches the full server-side pipeline (theme expansion, parallel platform search, AI scoring), persists a run row, blocks until the run completes (typically 60 to 120 seconds), and returns the scored leads. Consumes one credit on the user's plan. Idempotent only via the resulting run_id (use runs.get to re-read without spending another credit). Usage: call this when the user wants the full lead hunt for an idea. Do NOT call it twice for the same idea in the same session, use runs.get to re-analyse. Pair with idea.refine first if the idea is one or two words. After it returns, hand the run_id to outreach.plan for a Week-1 outreach plan and to outreach.draft for per-lead messages. Returns: scored leads (source, channel, title, url, lead_score 0-1, matched_signals including category and outreach hints), plus a header line with totals per source.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| idea | Yes | The app idea or product description to find leads for |
Implementation Reference
- src/index.ts:265-294 (registration)The tool 'leads.find' is registered using server.tool() with the name 'leads.find', schema (idea: string), and handler callback.
server.tool( "leads.find", "Find ranked social posts where people are describing the problem the user's SaaS solves, across Reddit, X, YouTube, and TikTok. Behavior: dispatches the full server-side pipeline (theme expansion, parallel platform search, AI scoring), persists a run row, blocks until the run completes (typically 60 to 120 seconds), and returns the scored leads. Consumes one credit on the user's plan. Idempotent only via the resulting run_id (use runs.get to re-read without spending another credit). Usage: call this when the user wants the full lead hunt for an idea. Do NOT call it twice for the same idea in the same session, use runs.get to re-analyse. Pair with idea.refine first if the idea is one or two words. After it returns, hand the run_id to outreach.plan for a Week-1 outreach plan and to outreach.draft for per-lead messages. Returns: scored leads (source, channel, title, url, lead_score 0-1, matched_signals including category and outreach hints), plus a header line with totals per source.", { idea: z .string() .describe("The app idea or product description to find leads for"), }, { title: "Find leads", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, async ({ idea }) => { const err = requireKey(); if (err) return err; const { run_id } = await call<{ run_id: string }>("POST", "run-pipeline", { idea, }); const result = await waitForCompletion(run_id); return { content: [{ type: "text" as const, text: formatResults(result) }], }; } ); - src/index.ts:280-293 (handler)The handler function for 'leads.find'. It validates the API key via requireKey(), calls the backend endpoint 'POST /run-pipeline' with the idea, waits for completion by polling 'GET /get-run', and formats the results.
async ({ idea }) => { const err = requireKey(); if (err) return err; const { run_id } = await call<{ run_id: string }>("POST", "run-pipeline", { idea, }); const result = await waitForCompletion(run_id); return { content: [{ type: "text" as const, text: formatResults(result) }], }; } - src/index.ts:119-148 (schema)TypeScript interfaces for the data types used by leads.find: Post (individual lead result with lead_score, matched_signals, etc.), RunResult (the full pipeline output), and ThemeExpansion.
interface Post { source: string; channel: { name: string }; id: string; title: string; url: string; body_snippet: string; score: number; num_comments: number; created_utc: number; lead_score: number; validation_score: number; matched_signals: string[]; metadata: Record<string, unknown>; } interface RunResult { run_id: string; status: "running" | "completed" | "failed" | "partial"; idea: string; results: Post[]; metadata: { total_posts: number; elapsed_ms: number; errors: string[]; expansion?: Record<string, unknown>; product_title?: string | null; }; steps?: Record<string, { status: string; message: string }>; } - src/index.ts:199-221 (helper)formats the RunResult into a human-readable string for the MCP response, with lead source breakdown and top 50 leads.
function formatResults(result: RunResult): string { const posts = result.results; if (posts.length === 0) { return `Run ${result.run_id} completed but found no leads.`; } const sorted = [...posts].sort((a, b) => b.lead_score - a.lead_score); const sourceCounts = new Map<string, number>(); for (const p of sorted) { sourceCounts.set(p.source, (sourceCounts.get(p.source) ?? 0) + 1); } const breakdown = [...sourceCounts.entries()] .map(([s, n]) => `${n} ${s}`) .join(", "); const high = sorted.filter((p) => p.lead_score >= 0.7).length; const header = `Found ${sorted.length} leads (${breakdown}). ${high} high-intent.`; const body = sorted.slice(0, 50).map(formatPost).join("\n\n"); return `${header}\n\n${body}${sorted.length > 50 ? `\n\n... and ${sorted.length - 50} more` : ""}`; } - src/index.ts:227-234 (helper)Polls the backend (GET /get-run) until the run completes or times out after ~5 minutes.
async function waitForCompletion(runId: string): Promise<RunResult> { for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) { const result = await call<RunResult>("GET", `get-run?id=${runId}`); if (result.status !== "running") return result; await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } throw new Error(`Run ${runId} did not complete within ${(MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS) / 1000}s`); }