Enrich Lead
lead_enrichEnrich a lead with company name, industry, size, country, website, headcount, and tech stack using the email domain. Updates the lead in place for accurate scoring.
Instructions
Derive and attach company data to an existing lead using the email domain: company name, industry, size, country, website, estimated headcount, and common tech stack. Does not call external APIs — enrichment is driven by the built-in domain knowledge base. Updates the lead in place and returns the enriched record, ready for lead_score. Run this before lead_score for the best qualification accuracy.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| lead_id | Yes | UUID of the lead to enrich (returned by lead_ingest or lead_search) |
Implementation Reference
- src/services/enrichment.ts:192-245 (handler)Main enrichment logic: extracts domain from email, checks freemail, fetches company info via Hunter.io API or domain heuristics (curated INDUSTRY_MAP, TLD hints, name keyword matching), estimates company size, and merges with existing lead data (user data wins). Updates lead status to 'enriched' and stores enriched_at timestamp.
/** * Enrich an existing lead with company data. * * Merge rules (user data is sacred): * - Any field the user already set on the lead (name, industry, etc.) is kept. * - Enrichment only fills fields that are undefined on the existing lead. * - Every field from the CompanyInfo schema is explicitly set to `null` * when unknown, so the output shape is stable and discoverable. */ export async function enrichLead(leadId: string, store?: Storage): Promise<Lead> { if (!RE_UUID.test(leadId)) throw new ValidationError(`Invalid lead ID format: ${leadId}`); const s = store ?? defaultStorage; const lead = await s.getLeadById(leadId); if (!lead) throw new NotFoundError('Lead', leadId); const domain = extractDomain(lead.email); const isFreemail = isFreemailDomain(domain); const existing = lead.company; let fetched: CompanyInfo; if (isFreemail) { fetched = { domain: existing?.domain }; } else { fetched = await fetchCompanyFromDomain(domain, existing); const estimatedSize = estimateCompanySize(domain); if (estimatedSize && !fetched.size && !existing?.size) { fetched.size = estimatedSize; } } // Merge: existing user-provided data wins over heuristic fallbacks const pick = <T>(userVal: T | undefined, heuristic: T | undefined): T | undefined => userVal !== undefined ? userVal : heuristic; const merged: CompanyInfo = { name: pick(existing?.name, fetched.name), domain: pick(existing?.domain, fetched.domain ?? (domain && !isFreemail ? domain : undefined)), industry: pick(existing?.industry, fetched.industry), size: pick(existing?.size, fetched.size), country: pick(existing?.country, fetched.country), description: pick(existing?.description, fetched.description), linkedin_url: pick(existing?.linkedin_url, fetched.linkedin_url), tech_stack: pick(existing?.tech_stack, fetched.tech_stack), }; const updated = await s.updateLead(leadId, { company: merged, status: lead.status === 'new' ? 'enriched' : lead.status, enriched_at: new Date().toISOString(), }); return updated!; } - src/services/enrichment.ts:122-172 (helper)Fetches company info from Hunter.io API (if HUNTER_API_KEY env var is set) or falls back to domain heuristics including curated INDUSTRY_MAP, TLD hints, and name keyword matching.
/** Fetch company info from external API (Hunter.io) or fall back to domain heuristics. */ async function fetchCompanyFromDomain( domain: string, existing: CompanyInfo | undefined, ): Promise<CompanyInfo> { const info: CompanyInfo = { domain }; // Try Hunter.io if API key is set const hunterKey = process.env.HUNTER_API_KEY; if (hunterKey) { try { const url = `https://api.hunter.io/v2/domain-search?domain=${encodeURIComponent(domain)}&api_key=${hunterKey}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const res = await fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer)); if (res.ok) { const data = await res.json() as any; const d = data?.data; if (d) { info.name = d.organization ?? undefined; info.industry = d.industry ?? undefined; info.country = d.country ?? undefined; info.description = d.description ?? undefined; info.linkedin_url = d.linkedin ?? undefined; if (d.technologies && Array.isArray(d.technologies)) { info.tech_stack = d.technologies; } } } } catch { // Hunter.io failed — continue with fallback } } // Only set a fallback name if the user did NOT already provide one. // This preserves user-provided casing and spelling (e.g. "VelocityAI" vs "Velocityai"). if (!existing?.name && !info.name) { const parts = domain.split('.'); info.name = parts[0].charAt(0).toUpperCase() + parts[0].slice(1); } // Industry resolution: curated map → TLD heuristic → name keyword heuristic if (!info.industry) { info.industry = INDUSTRY_MAP[domain] ?? guessIndustry(domain, existing?.name ?? info.name) ?? undefined; } return info; } - src/index.ts:179-209 (registration)Registers the 'lead_enrich' tool with the MCP server. Input schema requires a single lead_id (UUID). The handler calls ensureProOrReject for license enforcement, then delegates to enrichLead from the enrichment service.
// ━━━ TOOL: lead_enrich ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ server.registerTool( 'lead_enrich', { title: 'Enrich Lead', description: 'Derive and attach company data to an existing lead using the email domain: company name, industry, size, country, website, estimated headcount, and common tech stack. Does not call external APIs — enrichment is driven by the built-in domain knowledge base. Updates the lead in place and returns the enriched record, ready for lead_score. Run this before lead_score for the best qualification accuracy.', inputSchema: z.object({ lead_id: z.string().uuid().describe('UUID of the lead to enrich (returned by lead_ingest or lead_search)'), }), annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, }, async ({ lead_id }) => { try { const reject = await ensureProOrReject(LICENSE_CONFIG, 'lead_enrich'); if (reject) return reject; const lead = await enrichLead(lead_id); return { content: [ { type: 'text' as const, text: `Lead enriched: ${lead.email} — Company: ${lead.company?.name ?? 'unknown'}, Industry: ${lead.company?.industry ?? 'unknown'}, Size: ${lead.company?.size ?? 'unknown'}`, }, ], structuredContent: lead, }; } catch (error) { return handleToolError(error); } } ); - src/models/lead.ts:39-50 (schema)CompanyInfo schema defines the shape of enrichment data: name, domain, industry, size, country, description, linkedin_url, tech_stack. This is the output structure populated by lead_enrich.
/** Information about a company. */ export const CompanyInfoSchema = z.object({ name: z.string().optional(), domain: z.string().optional(), industry: z.string().optional(), size: CompanySizeSchema.optional(), country: z.string().optional(), description: z.string().optional(), linkedin_url: z.string().optional(), tech_stack: z.array(z.string()).optional(), }); export type CompanyInfo = z.infer<typeof CompanyInfoSchema>; - src/services/enrichment.ts:174-190 (helper)Estimates company size based on domain name, returning size brackets like '5000+', '1001-5000', '201-500', or default '51-200' for unknown domains.
/** Estimate company size from domain. Returns a default midmarket bracket for unknown domains. */ function estimateCompanySize(domain: string): CompanyInfo['size'] { // Well-known large companies const large = ['google.com', 'microsoft.com', 'apple.com', 'amazon.com', 'meta.com', 'salesforce.com']; if (large.includes(domain)) return '5000+'; const midLarge = ['stripe.com', 'shopify.com', 'hubspot.com', 'atlassian.com', 'twilio.com', 'databricks.com', 'snowflake.com']; if (midLarge.includes(domain)) return '1001-5000'; const mid = ['vercel.com', 'notion.so', 'linear.app', 'figma.com', 'anthropic.com']; if (mid.includes(domain)) return '201-500'; // Unknown domain — assume small-to-mid market (51-200). This is the default // bracket for "unknown small company" and lets scoring treat it as neutral // rather than penalizing it to zero. return '51-200'; }