Gtm people enrichment
Skill vivekkhimani/gtm-tools-template/.agents/skills/gtm-people-enrichment
Agent-first GTM operating manual template: positioning, lead pipeline, outreach, playbooks, investor materials + 22 agent skills
npx -y skills add vivekkhimani/gtm-tools-template --skill gtm-people-enrichmentAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Enrich contacts with work email, phone number, and/or LinkedIn URL. Use when you have a contact list that needs enrichment before outreach. Email waterfall (FullEnrich → Pipe0), phone (BetterContact → FullEnrich). Enforces demo mode (email only, no phone). Also triggers on "enrich contacts", "find emails for", "get phone numbers for".
SKILL.md
12.9 KB, as published. Nobody here has run it
People Enrichment
Enrich contacts with work email, phone number, and/or LinkedIn URL. Returns an updated CSV.
Read .agents/skills/_shared/conventions.md before executing.
When to Use
- You have a contact list and need emails and/or phone numbers
- Contacts from people-search need enrichment before outreach
- A user-provided CSV has names + companies but missing contact info
Inputs
| Input | Required | Source |
|---|---|---|
| Contact CSV | Yes | People Search output or user-provided |
| LinkedIn profile URLs | Recommended | In CSV or from People Search |
| Company domains | Recommended | In CSV |
| Enrichment type | Yes | email, phone, or both |
Demo Mode
When invoked from the demo flow: email only, skip all phone providers entirely.
Provider Selection
Ask the user which provider to use. Default recommendation:
| Priority | Provider | Hit Rate | Cost | Speed | Notes |
|---|---|---|---|---|---|
| 1st | FullEnrich v2 | 80% (SA), 83% (DACH) | 0.8 cr/contact | instant | Clear winner |
| 2nd | Pipe0 waterfall | 60% (SA), 83% (DACH) | 1.0–3.5 cr/contact | ~3 min/20 contacts | Solid backup |
| 3rd | BetterContact (email-only) | ~60% test / 14% production SA | 0.35 cr/contact | 30–90 min | Not recommended — slow, unreliable production |
Recommended flow: FullEnrich first → Pipe0 for FE misses → skip BC for email.
Phone
Skip entirely in demo mode.
| Priority | Provider | Hit Rate | Cost | Speed | Notes |
|---|---|---|---|---|---|
| 1st | FullEnrich v2 | ~60% DACH | ~0.8 cr/contact | instant | Good DACH coverage. Prefer most_probable_phone.number over phones[0] (phones[0] can be landline, most_probable is mobile) |
| 2nd | Pipe0 phone waterfall | ~92% DACH (n=74) · 0% SA | 2–7 cr/contact | ~30s/batch | Response field: mobile (not phone). Works reliably as FE fallback — on 7 FE contacts missing phones, recovered 5 (71%). Also strong as primary when contacts come from Pipe0 search (68/74 = 92%) |
| 3rd | BetterContact | 13–74% (~22% SA, ~53% DACH) | ~1–2 cr/contact | 5–10+ min for small batches | Unreliable speed — took 10+ min for 2 contacts in DACH test. Retest before production |
LinkedIn URL (when missing)
- Pipe0
people:profileurl:name@1— 0.60 cr/person, needs name + company_name + location_hint - PhantomBuster Profile Scraper (config key:
PB_AGENT_PROFILE) — scrape full profile data from existing LinkedIn URL; use/phantombusterskill with phantom name "LinkedIn Profile Scraper" and CSV path. See_shared/phantombuster.md.
Execution Protocol
1. Assess Input Data
- Check what fields are available (LinkedIn URL, domain, name, company)
- Determine which enrichment types are needed
- Estimate credit cost:
n_contacts × max_cost_per_record - Confirm credit balance with user before proceeding
2. Test Batch (5–10 contacts)
- Run chosen provider on 5–10 contacts in production
- Inspect every result: email validity status, phone format, match accuracy
- Check hit rate — if below 10%, investigate before proceeding
3. Review with User
- Present test results with hit rate, status breakdown, sample data
- Confirm provider choice and credit spend for full batch
- Get approval
4. Full Batch
- Split into chunks of max 100 contacts per request (all providers)
- Save request/task IDs immediately
- Poll with appropriate timeouts:
- FE: 15s interval, 600s timeout (usually instant)
- Pipe0: 30s interval, 600s timeout (≤100 contacts)
- BC phone: 30s interval, 900s timeout
- BC email: 60s interval, 3600s timeout
- Save results incrementally after each batch
5. Fallback Pass
- Identify contacts with missing email/phone after primary provider
- Run fallback provider on misses only
- Merge results
6. Final Output
- Merge all provider results into single CSV
- Add
email_sourceandphone_sourcecolumns for traceability - Flag email status (deliverable, catch_all, undeliverable)
- Write to
csv/output/contacts_enriched.csv
Provider 1: FullEnrich v2 (Email + Phone)
Endpoint: POST https://app.fullenrich.com/api/v2/contact/enrich/bulk
Auth: Authorization: Bearer $FULLENRICH_API_KEY
Submit (max 100 contacts/batch)
{
"name": "My enrichment job",
"data": [
{
"firstname": "Jane",
"lastname": "Doe",
"domain": "example.com",
"linkedin_url": "https://www.linkedin.com/in/janedoe",
"enrich_fields": ["contact.emails"],
"custom": {"row_id": "0"}
}
]
}
For phone: "enrich_fields": ["contact.phones"] or both: ["contact.emails", "contact.phones"]
Poll
GET https://app.fullenrich.com/api/v2/contact/enrich/bulk/{enrichment_id}
Done when: response["status"] == "FINISHED"
Speed: usually instant — often finished before first poll. Use 15s interval, 600s timeout.
Parse
for entry in result.get("data", []):
row_id = str(entry.get("custom", {}).get("row_id", ""))
ci = entry.get("contact_info", {})
# Email
best = ci.get("most_probable_work_email", {})
email = best.get("email", "") or ""
if not email:
for e in ci.get("work_emails", []):
if e.get("email"):
email = e["email"]; break
email_status = best.get("status", "") # DELIVERABLE / HIGH_PROBABILITY / CATCH_ALL
# Phone
phone_list = ci.get("phones", [])
phone = phone_list[0].get("number", "") if phone_list else ""
Key Notes
- Base URL is
app.fullenrich.com(NOTapi.fullenrich.com) - Use v2 (not v1)
- Max 100 contacts per batch
- Submit response key is
enrichment_id(UUID) - Email:
contact_info.most_probable_work_email.email - Phone:
contact_info.phones[0].number - If credits run out mid-batch:
status = "CREDITS_INSUFFICIENT", partial results returned - Email status values:
DELIVERABLE,HIGH_PROBABILITY,CATCH_ALL
Cost
~0.8 credits/contact
Docs
Provider 2: Pipe0 Email Waterfall
Pipe ID: people:workemail:profileurl:waterfall@1
Auth: Authorization: Bearer $PIPE0_API_KEY
Use curl — Python requests blocked by Cloudflare.
Submit
{
"config": {"environment": "production"},
"pipes": [{"pipe_id": "people:workemail:profileurl:waterfall@1"}],
"input": [
{"id": "1", "profile_url": "https://linkedin.com/in/janedoe"}
]
}
Key Notes
- Submit response key is
id(nottask_id) — use this value for polling:GET /pipes/check/{id} email_validation_statusfield is also returned alongsidework_emailin each record- Email quality: ~15–25% of hits may be stale (old employer email) for EU audiences. Waterfall picks up the most recent known email regardless of current role. Always post-filter: remove personal domains (gmail, yahoo, etc.) and cross-check email domain against current company. In EU SME test: 37/43 raw hits, 30/43 valid after filtering 7 stale/personal.
Poll
GET https://api.pipe0.com/v1/pipes/check/{id}
Done when: status == "completed". Use 30s interval, 600s timeout (~3 min for 43 contacts). Keep batches ≤100 contacts.
Parse
records = result.get("records", {})
for rid, rec in records.items():
fields = rec.get("fields", {})
email = (fields.get("work_email", {}) or {}).get("value", "") or ""
status = (fields.get("email_validation_status", {}) or {}).get("value", "") or ""
# work_email.value is a plain string, NOT nested
Post-filtering (Recommended)
After enrichment, filter out stale/personal emails:
PERSONAL_DOMAINS = {'gmail.com','yahoo.com','hotmail.com','outlook.com','icloud.com'}
df['email'] = df['email'].apply(
lambda e: "" if (e and e.split('@')[-1].lower() in PERSONAL_DOMAINS) else e
)
# Also manually flag emails where domain doesn't match current company
Waterfall Providers & Cost
| Provider | Cost |
|---|---|
| HunterMail | 1.00 cr |
| LeadMagic | 2.00 cr |
| FindyMail | 2.00 cr |
| Crustdata | 3.50 cr |
| Stops at first hit. Total: 1.00–3.50 cr/contact. |
Provider 3: BetterContact (Email or Phone)
Endpoint: POST https://app.bettercontact.rocks/api/v2/async
Auth: X-API-KEY: $BETTERCONTACT_API_KEY
Submit (max 100 contacts/batch)
{
"enrich_email_address": false,
"enrich_phone_number": true,
"data": [
{
"first_name": "Jane",
"last_name": "Doe",
"company_domain": "example.com",
"linkedin_url": "https://www.linkedin.com/in/janedoe",
"custom_fields": {"row_id": "0"}
}
]
}
Poll
GET https://app.bettercontact.rocks/api/v2/async/{request_id}
Done when: response["status"] == "terminated" (NOT "completed")
- Phone only: ~3–5 min/100 contacts. Use 900s timeout.
- Email only or both: 30–90 min. Use 3600s timeout.
on hold= credits exhausted, batch paused — no cancel endpoint.
Parse
for entry in result.get("data", []):
cf = entry.get("custom_fields", []) # ARRAY, not dict
row_id = next((f["value"] for f in cf if f.get("name") == "row_id"), "")
phone = entry.get("contact_phone_number") or ""
email = entry.get("contact_email_address") or ""
email_status = entry.get("contact_email_address_status") or ""
Key Notes
- Base URL:
app.bettercontact.rocks(notapi.) - Max 100 contacts per batch
- Submit response key:
id(nottask_idorrequest_id) statusfor completion:"terminated", not"completed"custom_fieldssent as object{"key": "val"}, returned as array:[{"name": "key", "value": "val", "position": 0}]- Email field:
contact_email_address, status:contact_email_address_status - Phone field:
contact_phone_number enriched: Falseentries can still have email values — these are low-confidence (catch_all_not_safe). Count but flag.- Summary
totalfield excludesnot_found— uselen(data)for verification
Cost
- Email only: ~0.35 cr/contact
- Phone only: ~1–2 cr/contact
- Can spike to 7+ cr/contact for enterprise audiences (complex email infra)
Docs
https://doc.bettercontact.rocks
Provider 4: Pipe0 LinkedIn URL (When Missing)
Pipe ID: people:profileurl:name@1
Request
{
"config": {"environment": "production"},
"pipes": [{"pipe_id": "people:profileurl:name@1"}],
"input": [
{"id": "1", "name": "John Smith", "company_name": "Example Inc", "location_hint": "United States"}
]
}
Cost
0.60 credits/operation
Provider 5: Pipe0 Phone Pipes
Pipe IDs:
people:phone:profile:waterfall@1— input:profile_url(LinkedIn URL)people:phone:workemail:waterfall@1— input:work_email
Cost
2.00–7.00 credits/operation
Known Coverage
- DACH: ~92% hit rate (n=74), April 2026 DACH e-commerce SME run — response field is
mobile, notphone - SA: 0% hit rate observed — skip for SA audiences
- Solid as both primary (when contacts come from Pipe0 search → no FE phone available) and FE-fallback (71% recovery on FE contacts missing phones, n=7). Use FE first if the contact already went through FE search/enrich (you've paid for it); use Pipe0 waterfall on the remainder.
Output
Updated CSV at csv/output/contacts_enriched.csv with added columns:
email, email_status, email_source,
phone, phone_source
All original columns preserved.
Troubleshooting
Pipe0: Task processing timeout
Batch too large. Keep batches ≤100 contacts. Re-poll task_id — partial results may be in records.
Pipe0: CreditBalanceInsufficient
Batch fails but credits for provider responses are consumed. Top up, save task_id, do NOT resubmit. New batch for failed records only.
BetterContact: Job never terminates
Timeout too short. Phone: 900s min. Email: 3600s min.
BetterContact: custom_fields parse error
custom_fields is an array [{"name": "row_id", "value": "0"}], not a dict.
FullEnrich: 404 on results
Wrong path. Correct: https://app.fullenrich.com/api/v2/contact/enrich/bulk/{enrichment_id}
FullEnrich: Phone field empty
Prefer contact_info.most_probable_phone.number (typically mobile) over phones[0].number (can be landline). If most_probable_phone is absent, fall back to phones[0]. If both empty, fall back to Pipe0 people:phone:profile:waterfall@1 on the LinkedIn URL.
What's Missing (To Document)
- Pipe0 phone pipe coverage by geography (currently only DACH/SA tested)
- FullEnrich v2 phone hit rates by geography
- BetterContact email-only production hit rates outside SA