ICP Pre-Qualification (Pre-Enrichment Filter)
lead_qualifyEvaluate and filter leads against Ideal Customer Profile using local signals to avoid external API costs; auto-disqualify rejected leads to conserve enrichment credits.
Instructions
Filter leads against your Ideal Customer Profile BEFORE spending enrichment credits. Uses only locally-available signals (email domain, job_title, country, industry hints, tech_stack) so nothing is charged to Hunter.io, HubSpot, Pipedrive, or any other external service. Set auto_disqualify=true to also update rejected leads to status="disqualified" with the reject reasons stored in custom_fields. If lead_ids is omitted, evaluates every lead currently in status="new". Pairs naturally with upstream platform-detection tools (e.g. Detecto's detect_platform) — run that first to populate company.tech_stack, then run lead_qualify with required_tech_stack=["shopify"] to drop wrong-platform leads before they cost a single API call. Returns qualified/rejected counts, per-lead reasons, and an estimated credit savings figure.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| lead_ids | No | Specific lead IDs to evaluate. If omitted, evaluates all leads with status="new". | |
| criteria | Yes | At least one criterion is required. All provided criteria must pass for a lead to qualify. | |
| auto_disqualify | No | If true, rejected leads have status set to "disqualified" and reasons stored in custom_fields. If false (default), just returns the evaluation without mutating storage. |
Implementation Reference
- src/services/qualify.ts:216-277 (handler)The main `qualifyLeads` function that executes the ICP pre-qualification logic. It evaluates leads against criteria, optionally auto-disqualifies rejected leads in storage, and returns a summary with cost savings estimate.
export async function qualifyLeads( options: QualifyOptions, store?: Storage, ): Promise<QualifySummary> { const s = store ?? defaultStorage; if (!options.criteria || Object.keys(options.criteria).length === 0) { throw new ValidationError('At least one qualification criterion must be provided.'); } let leads: Lead[]; if (options.lead_ids && options.lead_ids.length > 0) { leads = []; for (const id of options.lead_ids) { const lead = await s.getLeadById(id); if (!lead) { throw new NotFoundError('Lead', id); } leads.push(lead); } } else { const all = await s.getAllLeads(); leads = all.filter((l) => l.status === 'new'); } const results = leads.map((lead) => evaluateLead(lead, options.criteria)); let autoDisqualified = 0; if (options.auto_disqualify) { for (const r of results) { if (!r.qualified) { const lead = leads.find((l) => l.id === r.lead_id); const prevFields = lead?.custom_fields ?? {}; await s.updateLead(r.lead_id, { status: 'disqualified', custom_fields: { ...prevFields, disqualification_reason: r.reasons.join(' | '), disqualified_by: 'lead_qualify', disqualified_at: new Date().toISOString(), }, }); autoDisqualified++; } } } const qualifiedCount = results.filter((r) => r.qualified).length; const rejectedCount = results.length - qualifiedCount; return { evaluated: results.length, qualified: qualifiedCount, rejected: rejectedCount, auto_disqualified: autoDisqualified, results, cost_savings_estimate: { enrichment_calls_avoided: rejectedCount, note: `Each rejected lead would have consumed 1 Hunter.io credit (~$0.01) during enrichment. Rejected ${rejectedCount} leads saves ~$${(rejectedCount * 0.01).toFixed(2)}.`, }, }; } - src/services/qualify.ts:86-192 (handler)The `evaluateLead` function that applies ICP criteria (freemail, title keywords, country, industry, company size, domain allow/blocklist, tech stack) to a single lead, returning qualified status and reasons.
function evaluateLead(lead: Lead, criteria: QualificationCriteria): QualificationResult { const reasons: string[] = []; let qualified = true; const domain = extractDomain(lead.email); const isFreemail = domain ? FREEMAIL_DOMAINS.has(domain) : false; const industry = lead.company?.industry ?? null; const size = lead.company?.size ?? null; const country = lead.company?.country ?? null; const techStack = lead.company?.tech_stack ?? []; // Freemail filter if (criteria.reject_freemail && isFreemail) { qualified = false; reasons.push(`Rejected: freemail domain (${domain}) — not a business email.`); } // Title requirements if (criteria.required_title_keywords && criteria.required_title_keywords.length > 0) { const title = lead.job_title ?? ''; if (!includesAny(title, criteria.required_title_keywords)) { qualified = false; reasons.push(`Rejected: job_title "${title || '(empty)'}" missing any of [${criteria.required_title_keywords.join(', ')}].`); } } if (criteria.exclude_title_keywords && criteria.exclude_title_keywords.length > 0) { const title = lead.job_title ?? ''; if (includesAny(title, criteria.exclude_title_keywords)) { qualified = false; reasons.push(`Rejected: job_title contains excluded keyword.`); } } // Country filter if (criteria.target_countries && criteria.target_countries.length > 0) { const countryUpper = (country ?? '').toUpperCase(); const targets = criteria.target_countries.map((c) => c.toUpperCase()); if (!countryUpper || !targets.includes(countryUpper)) { qualified = false; reasons.push(`Rejected: country "${country ?? '(unknown)'}" not in target list [${targets.join(', ')}].`); } } // Industry filter if (criteria.target_industries && criteria.target_industries.length > 0) { if (!industry || !includesAny(industry, criteria.target_industries)) { qualified = false; reasons.push(`Rejected: industry "${industry ?? '(unknown)'}" not in target list.`); } } // Company size filter if (criteria.min_company_size && size) { const min = SIZE_ORDER[criteria.min_company_size]; const actual = SIZE_ORDER[size]; if (actual !== undefined && actual < min) { qualified = false; reasons.push(`Rejected: company size ${size} below minimum ${criteria.min_company_size}.`); } } // Domain allow/block if (criteria.domain_blocklist && domain) { const blocked = criteria.domain_blocklist.some((d) => domain === d.toLowerCase() || domain.endsWith(`.${d.toLowerCase()}`)); if (blocked) { qualified = false; reasons.push(`Rejected: domain ${domain} is blocklisted.`); } } if (criteria.domain_allowlist && criteria.domain_allowlist.length > 0) { const allowed = domain && criteria.domain_allowlist.some((d) => domain === d.toLowerCase() || domain.endsWith(`.${d.toLowerCase()}`)); if (!allowed) { qualified = false; reasons.push(`Rejected: domain ${domain ?? '(none)'} not in allowlist.`); } } // Tech stack requirement if (criteria.required_tech_stack && criteria.required_tech_stack.length > 0) { const stackLower = techStack.map((t) => t.toLowerCase()); const hasAny = criteria.required_tech_stack.some((t) => stackLower.includes(t.toLowerCase())); if (!hasAny) { qualified = false; reasons.push(`Rejected: tech_stack [${techStack.join(', ') || '(empty)'}] missing any of required [${criteria.required_tech_stack.join(', ')}]. Consider chaining with a platform-detection tool if tech_stack is empty.`); } } if (qualified && reasons.length === 0) { reasons.push('Passed: all configured ICP criteria matched.'); } return { lead_id: lead.id, email: lead.email, qualified, reasons, signals: { is_freemail: isFreemail, domain, industry_hint: industry, company_size: size, country, }, }; } - src/services/qualify.ts:24-44 (schema)The `QualificationCriteria` interface defining all available ICP filter fields (reject_freemail, required/exclude_title_keywords, target_countries, target_industries, min_company_size, domain allow/blocklist, required_tech_stack).
export interface QualificationCriteria { /** Reject any lead whose email uses a freemail provider (gmail, yahoo, outlook, etc.). */ reject_freemail?: boolean; /** Case-insensitive substrings a lead's job_title must contain (any match passes). */ required_title_keywords?: string[]; /** Case-insensitive substrings that disqualify if present in job_title. */ exclude_title_keywords?: string[]; /** ISO 3166-1 alpha-2 country codes the lead's company_country must match. */ target_countries?: string[]; /** Industries the company must belong to (case-insensitive substring match). */ target_industries?: string[]; /** Minimum company size tier. Leads below this bucket are rejected. */ min_company_size?: '1-10' | '11-50' | '51-200' | '201-500' | '501-1000' | '1001-5000' | '5000+'; /** Explicit domain allowlist — reject anything not matching (full domain or suffix). */ domain_allowlist?: string[]; /** Explicit domain blocklist — reject anything matching. */ domain_blocklist?: string[]; /** Tech-stack hints the lead's company.tech_stack must contain (any match passes). * Useful when chained after a platform-detection tool like Detecto. */ required_tech_stack?: string[]; } - src/index.ts:74-84 (schema)The Zod schema `QualificationCriteriaSchema` used for input validation of the lead_qualify tool, mirroring the QualificationCriteria interface.
const QualificationCriteriaSchema = z.object({ reject_freemail: z.boolean().optional().describe('Reject gmail/yahoo/outlook/etc. — non-business emails.'), required_title_keywords: z.array(z.string()).optional().describe('Case-insensitive substrings a lead\'s job_title must contain (any match passes). E.g. ["vp", "director", "head"].'), exclude_title_keywords: z.array(z.string()).optional().describe('Case-insensitive substrings that disqualify if present in job_title.'), target_countries: z.array(z.string()).optional().describe('ISO 3166-1 alpha-2 country codes to target. E.g. ["US", "CA", "GB"].'), target_industries: z.array(z.string()).optional().describe('Industries the company must belong to (case-insensitive). E.g. ["saas", "fintech"].'), min_company_size: z.enum(['1-10', '11-50', '51-200', '201-500', '501-1000', '1001-5000', '5000+']).optional().describe('Reject leads below this company-size tier.'), domain_allowlist: z.array(z.string()).optional().describe('Only accept these domains (full domain or suffix match). E.g. ["acme.com", "stripe.com"].'), domain_blocklist: z.array(z.string()).optional().describe('Reject these domains. E.g. ["competitor.com"].'), required_tech_stack: z.array(z.string()).optional().describe('Tech-stack tokens the company.tech_stack must include. Useful when chained after a platform-detection tool (e.g. Detecto). E.g. ["shopify", "stripe"].'), }); - src/index.ts:86-125 (registration)Registration of the `lead_qualify` tool via `server.registerTool()`, wiring the handler that calls `qualifyLeads()`, with pro license gating.
server.registerTool( 'lead_qualify', { title: 'ICP Pre-Qualification (Pre-Enrichment Filter)', description: 'Filter leads against your Ideal Customer Profile BEFORE spending enrichment credits. Uses only locally-available signals (email domain, job_title, country, industry hints, tech_stack) so nothing is charged to Hunter.io, HubSpot, Pipedrive, or any other external service. Set auto_disqualify=true to also update rejected leads to status="disqualified" with the reject reasons stored in custom_fields. If lead_ids is omitted, evaluates every lead currently in status="new". Pairs naturally with upstream platform-detection tools (e.g. Detecto\'s detect_platform) — run that first to populate company.tech_stack, then run lead_qualify with required_tech_stack=["shopify"] to drop wrong-platform leads before they cost a single API call. Returns qualified/rejected counts, per-lead reasons, and an estimated credit savings figure.', inputSchema: z.object({ lead_ids: z.array(z.string().uuid()).optional().describe('Specific lead IDs to evaluate. If omitted, evaluates all leads with status="new".'), criteria: QualificationCriteriaSchema.describe('At least one criterion is required. All provided criteria must pass for a lead to qualify.'), auto_disqualify: z.boolean().default(false).describe('If true, rejected leads have status set to "disqualified" and reasons stored in custom_fields. If false (default), just returns the evaluation without mutating storage.'), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, async ({ lead_ids, criteria, auto_disqualify }) => { try { const reject = await ensureProOrReject(LICENSE_CONFIG, 'lead_qualify'); if (reject) return reject; const summary = await qualifyLeads({ lead_ids, criteria: criteria as QualificationCriteria, auto_disqualify, }); const lines = [ `ICP qualification run:`, ` Evaluated: ${summary.evaluated}`, ` Qualified: ${summary.qualified}`, ` Rejected: ${summary.rejected}`, ` Auto-disqualified in storage: ${summary.auto_disqualified}`, ``, `Cost savings: ${summary.cost_savings_estimate.note}`, ]; return { content: [{ type: 'text' as const, text: lines.join('\n') }], structuredContent: summary as unknown as Record<string, unknown>, }; } catch (error) { return handleToolError(error); } } );