outreach.plan
Turn high-intent leads into a structured Week-1 outreach plan with per-channel daily send targets and per-category action register. Use after leads.find to get actionable steps.
Instructions
Build a Week-1 outreach plan from a completed run's HIGH-intent leads, with per-channel send cadence and per-category action register. Behavior: client-side synthesis. Fetches the run via runs.get (no extra credit), buckets HIGH leads (lead_score >= 0.7) by source and matched_signals category, then applies fixed cadence heuristics (Reddit / X tolerate 3-4 sends/day; YouTube / TikTok / Instagram only 2 because each comment is more visible). Idempotent and free. Usage: call this immediately after leads.find completes if the user wants a concrete action plan rather than a raw lead dump. Skip it if HIGH lead count is under 5 (the heuristic falls apart on tiny pools, refine the idea and re-run instead). Do NOT call this on a still-running run, results will be incomplete. Returns: a multi-line text plan with the HIGH/MED/total breakdown, per-channel daily send target + follow-up window, per-category action register (ACTIVE_SEARCH, PAIN_OR_FRUSTRATION, SWITCHING, COMPARISON, FEATURE_GAP, COMPETITOR, TUTORIAL, DISCUSSION), and an end-of-week deprioritisation rule.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| run_id | Yes | The run_id returned by leads.find |
Implementation Reference
- src/index.ts:729-731 (registration)Registration of the 'outreach.plan' tool on the MCP server via server.tool(), with description, schema (run_id), and metadata hints.
server.tool( "outreach.plan", "Build a Week-1 outreach plan from a completed run's HIGH-intent leads, with per-channel send cadence and per-category action register. Behavior: client-side synthesis. Fetches the run via runs.get (no extra credit), buckets HIGH leads (lead_score >= 0.7) by source and matched_signals category, then applies fixed cadence heuristics (Reddit / X tolerate 3-4 sends/day; YouTube / TikTok / Instagram only 2 because each comment is more visible). Idempotent and free. Usage: call this immediately after leads.find completes if the user wants a concrete action plan rather than a raw lead dump. Skip it if HIGH lead count is under 5 (the heuristic falls apart on tiny pools, refine the idea and re-run instead). Do NOT call this on a still-running run, results will be incomplete. Returns: a multi-line text plan with the HIGH/MED/total breakdown, per-channel daily send target + follow-up window, per-category action register (ACTIVE_SEARCH, PAIN_OR_FRUSTRATION, SWITCHING, COMPARISON, FEATURE_GAP, COMPETITOR, TUTORIAL, DISCUSSION), and an end-of-week deprioritisation rule.", - src/index.ts:742-838 (handler)Handler function for outreach.plan: validates API key, fetches run results, filters HIGH (>=0.7) and MED leads, groups by source and category, applies per-channel cadence heuristics, builds and returns a text plan with send targets and action register.
async ({ run_id }) => { const err = requireKey(); if (err) return err; const result = await call<RunResult>("GET", `get-run?id=${run_id}`); const posts = result.results; if (posts.length === 0) { return { content: [ { type: "text" as const, text: `Run ${run_id} returned no leads. Re-run with a sharper idea before planning a funnel.`, }, ], }; } const high = posts.filter((p) => p.lead_score >= 0.7); const med = posts.filter((p) => p.lead_score >= 0.4 && p.lead_score < 0.7); // Group HIGH by source and category const bySource = new Map<string, Post[]>(); const byCategory = new Map<string, number>(); for (const p of high) { const arr = bySource.get(p.source) ?? []; arr.push(p); bySource.set(p.source, arr); const cat = p.matched_signals.find((s) => s.startsWith("category:"))?.slice(9) ?? "OTHER"; byCategory.set(cat, (byCategory.get(cat) ?? 0) + 1); } // Per-channel cadence heuristic. Reddit + X tolerate higher daily volume than // YouTube/TikTok/Instagram, where each comment is more visible to the creator. const VOLUME = { reddit: { perDay: 3, followUpDays: 5 }, twitter: { perDay: 4, followUpDays: 3 }, x: { perDay: 4, followUpDays: 3 }, youtube: { perDay: 2, followUpDays: 7 }, tiktok: { perDay: 2, followUpDays: 7 }, instagram: { perDay: 2, followUpDays: 7 }, } as Record<string, { perDay: number; followUpDays: number }>; const ACTION_BY_CATEGORY: Record<string, string> = { ACTIVE_SEARCH: "Direct answer. They're asking, you have it. Reply within 24h.", PAIN_OR_FRUSTRATION: "Empathy first, link second. Acknowledge the pain in your own words before mentioning the product.", SWITCHING: "Position as the next step, not 'a' next step. Reference what they said they're leaving.", COMPARISON: "Insert as the third option in their list. One concrete tradeoff vs each they named.", FEATURE_GAP: "Lead with the missing feature. Skip the rest of the pitch.", COMPETITOR: "INTEL ONLY. Do not DM. Use to understand positioning.", TUTORIAL: "Skip for outreach. Use for keyword research and content ideas.", DISCUSSION: "Comment publicly, not via DM. Lower-effort, lower-conversion.", }; const channelLines: string[] = []; let totalWeeklySends = 0; for (const [source, leads] of bySource.entries()) { const v = VOLUME[source] ?? { perDay: 2, followUpDays: 5 }; const weekly = Math.min(leads.length, v.perDay * 7); totalWeeklySends += weekly; channelLines.push( ` ${source}: ${leads.length} HIGH leads. Send ${v.perDay}/day (${weekly} this week). Follow up after ${v.followUpDays} days if no reply.`, ); } const categoryLines: string[] = []; for (const [cat, n] of [...byCategory.entries()].sort((a, b) => b[1] - a[1])) { categoryLines.push(` ${cat} (${n}): ${ACTION_BY_CATEGORY[cat] ?? "Use the per-lead opener."}`); } const text = [ `Acquisition funnel for run ${run_id}`, ``, `Pool: ${high.length} HIGH, ${med.length} MED, ${posts.length} total.`, ``, `Per-channel cadence (Week 1):`, ...channelLines, ``, `Total Week 1 send target: ${totalWeeklySends} messages.`, ``, `Action per category:`, ...categoryLines, ``, `Workflow:`, ` 1. Triage HIGH leads. Drop the COMPETITOR / TUTORIAL ones.`, ` 2. For each remaining lead, call outreach.draft with the right source + outreach_action.`, ` 3. Hand-edit each draft for one specific detail from the post.`, ` 4. Send. Log it. Move on.`, ` 5. After 7 days, follow up only on the channels listed above. One follow-up max.`, ``, `If conversion is below 5% after 50 sends on a channel, deprioritise that channel and re-allocate to whichever is converting.`, ].join("\n"); return { content: [{ type: "text" as const, text }], }; }, - src/index.ts:732-734 (schema)Input schema for outreach.plan: expects a single 'run_id' string parameter validated with Zod.
{ run_id: z.string().describe("The run_id returned by leads.find"), }, - src/index.ts:91-113 (helper)Generic HTTP client (call<T>) used by the handler to fetch run results from the API.
async function call<T>( method: "GET" | "POST" | "DELETE", endpoint: string, body?: unknown ): Promise<T> { const cfg = await getConfig(); const res = await fetch(`${cfg.api_base}/${endpoint}`, { method, headers: { "Content-Type": "application/json", "x-api-key": GORILLA_API_KEY, apikey: cfg.gateway_key, }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`${method} /${endpoint} failed (${res.status}): ${text}`); } return res.json() as Promise<T>; } - src/index.ts:240-252 (helper)requireKey() helper used by the handler to check GORILLA_API_KEY is set.
function requireKey() { if (!GORILLA_API_KEY) { return { content: [ { type: "text" as const, text: "GORILLA_API_KEY is not set. Sign up at https://usegorilla.app, then create a key at gorilla.opusforge.com.br > Menu > API Keys and set GORILLA_API_KEY in your environment.", }, ], }; } return null; }