Lead enrich
Research anyone by name and company. Pulls back their LinkedIn, job title, bio, company domain, and website. Works standalone with a Serper API key, no heavy setup. With Outreach Magic, it checks your pipeline so you never pay twice for the same lead data.From its SKILL.md
npx -y skills add outreachmagic/lead-enrichAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 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 file declares
Copied from the file, not written here
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
26.0 KB, ~6.1k tokens by cl100k_base, as published. Nobody here has run it
Lead Enrich
Research a person (name + company) using Serper.dev Google Search. Uses the agent's built-in model for extraction. No external LLM API needed.
Works standalone — just a Serper key. Pairs with Outreach Magic for credit-saving dedup (skip Serper when leads already have LinkedIn/email) and persistent storage across sessions.
Credit-saving (with OM): checks outreachmagic first. If the lead already has LinkedIn + email at the same company, zero Serper credits spent. LinkedIn without email skips Serper — use email-finder for trykitt. Email-only records still get LinkedIn searches (1–2 Serper credits). Name matches at a different company return
ambiguousso APIs aren't wasted.
Setup
Standalone (no OM)
Just a Serper key. Run serper-search directly. Results print as JSON.
| Key | For |
|---|---|
SERPER_API_KEY | serper.dev — Google Search API |
python3 scripts/enrich.py serper-search --query '"Acme Corp" official website'
python3 scripts/enrich.py serper-search --query 'site:linkedin.com/in Jane Doe Acme Corp'
# → pipe results to your model for extraction, or use serper-format
Set SERPER_API_KEY in your environment or via ~/.hermes/skills/lead-enrich/config.json.
With Outreach Magic (dedup + save)
Adds pre-flight dedup (skip leads already in OM) and saves structured enrichment
to your local SQLite pipeline. Requires outreachmagic skill
with pipeline.py login.
| Key | For |
|---|---|
| All standalone keys above + | |
OUTREACHMAGIC_AGENT_KEY | OM login via pipeline.py login |
python3 scripts/enrich.py check "Jane Doe" "Acme Corp" # 0 credits
python3 scripts/enrich.py batch-check --workspace W input.json # batch dedup
Save your Serper key in Outreach Magic portal → Settings → API Keys, then run
pipeline.py sync-secrets. Verify with enrich.py config (serper_api_key_source
should be agent_secrets).
New install? Run the doctor:
python3 scripts/enrich.py doctor
Checks the full chain: key presence, key validity (test query), OM install, credential files, and portal sync freshness. Tells you exactly what's missing.
3. Email finding (email-finder skill)
Email find is not in lead-enrich v2+. Install email-finder with outreachmagic using the platform install guide. Or install standalone via npx skills add outreachmagic/email-finder.
After Serper enrichment saves company_domain, run:
python3 ~/.hermes/skills/email-finder/scripts/email_finder.py find --name "Jane Doe" \
--domain acme.com --linkedin "https://linkedin.com/in/janedoe" --save
See references/email-finder.md and the email-finder skill docs.
Workspace rollups (no Serper credits): after saving leads, use outreachmagic
workspace summary --workspace <slug> --json for tag counts and LinkedIn
connection accepted per sender. On large workspaces (>2,000 leads), add
--tags-only for faster tag counts. Local DB only — pull optional.
Common workflows
| User says | You do |
|---|---|
| "Research this person" | Serper search → save via import-profiles |
| "Enrich my CSV" | batch-check / import → enrich missing fields → save to OM |
| "Find their email too" | After enrich, hand off to email-finder |
Sales Nav / Vayne CSVs: use outreachmagic import-profiles --file … (auto-detects columns).
CSV / award-list workflow (preferred for 10+ people)
Paths like outreachmagic/imports/awards.csv are relative to your workspace directory (where the agent runs the command), not the skill install folder.
# 0 credits — dedup entire file first (auto-stamps serper_attempted on complete rows)
python3 scripts/enrich.py batch-check --workspace your_workspace outreachmagic/imports/awards.csv
# Re-run dedup skipping leads already tagged serper_attempted
python3 scripts/enrich.py batch-check --workspace your_workspace --skip-tagged outreachmagic/imports/awards.csv
# Serper only for rows that need LinkedIn/domain (skip team_award, exists_linkedin_*, skipped_serper_attempted)
# After research — patch title/industry only (0 Serper credits)
python3 scripts/enrich.py backfill --fields title,industry --workspace your_workspace outreachmagic/imports/patch.csv
batch-check accepts .json or .csv. backfill requires email or linkedin per row; uses chunked import-profiles via companion_common (200 rows/chunk, up to 300s/chunk; fills empty fields; add --overwrite to replace).
When to Use
- User says "research this person" / "look up Jane Doe at Acme"
- User wants to enrich a list of prospects before outreach
- User asks "do we already have this lead?" before researching
- User provides a CSV/JSON of names and companies to enrich in bulk
- User mentions Serper, lead enrichment, or person research
Agent Behavior Rules (Important)
- Dedup first (with OM). Before any Serper API call, run
enrich.py check. If the lead has LinkedIn + email at the same company, skip Serper entirely. If LinkedIn exists but no email, skip Serper and offer email-finder when the user wants an address. Never spend Serper credits on leads already complete in outreachmagic. Standalone: skip check — runserper-searchdirectly. - Serper only. Prefer
enrich.py serper-search --query "..."(stdlib HTTP, key from config/env). Or usecurlwith$SERPER_API_KEY— never embed the key in chat logs. Never scrape Google or LinkedIn directly. - Built-in model only. You (the agent) extract JSON from Serper results. No external LLM APIs (no Gemini, no OpenAI) — your own reasoning is the extraction engine.
- Complete research before saving. Run the full search ladder first, then save once.
- Save via outreachmagic (with OM). Use
import-profilesfor leads with LinkedIn. Always appendserper_attemptedto tags on save (included automatically inmap-to-omoutput). For leads without LinkedIn but with a knownlead_id, stamp the tag viastamp-attemptedorimport-profileswithid+ tags — do not rely on notes alone. For read-only dedup checks usepipeline.py queryorenrich.py check— never rawINSERT/UPDATE. Never run both save paths for the same person. Standalone: save JSON output to a file — no OM needed. - Tag after enrichment (with OM). Every lead that goes through Serper must get
serper_attemptedon save. Prevents re-processing on future runs. - Check tag before Serper (with OM). Before spending Serper credits, check for
serper_attempted(viaenrich.py check --skip-taggedorskip_reasonin check output). If present and LinkedIn is still empty, skip unless the user explicitly wants a retry (e.g. stale >30 days). - Transparency. Show which Serper queries ran, confidence, and what was saved. The user should see exactly where their credits went.
- Batch wisely. Cap at 50 people per run. For CSV/award lists run
batch-checkonce on the whole file (JSON or CSV) before any Serper. Process Serper only for statuses that need LinkedIn/domain. Skipteam_award,exists_linkedin_email, andskipped_serper_attemptedrows. - Email finding — use email-finder. After enrichment saves
company_domain, hand off to email-finder (email_finder.py find --save). Seereferences/email-finder.md. Never fabricate or pattern-guess emails in this skill.
Quick Start
# Diagnose your setup (keys, OM, credential files)
python3 scripts/enrich.py doctor
# Single person (most common)
python3 scripts/enrich.py check "Jane Doe" "Acme Corp"
# → if "not_found", proceed with Serper search pack below
# With workspace (associates lead with your pipeline workspace)
python3 scripts/enrich.py check --workspace your_workspace "Jane Doe" "Acme Corp"
# Batch from JSON or CSV (run this before Serper on lists)
python3 scripts/enrich.py batch-check --workspace your_workspace outreachmagic/imports/people.json
python3 scripts/enrich.py batch-check --workspace your_workspace --skip-tagged outreachmagic/imports/awards.csv
# Stamp serper_attempted after failed LinkedIn lookup (when lead_id is known)
python3 scripts/enrich.py stamp-attempted --workspace your_workspace --lead-ids 42,43 \
--notes "No LinkedIn found via Serper"
# Backfill title/industry on existing leads (linkedin or email required)
python3 scripts/enrich.py backfill --fields title,industry outreachmagic/imports/patch.csv
# Update skill safely from GitHub release (checksum-verified)
python3 scripts/enrich.py update --check
python3 scripts/enrich.py update
update verifies SHA256 checksums from update-manifest.json before replacing
files. If checksums are missing or mismatched, the update aborts.
Core Workflow
Phase 1 — Dedup Check (0 credits)
For each person, run:
# Without workspace (org-wide lookup)
python3 scripts/enrich.py check "Jane Doe" "Acme Corp"
# With workspace (scoped to your pipeline workspace)
python3 scripts/enrich.py check --workspace your_workspace "Jane Doe" "Acme Corp"
Output per person:
| Status | Meaning | Action |
|---|---|---|
exists_linkedin_email | Same company, LinkedIn + email | Skip Serper and email APIs |
exists_linkedin_no_email | Same company, LinkedIn, no email | Skip Serper → email-finder if user wants email |
exists_no_linkedin_email | Same company, email, no LinkedIn | LinkedIn Serper queries only |
exists_no_linkedin | Same company, neither | LinkedIn Serper queries only |
skipped_serper_attempted | Has serper_attempted tag, no LinkedIn | Skip Serper — already tried |
ambiguous | Name match, company mismatch | Run full Serper pack — do not skip |
not_found | No match | Run full Serper search pack |
team_award | Team/group row (no individual) | Skip Serper — tag team_award, add contact note |
dedup_disabled | dedup_before_search: false in config | Run Serper as requested |
Check output includes tags and optional skip_reason (has_linkedin or
skipped_serper_attempted). Uses batch-lead-lookup (local, zero Serper credits).
Phase 2 — Serper Search Pack
Only run for people who need it. 2–4 searches per person depending on result quality:
Serper Credit Budget Estimator
- Per person minimum:
0Serper credits (exists_linkedin_email). - Common path:
2credits (2astrict company +2cLinkedIn profile). - Per person hard max:
4credits when all fallbacks are needed (2a+2b+2c+2e). - Batch formula:
min=0,max=4*NwhereNis people in the run. - Batch cap example: with
N=50, hard max is200credits (worst case).
2a. Company discovery — strict (always)
Preferred (no key in shell history):
python3 scripts/enrich.py serper-search --query '"Acme Corp" official website' --label company_discovery_strict
Or retry with a simpler query:
python3 scripts/enrich.py serper-search --query 'Acme Corp website' --label company_discovery_broad
2b. Company discovery — broad (conditional)
Run only if 2a returns no organic results with an http:// or https:// link:
python3 scripts/enrich.py serper-search --query 'Acme Corp official website' --label company_discovery_broad
(Same template, unquoted company name.)
2c. LinkedIn profile (always)
Build query (unquoted company — matches variant employer names in snippets):
site:linkedin.com/in {First Last} {up to 5 words of role} {Company Name}
Example:
python3 scripts/enrich.py serper-search \
--query 'site:linkedin.com/in Jane Doe VP Marketing Acme Corp' \
--label linkedin_profile
2d. Retry with a different Serper query (when results are empty)
If a strict query returns no useful results, try a simpler/broader one:
python3 scripts/enrich.py serper-search --query 'Acme Corp about us' --label company_discovery_about
Stay within Serper. Never use a browser, never ask the user to manually provide a
domain, and never fabricate one. If Serper returns nothing, report confidence: low
with an empty company_domain.
Phase 3 — Model Extraction
Pass the formatted Serper results to yourself (the agent model) with this system instruction:
You are a research assistant. The user message contains Serper.dev Google search
results in labeled sections. You do NOT have live web search — use only the pasted
blocks.
Task: for ONE specific person at ONE company, extract:
- company_domain: registrable hostname only (no path, no www), or empty string
- company_website: full https:// homepage URL if supported, or empty string
- linkedin_url: https://linkedin.com/in/… for the named person at this company, or empty string
- confidence: high | medium | low
- note: optional explanation of ambiguity or gaps
Rules:
- Every non-empty URL must appear in the Serper blocks (minor query-string normalization ok)
- Name on LinkedIn may differ from input (nickname vs legal name) — match on same human + employer
- Before accepting `linkedin_url`, verify match quality:
- Extract first + last name tokens from `Full name: {full_name}` (ignore punctuation)
- The chosen `/in/` result must have *both* tokens present in either the result `title` or `snippet`
- If no `/in/` result meets the token requirement, return `linkedin_url` as an empty string and set `confidence` to `low`
- Company public site may use a different banner name than input — prefer official evidence
- When resolving `company_domain`:
- Prefer `knowledgeGraph.website` when present
- Reject known aggregator/registry/catalog sites, even when returned by Serper's
knowledge graph. These are never the company's own domain:
- Business directories: `naceweb.org`, `usnews.com`, `niche.com`, `zoominfo.com`,
`bloomberg.com`, `craft.co`, `owler.com`, `crunchbase.com`
- Review sites: `yelp.com`, `g2.com`, `trustpilot.com`, `capterra.com`
- Social media: `linkedin.com`, `facebook.com`, `instagram.com`, `twitter.com`
- Reference: `wikipedia.org`, `britannica.com`
- Job boards: `indeed.com`, `monster.com`, `glassdoor.com`
- News aggregators: `prnewswire.com`, `businesswire.com`, `marketwatch.com`
- Generic email: `gmail.com`, `yahoo.com`, `hotmail.com`
- If `company_name` appears to be missing or matches the person name closely, return empty string
- Never fabricate URLs or slugs
Respond ONLY with a single JSON object (no markdown fences):
{"company_domain":"","company_website":"","linkedin_url":"","confidence":"medium","note":""}
Use this user message template:
### Target person
Full name: {full_name}
Stated role/title: {stated_role}
Company (as provided): {company_name}
### Search results
{formatted_serper_sections}
### Task
Return the JSON object described in the system instruction.
Then parse your own response: strip markdown fences, extract the JSON object.
LinkedIn harvest fallback: if linkedin_url is empty after extraction, scan
the raw Serper organic results for /in/ URLs where the title contains both
first and last name tokens. Prefer matches whose snippet/title also mention the employer/company.
Phase 4 — Save via outreachmagic
Map extracted fields to outreachmagic:
| Research field | outreachmagic field |
|---|---|
full_name | name |
stated_role | job_title |
company_name | company |
linkedin_url | linkedin |
company_domain | company_domain (structured) + optional notes |
company_website | → notes |
confidence | → notes |
note | → notes |
tags | tags (JSON array) |
import_name | → notes prefix |
If LinkedIn found:
Every import from this skill sets --source lead_enrich and --source-detail "lead-enrich" by default.
If an import_name is provided, detail appends as "lead-enrich/{import_name}".
# Org-wide (no workspace)
python3 {outreachmagic_home}/scripts/pipeline.py import-profiles \
--source lead_enrich --source-detail "lead-enrich" \
--json '[{"name":"Jane Doe","company":"Acme Corp","job_title":"VP Marketing","linkedin":"linkedin.com/in/janedoe","company_domain":"acme.com","tags":["nace","serper_attempted"]}]'
# Scoped to a workspace
python3 {outreachmagic_home}/scripts/pipeline.py import-profiles \
--workspace your_workspace \
--source lead_enrich --source-detail "lead-enrich" \
--json '[{"name":"Jane Doe","company":"Acme Corp","job_title":"VP Marketing","linkedin":"linkedin.com/in/janedoe","company_domain":"acme.com","tags":["nace","serper_attempted"]}]'
If no LinkedIn, no email:
When lead_id is known (from batch-check), stamp attempt state — do not bury
failure only in notes:
python3 scripts/enrich.py stamp-attempted --workspace your_workspace --lead-ids 42 \
--notes "No LinkedIn found via Serper"
Or via import-profiles when you also have name + company:
python3 {outreachmagic_home}/scripts/pipeline.py import-profiles \
--workspace your_workspace \
--source lead_enrich --source-detail "lead-enrich/no-linkedin" \
--json '[{"id":42,"name":"Jane Doe","company":"Acme Corp","tags":["nace","serper_attempted"],"notes":"No LinkedIn found"}]'
Without a lead_id, use add-lead with notes (last resort) or report unsaved.
Email finding (email-finder skill)
After Phase 4 save, if the user wants an email and company_domain is known,
use the email-finder companion — not this skill:
python3 ~/.hermes/skills/email-finder/scripts/email_finder.py find \
--name "Jane Doe" --domain acme.com \
--linkedin "https://linkedin.com/in/janedoe" --save
See references/email-finder.md and email-finder's email-finding-research.md.
Phase 5 — Report
Summarize per person:
Jane Doe @ Acme Corp
✅ Company: acme.com | https://acme.com
✅ LinkedIn: linkedin.com/in/janedoe
🟢 Confidence: high
💾 Saved to outreachmagic (lead #42)
🔍 Serper: 2 queries
📧 Email: use email-finder if needed
Email-only mode
When contacts are already enriched (have company_domain + linkedin in CSV or
outreachmagic) but lack email:
- Run
batch-check— process onlyexists_linkedin_no_email(and optionallynot_foundrows that already have domain in the file). - Skip Serper entirely (0 Serper credits).
- Run email-finder
batch-findfor each person with domain + LinkedIn. - Respect
trykitt_attemptedtag — email-finder skips rows already tagged. - Respect
serper_attemptedtag — lead-enrich skips rows already researched.
Useful after a prior enrichment pass or when importing a pre-researched list.
Input Formats
Single person
Research Jane Doe, VP Marketing at Acme Corp
Or with workspace:
Research Jane Doe at Acme Corp --workspace your_workspace
Batch (JSON file or inline)
{
"people": [
{"full_name": "Jane Doe", "company_name": "Acme Corp", "stated_role": "CEO"},
{"full_name": "John Smith", "company_name": "Beta Inc"}
],
"workspace": "your_workspace",
"tags": ["nace"],
"import_name": "NACE 2026 attendee"
}
Max 50 people per run.
Config Reference
config.json (copy from config.example.json):
| Key | Required | Default | Description |
|---|---|---|---|
serper_api_key | Yes* | — | Serper.dev API key (*portal → agent_secrets.env via sync-secrets) |
serper_endpoint | No | https://google.serper.dev/search | API endpoint |
outreachmagic_home | No | auto-detect | Path to outreachmagic skill |
max_people_per_run | No | 50 | Batch size limit |
dedup_before_search | No | true | Check outreachmagic before Serper |
serper_num_results | No | 10 | Results per Serper query |
serper_gl | No | us | Country code |
serper_hl | No | en | Language |
Enrichment query patterns (SQL)
Use via pipeline.py query --sql '…' --params '[…]' --json.
Leads that still need Serper (no LinkedIn, not yet attempted):
SELECT l.id, l.name, l.company
FROM leads l
JOIN workspace_lead_tags n ON n.lead_id = l.id AND n.tag = ?
JOIN workspaces w ON n.workspace_id = w.id
WHERE w.slug = ?
AND (l.linkedin_url IS NULL OR l.linkedin_url = '')
AND l.id NOT IN (
SELECT lead_id FROM workspace_lead_tags
WHERE tag = 'serper_attempted' AND workspace_id = w.id
)
Params: ["nace", "your_workspace"]
Enrichment attempted but failed (retry-eligible when stale):
SELECT l.id, l.name, l.company, l.updated_at
FROM leads l
JOIN workspace_lead_tags s ON s.lead_id = l.id AND s.tag = 'serper_attempted'
JOIN workspaces w ON s.workspace_id = w.id
WHERE w.slug = ?
AND (l.linkedin_url IS NULL OR l.linkedin_url = '')
AND l.updated_at < datetime('now', '-30 days')
Params: ["your_workspace"]
What this skill does NOT do
- ❌ Call Outreach Magic person-research API (
/v1/person-research) - ❌ Use external LLM APIs (Gemini, OpenAI, etc.) for extraction
- ❌ Scrape HTML pages or LinkedIn directly
- ❌ Guess email addresses (use email-finder skill)
- ❌ Write raw SQL or ad-hoc DB mutations (use
import-profiles/add-lead; reads viapipeline.py query) - ❌ Upload to remote servers (local-only by default)
Platform Support
| Platform | Install | Skill path |
|---|---|---|
| Hermes | outreachmagic/outreachmagic — install.sh --platform hermes | ~/.hermes/skills/lead-enrich/ |
| Cursor | install.sh --platform cursor | ~/.cursor/skills/lead-enrich/ |
| Claude Code | install.sh --platform claude | ~/.claude/skills/lead-enrich/ |
Hermes: Real files live under ~/.hermes/skills/. Each profile uses symlinks only (profiles/<name>/skills/lead-enrich → ../../../skills/lead-enrich). Do not copy the skill into a profile directory.
Learn more at outreachmagic.io.
Troubleshooting
| Problem | Fix |
|---|---|
| "SERPER_API_KEY not set" on first run | Run enrich.py doctor — tells you exactly where to get the key and how to sync |
| Stale skill or empty DB | Re-run install.sh --platform <name>. Check pipeline.py paths for warning. |
| "No outreachmagic found" | Set outreachmagic_home in config.json to the absolute path |
| Serper 400 "not allowed" | Query too restrictive — fallback to simpler template |
import-profiles rejects row | Requires email, LinkedIn, or id (lead_id). Use stamp-attempted for tag-only updates |
| Serper credits wasted on re-runs | Use batch-check --skip-tagged; ensure serper_attempted is stamped on save |
| Empty extraction | Serper results too thin — try broad queries, or mark confidence low |
ambiguous on check | Name matched wrong company — run Serper or check --force |
| Team / group award row | batch-check returns team_award — skip research |
| outreachmagic not found | Install outreachmagic/outreachmagic or set outreachmagic_home |
| Need email find | Installed with the suite — see references/email-finder.md |
| Dead Serper key (slot 0 fails every time) | After 3+ consecutive failures, the slot is auto-retired: [outreachmagic] serper: Primary (slot 0) retired after 3+ failures. Remove the dead key in portal → Settings → API Keys, then pipeline.py sync-secrets. Check enrich.py config for key pool status. |
| Wrong domain extracted (aggregator site) | validate_company_domain() rejects known aggregators before saving. Look for [rejected_domain: ...] in notes. Use map-to-om --preview to see the full table before importing. |
What ships with it: 10 files
122.2 KB alongside SKILL.md, 2 of them executable
references/
- email-finder.md448 B
scripts/
- companion_common.pyruns38.9 KB
- enrich.pyruns71.6 KB
- config.example.json570 B
- default.env184 B
- .gitignore56 B
- LICENSE1.0 KB
- README.md6.0 KB
- SECURITY.md2.2 KB
- update-manifest.json1.1 KB