Install
$ agentstack add skill-vivekkhimani-gtm-tools-template-gtm-people-enrichment ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
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 + companyname + locationhint - 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
https://docs.fullenrich.com
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 (catchallnot_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
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: vivekkhimani
- Source: vivekkhimani/gtm-tools-template
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.