crustdata/skills

email-enrichment

Enrich a list of email addresses to find the person behind each one (email to person profile). Uses a six-phase waterfall over the Crustdata Code Mode MCP, calling these tools inside execute() (name+company and name-only strategies), web_search_live, web_enrich_live. Handles verification, progress saving, and resume. Trigger on: "enrich these emails", "who are these people", "find info for these email addresses", "look up these contacts", "identify people from emails", "reverse email lookup", "…

First seen Jun 3, 2026

Installation

$ npx skills add crustdata/skills --skill email-enrichment

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from crustdata/skills.

npx skills add crustdata/skills

Browse all from crustdata/skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 7
License LICENSE
Default branch main
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version0.1.1

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 43,566 B
  • docs README.md 4,190 B
  • docs SUMMARY.md 727 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 438 installs

SKILL.md

Email Enrichment

Two directions, one skill:

  1. Email to person - Turn a list of email addresses into rich contact profiles (name, title, company, profile URL). Uses a six-phase waterfall optimized for coverage and accuracy.
  2. Person to email - Find business emails, personal emails, and phone numbers for a list of people. Uses enrichment with personal contact info, plus GitHub commit fallbacks for technical people.

Overview

The approach uses six phases in a strict waterfall. Each phase catches emails that earlier phases missed. The phases are ordered by cost (free first, then cheapest) and reliability (highest precision first).

All Crustdata work runs inside execute({ code }) scripts — write a short plain-JavaScript script whose only I/O is const r = await callTool("<tool>", params), then branch on r.ok. Author it against the TypeScript-typed tool surface from get_schema, but put NO type annotations in the body — a : Type, as, or interface is a parse error that fails the whole run. See the per-phase scripts below.

Phase Crustdata tool (via callTool) Targets Cost
1 company_identify Work + Edu emails FREE
2 personenrich (businessemails) + post-verification Work + Edu ~1-2 cr/profile
2 (personal) batchpersonidentify (async) Personal 1 cr/matched
3 person_search (name+company) Missed work/edu ~0.03 cr/result
4 person_search (name only) Remaining personal ~0.03 cr/result
5 websearchlive + person_enrich ALL remaining 1 cr/query + enrich
6 Scoring gate ALL candidates from Phases 3-5 N/A

Coverage rates:

Category Person Match Company Match
Work emails 95%+ 95%+
Edu emails 95%+ 95%+
Personal emails 95%+ N/A
Blended 95%+ 95%+

Phase 0: Parse input and classify emails

Read the input

Accept CSV files, spreadsheets (.xlsx/.csv), or inline lists. Extract all email addresses. Deduplicate.

Classify each email into one of three categories

Personal email domains (match against this list):

gmail.com, yahoo.com, hotmail.com, outlook.com, aol.com, icloud.com, me.com,
live.com, protonmail.com, proton.me, msn.com, ymail.com, comcast.net, att.net,
verizon.net, mac.com, fastmail.com, hey.com, pm.me, zoho.com, gmx.com,
googlemail.com

Edu email domains (match against these TLD patterns):

.edu, .ac.uk, .ac.jp, .ac.kr, .ac.in, .ac.nz, .ac.za

Work emails: everything else.

Name extraction from email prefix

Split the local part (before @) on dots, underscores, and hyphens. Remove any parts that are purely digits. Capitalize each remaining part. Only keep parts with 2+ characters.

import re

def extract_name_parts(email):
    local = email.split("@")[0]
    parts = re.split(r'[._\-]', local)
    parts = [p for p in parts if not p.isdigit()]
    parts = [p.capitalize() for p in parts if len(p) >= 2]
    return parts

# Examples:
# "[email protected]"   -> ["Daniel", "Lee"]
# "[email protected]"      -> ["John", "Smith"]
# "[email protected]"      -> ["Jsmith"]
# "[email protected]"  -> ["Rodriguez"]

Phase 1: Company Identify (FREE)

Identify the company behind each non-personal email domain. This phase is FREE and should always run first.

Script

company_identify takes a domains array, so deduplicate domains first (a list of 1,000 work emails might only have 200 unique domains) and resolve them all in ONE call.

// model query: identify the company behind each work/edu email domain
const domains = [...new Set(workEduEmails.map(e => e.split("@")[1].toLowerCase()))];
const r = await callTool("company_identify", { domains });
if (!r.ok) return { error: r.message };

// build domain_map: domain -> { name, primary_domain, professional_network_url }
const domainMap = {};
for (const rec of r.data) {
  const info = rec.matches?.[0]?.company_data?.basic_info;
  if (info) domainMap[rec.matched_on.toLowerCase()] = {
    name: info.name,
    primary_domain: info.primary_domain,
    professional_network_url: info.professional_network_url,
  };
}
return { domainMap };

What it returns

An ARRAY of { matchedon, matchtype, matches: [{ confidencescore, companydata }] }. Read the company off matches[0].companydata.basicinfo (name, primarydomain, website, professionalnetworkurl, description, yearfounded, …). matched_on is the domain you passed in, so you can map results back to inputs directly.

How to run it

Store the result in a domain_map: domain -> { name, ... }. This domainMap is returned and passed back into later phases (via inputs) for Phase 3 verification — don't re-run this free call per-domain.

Expected results

  • 95%+ of work email domains will be identified
  • Edu domains are nearly 100% (universities are well-known)

Phase 2: Person Enrich via business email (work/edu)

Look up each work/edu email directly using person enrichment. Phase 2 branches based on email type:

  • Work/edu emails -> use the business_emails identifier + post-verification (Branch A below)
  • Personal emails -> see Branch B — the async batchpersonidentify path, with the name-based phases as fallback

Branch A: Work/edu emails (business_emails)

Script

personenrich takes a businessemails ARRAY (≤25 per call), so batch the work/edu emails. Pass fields: ["basicprofile", "experience"] to get the name + employers needed for post-verification. Each call returns one record per input email; map results back via matchedon.

// model query: enrich work/edu emails to find the person behind each
const r = await callTool("person_enrich", {
  business_emails: workEduEmailBatch,          // ≤25 emails
  fields: ["basic_profile", "experience"],     // name + current/past employers
});
if (!r.ok) return { error: r.message };

// r.data is an ARRAY of { matched_on, match_type, matches: [{ confidence_score, person_data }] }
const byEmail = {};
for (const rec of r.data) {
  const pd = rec.matches?.[0]?.person_data;
  if (pd) byEmail[rec.matched_on.toLowerCase()] = pd;   // post-verify before accepting
}
return { byEmail };

For more than 25 work/edu emails, chunk(emails, 25) then await parallelMap(batches, b => callTool("personenrich", { businessemails: b, fields: ["basic_profile", "experience"] })).

Critical details

  • business_emails takes an array of email strings (≤25 per call)
  • Pass ONE identifier kind per call — professionalnetworkprofileurls and businessemails are not combined in the same call
  • Despite the name "business_emails", this works for edu emails too (especially faculty/staff)
  • Returns persondata including: basicprofile.name, basicprofile.headline, socialhandles.professionalnetworkidentifier.profileurl, and experience.employmentdetails.current[] / .past[] (employers)
  • Set preview: true for a 0-credit base-profile check before paying

If a match is returned (matches[0].person_data has a name), it MUST pass post-verification before accepting (see Post-verification section below).

Branch B: Personal emails (reverse personal-email lookup)

Async only: Personal emails (gmail/yahoo/outlook) resolve through the ASYNC
batchpersonidentify — the only tool that matches personal emails. The sync tools cannot:
personenrich accepts only professionalnetworkprofileurls or business_emails as
identifiers, personenrichlive accepts only profile URLs, and person_search has no
emails filter column.

How: submit batchpersonidentify({ emails: [...] }) (≤300 per job), poll
batchjobget(batchid) until completed, then read the batchresults rows —
matches[0].persondata carries the same ids/basicprofile/profile-URL shape as Branch A.
1 credit per MATCHED identifier; unmatched identifiers are free. For emails it does not match,
fall through to:
1. Phase 4person_search by the full name extracted from the email prefix (works when
the prefix is a clear first.last pattern), then person_enrich by the resulting profile URL.
2. Phase 5websearchlive({ query: "who is EMAIL", sources: ["ai"] }) to resolve the
owner's name, then personsearchpersonenrich.

Post-verification (required for every Phase 2 Branch A result -- work/edu only)

The person enrich API can return wrong matches: a person at the right company but not the email owner, or a person who no longer works there. Every result must pass these checks. The checks run on the persondata returned by the enrich script above — employers live at experience.employmentdetails.current[] / .past[] (each entry has name for the company, title, and companywebsitedomain).

Check 1: Employer domain verification. The email domain must appear in the person's current OR past employer website domains. For example, [email protected] must have sapphireventures.com in at least one employer's domain list. If the domain doesn't appear in any employer (current or past), REJECT the match.

def verify_employer_domain(person_data, email_domain):
    domain_base = email_domain.lower().split('.')[0]
    emp_details = person_data.get("experience", {}).get("employment_details", {})
    employers = emp_details.get("current", []) + emp_details.get("past", [])
    for emp in employers:
        domains = emp.get("company_website_domain") or []
        if isinstance(domains, str):
            domains = [domains]
        for d in domains:
            if email_domain.lower() in d.lower() or d.lower() in email_domain.lower():
                return True, emp.get("name", "")
        if len(domain_base) > 3 and domain_base in emp.get("name", "").lower().replace(" ", ""):
            return True, emp.get("name", "")
    return False, None

Check 2: Name-prefix match. The email prefix must plausibly match the returned person's name. For example, [email protected] should match a name containing "alling" (as in "Ted Alling"), not "Santosh Sankar". Check if any part of the profile name starts with the same characters as the email prefix, or if a first-initial + lastname pattern matches.

def name_matches_prefix(profile_name, email_prefix):
    pn_parts = profile_name.lower().split()
    prefix = email_prefix.lower()
    for part in pn_parts:
        if prefix.startswith(part[:3]) or part.startswith(prefix[:3]):
            return True
        # First-initial + lastname pattern: "talling" = "t" + "alling"
        if len(prefix) > 2:
            for i in range(1, min(3, len(prefix))):
                if prefix[i:] in part and len(prefix[i:]) > 2:
                    return True
    return False

Check 3: AI correction for name mismatches. When the employer domain matches but the name doesn't (right company, wrong person), use web search AI mode to find who actually owns the email:

// model query: who actually owns [email protected]
const r = await callTool("web_search_live", {
  query: "who is [email protected]",
  sources: ["ai"],
});
const aiText = r.ok ? r.data.results?.map((x) => x.snippet || x.title).join(" ") : "";
return { aiText };

The AI response typically says something like "belongs to Ted Alling, Partner at Lamp Post Group". Extract the real name and search person DB (Phase 3 person_search) with the corrected name + company.

This step recovered 7 correct matches in testing that would otherwise have been lost.

Expected results

  • Work/edu (Branch A): ~58% of work+edu emails pass all verification checks. ~5% are rejected by employer domain check (wrong person entirely). ~1% are AI-corrected (right company, wrong person -> AI finds the real name).
  • Personal (Branch B): matched directly by the async batchpersonidentify; emails it does not match are resolved downstream via the name-based Phases 4/5, with precision gated by Phase 6.

Phase 3: Person search by name + company

For work/edu emails that Phase 2 missed, try a name+company search. Extract a name guess from the email prefix and combine it with the company identified in Phase 1.

When to use

Only for emails where:

  1. Phase 2 returned no match
  2. The email is work or edu (not personal)
  3. The domain was identified in Phase 1 (we know the company name)
  4. At least one name part can be extracted from the email prefix

Script

The filter key is always field. Person-name filters on basicprofile.name; current-employer name filters on experience.employmentdetails.current.company_name; limit caps the page size.

// model query: find FirstName at CompanyName
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "FirstName" },
    { field: "experience.employment_details.current.company_name", type: "(.)", value: "CompanyName" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],   // name + profile_url
});
if (!r.ok) return { error: r.message };
// r.data.profiles[]: read basic_profile.name and
// social_handles.professional_network_identifier.profile_url
return { profiles: (r.data.profiles ?? []).map((p) => ({
  name: p.basic_profile?.name,
  profile_url: p.social_handles?.professional_network_identifier?.profile_url,
})) };

Verification (required)

The returned profile's name must contain the first name extracted from the email prefix:

def verify_name_match(email_name_parts, profile_name):
    if not email_name_parts or not profile_name:
        return False
    return email_name_parts[0].lower() in profile_name.lower()

How to run it

For each missed work/edu email, extract the name and look up the company from the domain_map:

// model query: find Kyle at Backswing Ventures (email [email protected])
// Name parts: ["Kyle"]; company from Phase 1 domainMap: "Backswing Ventures"
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Kyle" },
    { field: "experience.employment_details.current.company_name", type: "(.)", value: "Backswing Ventures" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],
});
return r.ok ? { profiles: r.data.profiles } : { error: r.message };

Check each returned profile: does "kyle" appear in basic_profile.name? If yes, it's a match.

Expected results

  • Catches emails that Phase 2 missed using name + company compound search
  • Works best for emails with clear name formats (john.smith@, daniel_lee@)

Candidate verification (CRITICAL -- do not skip)

Without verification, name/substring matches produce false positives. For example, a search for "wraecca" might match "Alessandro Racca" because "racca" is a substring.

For every candidate, verify ALL of the following:

Step 1 -- Name verification (required for all email types):

  • Extract name parts from the email prefix
  • If 2+ name parts: BOTH first AND last must appear in the profile name
  • If 1 name part: that part must appear in the profile name, and the part must be 3+ characters

Step 2 -- Organization verification (required for work and edu emails):

  • Look up the company/institution from the domain_map (Phase 1)
  • Extract significant words from the org name (skip common words like "the", "inc", "llc", "of")
  • At least one significant org word must appear somewhere in the profile data (check employers, education, headline)
  • If no org word matches, reject the result even if the name matched

Step 3 -- Personal emails (name check only):

  • For personal emails (gmail, yahoo, etc.), there is no org to cross-reference
  • The name match from Step 1 is the only gate
  • This means personal email matches have lower precision

A search profile carries its name at basic_profile.name; flatten that before verifying.

def verify_candidate_match(email, name_parts, profile, domain_map):
    profile_name = profile.get("basic_profile", {}).get("name", "").lower()

    # Name verification
    if len(name_parts) >= 2:
        if not (name_parts[0].lower() in profile_name and name_parts[-1].lower() in profile_name):
            return False
    elif len(name_parts) == 1 and len(name_parts[0]) > 2:
        if name_parts[0].lower() not in profile_name:
            return False
    else:
        return False

    # Org verification for work/edu
    domain = email.split("@")[1]
    company_info = domain_map.get(domain)
    if company_info:
        org_name = company_info.get("name", "")
        skip = {"the", "inc", "llc", "ltd", "co", "corp", "of", "and", "for", "university", "college"}
        org_words = [w.lower() for w in org_name.split() if w.lower() not in skip and len(w) > 2]
        profile_str = str(profile).lower()
        if org_words and not any(w in profile_str for w in org_words):
            return False

    return True

Expected results

  • For work/edu emails: both name AND company must verify. No company from Phase 1 = automatic reject.
  • For personal emails: name match only (lower precision, but better than no verification)

Phase 4: Person search by name for personal emails

Last resort for personal emails where we can extract a plausible full name from the email prefix.

When to use

Only for emails where:

  1. All previous phases returned no match
  2. The email is personal (gmail, yahoo, etc.)
  3. At least 2 name parts can be extracted from the email prefix

Script

// model query: find FirstName LastName by name
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "FirstName LastName" },
  ]},
  limit: 5,
  fields: ["basic_profile", "social_handles"],
});
if (!r.ok) return { error: r.message };
return { profiles: r.data.profiles ?? [] };

Acceptance criteria

  • The search must return 3 or fewer results (low ambiguity)
  • If 4+ results come back, skip -- too many possible matches
  • The returned name must reasonably match the extracted name parts

How to run it

// model query: find Joanne Bradford (email [email protected]) by name
// Name parts: ["Joanne", "Bradford"]
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Joanne Bradford" },
  ]},
  limit: 5,
  fields: ["basic_profile", "social_handles"],
});
return r.ok ? { profiles: r.data.profiles } : { error: r.message };

If 1-3 results returned, take the first one. If 0 or 4+, mark as unmatched.

Expected results

  • Catches remaining personal emails with clear first.last patterns
  • The 3-result ceiling prevents matching the wrong person for common names
  • MUST verify: both name parts from the email prefix must appear in the returned profile name. Do not just accept the first result.

Phase 5: Web search fallback for all remaining unmatched emails

Final fallback for emails that all previous phases missed. Uses web search to find the person's profile URL, then enriches via that URL. This catches vanity domains (e.g., carolewainaina.com), personal brand domains, and any email not indexed in Crustdata's database.

When to use

For any email that remains unmatched after Phases 1-5, regardless of category (work, edu, or personal).

Step 1: Web search for profile URL

// model query: find a professional-network profile URL for EMAIL
const r = await callTool("web_search_live", {
  query: "EMAIL linkedin",
  sources: ["web"],
});
// r.data.results[]: { source, title, url, snippet, ... }
const hit = r.ok && (r.data.results ?? []).find((x) => /linkedin\.com\/in\//.test(x.url || ""));
return { profile_url: hit?.url ?? null };

Check the results for any URL containing linkedin.com/in/. If found, proceed to Step 3.

Step 2: AI web search for name (if Step 1 didn't find a profile URL)

// model query: who owns EMAIL
const r = await callTool("web_search_live", {
  query: "who is EMAIL",
  sources: ["ai"],
});
const aiText = r.ok ? (r.data.results ?? []).map((x) => x.snippet || x.title).join(" ") : "";
return { aiText };

The AI response often says something like "belongs to Carole Wamuyu Wainaina" or "associated with John Smith at Company X". Extract the person's name and search the person DB:

// model query: find <Extracted Name> by name
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Extracted Name" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],
});
// take social_handles.professional_network_identifier.profile_url off the best match
return r.ok ? { profiles: r.data.profiles } : { error: r.message };

If personsearch returns a result with a profile URL (socialhandles.professionalnetworkidentifier.profile_url), proceed to Step 3.

Step 3: Enrich via profile URL

// model query: enrich the resolved profile URL to get the person's details
const r = await callTool("person_enrich", {
  professional_network_profile_urls: ["PROFILE_URL_FROM_STEP_1_OR_2"],
  fields: ["basic_profile", "experience"],
});
// r.data[0].matches[0].person_data: basic_profile.name, basic_profile.headline, employers
return r.ok ? { person: r.data?.[0]?.matches?.[0]?.person_data } : { error: r.message };

Expected results

  • Catches vanity/personal domains (carolewainaina.com, first-last.com)
  • Catches people not indexed by email but findable via web search
  • The AI mode is particularly effective at resolving "who owns this email" queries

Phase 6: Scoring gate (applied to all candidates from Phases 3-5)

All candidate matches produced by Phases 3, 4, and 5 must pass through this scoring gate before being accepted. Phase 2 results are exempt — Branch A has its own employer-domain + name post-verification, and Branch B matches are keyed on the email itself (the API matched the identifier directly).

Hard requirements (both must pass)

  1. name_sim > 0.8 -- The similarity between the name extracted from the email prefix and the candidate profile name must exceed 0.8. This prevents a perfect company match from compensating for a bad name match.
  2. combined_score > 0.7 -- The overall combined score (incorporating name similarity, company match, and any other signals) must exceed 0.7.

Phase 6 requires BOTH namesim > 0.8 AND combinedscore > 0.7. This prevents a perfect company match from compensating for a bad name match. For example, finding someone at the right company whose name does not resemble the email prefix will be rejected even if the company match is perfect.

When a candidate fails

If a candidate fails the scoring gate, it is rejected and the email continues to the next phase in the waterfall. If no phase produces a candidate that passes the gate, the email is marked UNMATCHED.


Scaling and concurrency

Rate limiting is handled by the host inside execute — there are no manual RPM tables, sleeps, or backoff to manage. For large lists, scale by fanning out work inside ONE script:

  • Batch enrich. personenrich and companyidentify take arrays — pass up to 25 emails/domains per call. chunk(emails, 25) then await parallelMap(batches, b => callTool("personenrich", { businessemails: b, fields: [...] })).
  • Parallelize independent calls with await parallelMap(items, fn) — never for … await. Keep dependent steps (search → enrich) sequential; only parallelize WITHIN a stage.
  • Return the smallest projection — only what you return reaches the model.

Optimization: deduplicate domains in Phase 1

A list of 1,000 work emails might only have 200 unique domains. Always deduplicate domains before calling company_identify (which takes a domains array — resolve all uniques in one call).


Progress saving and resumability

For large lists, save progress to a JSON file after each phase so the enrichment can resume if interrupted.

Progress file format

{
  "phase_completed": 3,
  "domain_map": {
    "acme.com": {"name": "Acme Corp"},
    "stanford.edu": {"name": "Stanford University"}
  },
  "results": {
    "[email protected]": {
      "name": "John Smith",
      "headline": "VP Engineering at Acme",
      "company": "Acme Corp",
      "method": "person_enrich"
    }
  },
  "unmatched_emails": ["[email protected]"]
}

Resume logic

On start, check if a progress file exists. If it does, skip phases that are already complete and continue from where it left off.


Output

CSV output

Generate a CSV with these columns:

Column Description
email Original email address
category work, edu, or personal
person_name Full name of the person
person_headline Job title / headline
company_name Company or institution name
profile_url Professional profile URL
method Which phase found the match: personenrich, name+company, namesearch, web_search

Summary statistics

Print a summary at the end:

=== Email Enrichment Results ===
Total emails: {N}
  Work:     {W}  | Person: {P1} ({P1%})  | Company: {C1} ({C1%})
  Edu:      {E}  | Person: {P2} ({P2%})  | Company: {C2} ({C2%})
  Personal: {R}  | Person: {P3} ({P3%})  | Company: {C3} ({C3%})
  Overall:  {N}  | Person: {PT} ({PT%})  | Company: {CT} ({CT%})

Breakdown by method:
  Phase 2 (person_enrich):    {count}
  Phase 2B (batch_identify):  {count}
  Phase 3 (name+company):     {count}
  Phase 4 (name_search):      {count}
  Phase 5 (web_search):       {count}

Decision flowchart

For each email:
|
+-- Classify: work / edu / personal
|
+-- Phase 1: Is it work or edu?
|   +-- Yes -> Extract domain -> company_identify({ domains: [...] })
|   |   +-- Found company? -> Store in domain_map
|   |   +-- Not found? -> Continue (no company info for this domain)
|   +-- No (personal) -> Continue to Phase 2 Branch B
|
+-- Phase 2: Branch by email type
|   |
|   +-- Work/edu (Branch A):
|   |   +-- person_enrich({ business_emails: [email], fields: ["basic_profile","experience"] })
|   |   +-- Found person? -> Post-verify (employer domain + name prefix + AI correction)
|   |   |   +-- Verified? -> DONE (method=person_enrich)
|   |   |   +-- Failed verification? -> Continue to Phase 3
|   |   +-- Not found? -> Continue to Phase 3
|   |
|   +-- Personal (Branch B): batch_person_identify({ emails: [...] }) -> poll batch_job_get -> batch_results
|       +-- Matched? -> DONE (method=batch_identify)
|       +-- Not matched? -> Continue to Phase 4 (name search) then Phase 5 (web search)
|
+-- Phase 3: Is it work/edu AND have company name AND name parts?
|   +-- Yes -> person_search(filters: basic_profile.name + experience.employment_details.current.company_name)
|   |   +-- Found + name verified? -> Phase 6 scoring gate -> DONE (method=name+company)
|   |   +-- Not found? -> Continue to Phase 4
|   +-- No -> Skip to Phase 4
|
+-- Phase 4: Is it personal AND has 2+ name parts?
|   +-- Yes -> person_search(filters: basic_profile.name="FirstName LastName")
|   |   +-- 1-3 results returned + name verified? -> Phase 6 scoring gate -> DONE (method=name_search)
|   |   +-- 0 or 4+ results? -> Continue to Phase 5
|   +-- No -> Continue to Phase 5
|
+-- Phase 5: Still unmatched? (any email type)
|   +-- web_search_live({ query: "EMAIL linkedin", sources: ["web"] })
|   |   +-- Found linkedin.com/in/ URL? -> person_enrich({ professional_network_profile_urls: [URL] }) -> Phase 6 scoring gate -> DONE
|   +-- No URL found? -> web_search_live({ query: "who is EMAIL", sources: ["ai"] })
|   |   +-- Extracted person name? -> person_search(name) -> get profile URL -> person_enrich -> Phase 6 scoring gate -> DONE
|   +-- Nothing found? -> UNMATCHED
|
+-- Phase 6: Scoring gate (applied to all candidates from Phases 3-5)
    +-- Requires BOTH: name_sim > 0.8 AND combined_score > 0.7
    +-- Pass? -> Accept match
    +-- Fail? -> Reject, continue to next phase or mark UNMATCHED

Key learnings

  1. Verification is non-negotiable. In testing on 1,476 emails, strict verification removed 149 false positives that the unverified approach would have returned. Always verify.
  1. Phase 2 post-verification catches ~5% bad matches. The person enrich API sometimes returns the wrong person at the right company (e.g., a different employee). Employer domain + name-prefix checks catch these.
  1. AI web search correction works. When person enrich returns the right company but wrong person, websearchlive with sources: ["ai"] and query "who is EMAIL" correctly identifies the real person. Recovered 7 matches in testing.
  1. Name/substring candidates have an extremely high false positive rate without verification. In testing: 1,540 rejections vs 37 accepts. Substring name matching produces many spurious matches. Strict name + company verification is essential.
  1. Personal-email reverse lookup is async-only. personenrich has no personal-email identifier and personsearch has no emails filter field — the only direct path is batchpersonidentify (submit the job, poll batchjobget, read batch_results); emails it does not match fall back to the name-based Phases 4/5.
  1. For work/edu emails, no company = no match. If Phase 1 didn't identify the company for a domain, do NOT accept name-search results for emails at that domain. There's nothing to verify against.
  1. Phase 4 must verify names, not just count results. Accepting the first result just because <= 3 came back produces false positives like "Bert Zacharin" matching "Zacharie Bere". Both name parts from the email must appear in the profile name.
  1. Edu emails work with personenrich. Despite the identifier being called businessemails, it matches faculty and staff at universities.
  1. personenrich accepts ONE identifier kind per call — pass either professionalnetworkprofileurls OR business_emails, not both. Each is an array (≤25). Use preview: true for a 0-credit base-profile check before paying.
  1. person_search returns results under r.data.profiles (and everything is under r.data first). A failed call does NOT abort the script — always branch on r.ok (or unwrap), or an unchecked failure looks like "no results".
  1. (.) is a literal case-insensitive substring, not a regex alternation. A piped value like "a|b" matches nothing on person search — use anyof(field, [...]) / in for N values. Filter on field; read responses off the Returns: paths (e.g. socialhandles.professionalnetworkidentifier.profileurl, not the experience.… field you filter on).
  1. Phase 6 requires BOTH namesim > 0.8 AND combinedscore > 0.7. This prevents a perfect company match from compensating for a bad name match. Without the name_sim hard gate, false positives like "Bert Zacharin" matching "Zacharie Bere" can slip through.


Person-to-Email Enrichment

When the input is a list of people (names, profile URLs, or both) and the goal is to find their email addresses, use this flow instead.


Step 1: Resolve profile URLs

If the input already has profile URLs, skip this step.

If only names + companies are provided, resolve to profile URLs first:

// model query: resolve Person Name at Company Name to a profile URL
const r = await callTool("person_search", {
  filters: { op: "and", conditions: [
    { field: "basic_profile.name", type: "(.)", value: "Person Name" },
    { field: "experience.employment_details.current.company_name", type: "(.)", value: "Company Name" },
  ]},
  limit: 3,
  fields: ["basic_profile", "social_handles"],
});
if (!r.ok) return { error: r.message };
// profile URL lives at social_handles.professional_network_identifier.profile_url
return { profile_url: r.data.profiles?.[0]?.social_handles?.professional_network_identifier?.profile_url };

The profile URL you need is at socialhandles.professionalnetworkidentifier.profileurl.

Fallback: If not found in the person DB, try web search:

// model query: find Person Name's profile URL on the web
const r = await callTool("web_search_live", {
  query: "Person Name Company site:linkedin.com/in/",
  sources: ["web"],
});
const hit = r.ok && (r.data.results ?? []).find((x) => /linkedin\.com\/in\//.test(x.url || ""));
return { profile_url: hit?.url ?? null };

Extract the profile URL from the top result.

Common pitfalls

  • Common names: always include company or title context. "Michael Ma Liquid 2 Ventures" not just "Michael Ma".
  • Name variants: try both formal and common names - "William Drevno" vs "Will Drevno", "Robert" vs "Bob".
  • Recently changed roles: search with both old and new company if you know them.

Step 2: Enrich business emails

professionalnetworkprofile_urls is an ARRAY (≤25 per call). Request the contact field group to get business emails — they are not returned by default.

// model query: get business emails for these profile URLs
const r = await callTool("person_enrich", {
  professional_network_profile_urls: profileUrlBatch,   // ≤25 URLs
  fields: ["basic_profile", "contact"],                 // name + business_emails
});
if (!r.ok) return { error: r.message };
// r.data is an ARRAY of { matched_on, matches: [{ person_data }] }; map back via matched_on
return { results: r.data.map((rec) => ({
  profile_url: rec.matched_on,
  name: rec.matches?.[0]?.person_data?.basic_profile?.name,
  business_emails: (rec.matches?.[0]?.person_data?.contact?.business_emails ?? []).map((b) => b.email),
})) };

Critical details

  • professionalnetworkprofile_urls takes up to 25 profile URLs per call (an array)
  • Business emails come back in the contact field group (contact.business_emails[].email) — request contact in fields
  • Pass ONE identifier kind per call — professionalnetworkprofile_urls here
  • The response is an array. Map results back to input URLs using each record's matched_on field.

Handling large lists

For 25+ profiles, chunk(profileUrls, 25) then fan out: await parallelMap(batches, b => callTool("personenrich", { professionalnetworkprofileurls: b, fields: ["basicprofile", "contact"] })). return only the projected { profileurl, name, business_emails } to stay under token limits.


Step 3: Enrich personal emails and phone numbers

The contact field group returns businessemails, personalemails, AND phone_numbers — request it to get personal contact info. Use preview: true first (0 credits) to check whether a profile has personal contact data before paying for it. preview: true is account-gated: if it returns a 400 "Preview feature is not available for your account", your account lacks preview — skip it and enrich directly (you pay base credits).

// model query: get personal emails and phone numbers for these profiles
const r = await callTool("person_enrich", {
  professional_network_profile_urls: profileUrlBatch,   // ≤25 URLs
  fields: ["basic_profile", "contact"],                 // personal_emails + phone_numbers
});
if (!r.ok) return { error: r.message };
return { results: r.data.map((rec) => {
  const c = rec.matches?.[0]?.person_data?.contact ?? {};
  return {
    profile_url: rec.matched_on,
    personal_emails: (c.personal_emails ?? []).map((e) => e.email ?? e),
    phone_numbers: c.phone_numbers ?? [],
  };
}) };

Credit usage

person_enrich cost is additive per add-on: base 1, +1 business email, +2 personal email, +2 phone number, +1 dev-platform — capped at 7. preview: true is 0 credits. So a profile with business email = 2 cr; with personal email + phone = 5 cr. Default to business email for outreach; request personal/phone only when the task needs them.

Access

Personal contact info enrichment is access-controlled. Not all accounts have it enabled. If personalemails / phonenumbers come back empty, the account may need this feature turned on (preview is a cheap way to check first).

Combining with business email

The contact group already returns business, personal, and phone in one call, so you get everything together:

// model query: get business + personal emails + phone for one profile
const r = await callTool("person_enrich", {
  professional_network_profile_urls: ["https://linkedin.com/in/person1"],
  fields: ["basic_profile", "contact"],
});
return r.ok ? { contact: r.data?.[0]?.matches?.[0]?.person_data?.contact } : { error: r.message };

Step 4: GitHub fallback for personal emails (technical people)

If personal contact info enrichment is not available or returns empty for technical people, fall back to GitHub commit history. This only works for engineers, developers, and technical founders.

Find their GitHub username

Check the person record first: socialhandles carries a devplatformidentifier (the person's GitHub handle) alongside the professional-network one, and adding devplatformprofiles to personenrich's fields (the dev add-on, +1 credit) returns the full GitHub profile — repos, org memberships, alllanguages, and sometimes a public email — in the same call as the profile. For a person you resolved without those groups, use standalone devplatformenrich. Pass EXACTLY ONE of crustdatapersonid — every match row from earlier phases carries it as persondata.crustdatapersonid, so use this for a person you already resolved — or profileurl, which must be a GitHub URL (https://github.com/<username>); a LinkedIn URL here is rejected with a 400. It returns devplatformprofiles[] with profileurl, name, bio, company_text, and sometimes a public email:

// model query: find this person's GitHub profile (and any public email)
const r = await callTool("dev_platform_enrich", {
  crustdata_person_id: PERSON_ID_FROM_EARLIER_PHASE, // person_data.crustdata_person_id
});
if (!r.ok) return { error: r.message };
// r.data.dev_platform_profiles[]: { profile_url, name, bio, company_text, email, ... }
return { github: r.data.dev_platform_profiles ?? [] };

Or search the web:

// model query: find Person Name's GitHub profile
const r = await callTool("web_search_live", {
  query: "Person Name Company site:github.com",
  sources: ["web"],
});
return r.ok ? { results: r.data.results } : { error: r.message };

Verify the GitHub profile

Confirm at least 2 of these match: GitHub bio mentions their company/role, profile name matches, repo topics align with their expertise. devplatformenrich returns name, bio, and companytext to check against. If devplatform_profiles[].email is already populated and is not a noreply address, you can use it directly and skip the commit-scraping below.

Extract email from commits

The devplatformprofiles[].repos entries already carry what you need to pick a target — fullname, isfork, and githubcreatedat. Choose the oldest repo where isfork is false; no extra call needed. Only if the enrichment returned no repos, list them with webenrich_live (the urls param is an ARRAY):

// model query: list the user's oldest repos to find a non-fork repo
const r = await callTool("web_enrich_live", {
  urls: ["https://api.github.com/users/USERNAME/repos?sort=created&direction=asc&per_page=5"],
});
// r.data is [{ success, url, title, content }]; parse content[0] for repo names
return r.ok ? { content: r.data?.[0]?.content } : { error: r.message };

Then fetch the commit email:

// model query: read the commit patch to extract the author email
const r = await callTool("web_enrich_live", {
  urls: ["https://github.com/OWNER/REPO/commit/SHA.patch"],
});
return r.ok ? { content: r.data?.[0]?.content } : { error: r.message };

Extract the email from the From: header in the patch. Discard [email protected] and *@users.noreply.github.com addresses.


Person-to-email expected results

  • Business emails: 95%+ of professionals at known companies
  • Personal emails: 95%+ via enrichment API with personal contact info enabled
  • Phone numbers: 95%+ via enrichment API with personal contact info enabled

Crustdata tool reference

All tools are reached via callTool("<tool>", params) inside an execute({ code }) script.

Tool Purpose Key parameters
company_identify Domain to company (FREE) domains: [...] (array), names, optional exact_match
person_enrich Email to person, or profile URL to emails ONE of businessemails: [...] OR professionalnetworkprofileurls: [...]; fields: [...] (groups: basic_profile, contact, experience, …); preview: true (0 cr)
person_search Search people by filters filters ({ op, conditions: [{ field, type, value }] }), limit, cursor, fields
websearchlive Web search (find profiles, AI "who is" lookups) query, sources (e.g. ["web"], ["ai"])
webenrichlive Fetch page content (GitHub commits) urls: [...] (array)
devplatformenrich GitHub/dev-platform profile (technical people) profileurl or crustdataperson_id

MCP server: install.crustdata.com/mcp (Code Mode — meta-tools listtools, getschema, execute).


Tool dependencies

This skill requires the Crustdata Code Mode MCP server connected at install.crustdata.com/mcp. It exposes the meta-tools listtools, getschema, and execute; data tools are called via callTool(...) inside an execute script. The data tools this skill uses:

  • company_identify
  • personenrich (and personenrich_live for real-time profile-URL enrichment)
  • person_search
  • websearchlive
  • webenrichlive
  • devplatformenrich