Bulk LinkedIn URL Enrichment
Given N LinkedIn profile URLs, this skill returns enriched data: current role, company, location, experience timeline, skills, and email when findable. Output as a markdown table, CSV, or JSON.
Required prerequisites — check before you start
linkupapiMCP connected. Verify withlinkupapi_list_accounts.- If the list is empty, tell the user they have two options to connect a LinkedIn account, then stop and wait:
- Hosted UI — open https://app.linkupapi.com/account-connection (fastest, handles checkpoints in-browser).
- MCP login — run
linkupapi_logindirectly (platform=linkedin, with email+password OR alogin_token). Oncheckpoint_required→ runlinkupapi_checkpoint.
- If the list is empty, tell the user they have two options to connect a LinkedIn account, then stop and wait:
- Pick the sending account before Stage 1. After confirming at least one
status = connectedaccount exists, present the connected accounts viaAskUserQuestion(single-select). Each option label is the account display name; description shows email + country. The chosenaccount_idis used for thelinkedin_profiles getcalls in Stage 2 (which consume the 100 profile gets / day cap on that account). Thelinkupapi_enrichactions (find_email,validate_email) don't require anaccount_id— they use LinkUp's managed pipeline. - A list of LinkedIn URLs from the user (inline, file path, or copy-paste).
Daily LinkedIn safety caps (MANDATORY — enforced)
- 100 profile gets / day — hard ceiling for this skill
- 15 searches/day — not relevant (no search calls)
- 20 invites/day — not relevant (no invites)
Before Stage 1, run linkupapi_get_logs for last 24h on the chosen account, count today's linkedin_profiles/get. The skill enriches only min(N, 100 - already_used_today). If user provides 50 URLs but 80 profile gets are already used today, enrich the 20 most-prioritized and defer the rest.
If linkupapi_get_logs is unavailable, fall back to scanning today's ./campaigns/*.md and ./enrichments/*.md for linkedin_profiles/get evidence and counting.
Stage 0 — Collect input
AskUserQuestion:
- URL source:
- Paste list inline (one URL per line)
- Path to a file (
.csv/.txt/.md— agent reads viaRead)
- Email enrichment: yes / no
- Validate emails: yes / no (recommended yes for paid lists)
- Output format:
- Markdown table inline (default for ≤50 URLs — readable in chat)
- CSV file →
./enrichments/{date}-{slug}.csv - JSON file →
./enrichments/{date}-{slug}.json
- Output fields — checkboxes:
- Always: name, current title, current company, location, headline
- Optional: industry, full experience timeline, skills, education, profile_picture_url
- If email enabled: email, validation status
Echo the plan with daily-cap math:
URLs provided: 50
URLs after dedupe: 47
Profile budget today: 100 used / 100 cap → 0 remaining 🚫 STOP
OR
Profile budget today: 20 used / 80 remaining → enrich up to 47 ✓
Output format: CSV → ./enrichments/2026-04-27-leads-batch-1.csv
Wait for "yes" before continuing.
Stage 1 — Parse and dedupe URLs
Extract the LinkedIn handle from each URL (/in/<handle> or /in/<handle>/). Drop:
- Duplicates (same handle)
/company/URLs (this skill is profiles only — surface them in a separate "skipped: company URLs" list)LinkedIn Memberanonymized URLs (URL containsheadless) — cannot be enriched- URL-encoded paths — extract raw handle (decode percent-encoding)
Show the user the parsed count vs original. If >20% were dropped, ask for confirmation before continuing (likely a bad input list).
Stage 2 — Profile enrichment
Tool: linkedin_profiles get with identifier=<handle>, sequential with 2-3s sleep between calls (longer than outreach skill — bulk enrichment patterns are more closely watched by LinkedIn).
HARD CAP at remaining 100/day budget. If reached mid-run, save partial results, mark remaining as "deferred to tomorrow", and stop gracefully.
For each profile, extract per the user's chosen output fields. Always capture:
first_name,last_name,headlineexperience[0].company→current_companyexperience[0].title→current_titleexperience[0].company_url→ derivecompany_domainfor email lookup (extract last path segment of LinkedIn company URL → match against known domain registry, OR pass the company name to email finder)location,industry
Mark a profile as enrichment_failed (not budget-exhausted) if the API returns 404 / private / member-removed. Don't retry.
Stage 3 — Email enrichment (if requested)
Tool: linkupapi_enrich action find_email.
Mode A — by LinkedIn URL (cleanest):
{"action": "find_email", "params": {"linkedin_url": "<full url>"}}
Mode B — by name + company domain (fallback when Mode A returns nothing):
{"action": "find_email", "params": {
"first_name": "...", "last_name": "...",
"company_domain": "<domain>"
}}
No LinkedIn-side cap (it's not a LinkedIn API call), but enforce sane volume — stop if more than 200 in a single run unless user explicitly approves.
For each profile that returns an email, run linkupapi_enrich validate_email if user opted in (catches typos and dead inboxes). Skip validation on profiles where find_email returned nothing.
Stage 4 — Output
Build the chosen output format.
Markdown table (≤50 URLs, default):
| # | Name | Title | Company | Location | Email | Valid |
|---|------|-------|---------|----------|-------|-------|
| 1 | Oleksii Kratko | Founder/CEO | Snov.io | Toronto | oleksii@snov.io | ✓ |
CSV (>50 or chosen): write to ./enrichments/{date}-{slug}.csv with header row, one row per profile (including failed enrichments with reason).
JSON: array of objects with all enriched fields, suitable for re-import into other workflows.
Show the user the output path + a preview of the first 5 rows. For inline markdown, render the table directly.
Stage 5 — Persist & report (mandatory)
Always write a log regardless of output format:
./enrichments/{YYYY-MM-DD}-{slug}.md:
- Input source (file path or "inline N URLs")
- URL counts: provided / deduped / company URLs separated / anonymous dropped / enriched / failed / deferred
- Email match rate (X / N profiles got an email)
- Email validation rate (Y / X validated)
- Failed enrichments table:
url | reason(private / 404 / removed) - Deferred URLs table (for tomorrow's run)
- Daily caps remaining
- Output file path (CSV / JSON if produced)
The log lets the user re-run failed/deferred ones tomorrow without losing track.
De-dup with previous enrichments
Before Stage 2, glob ./enrichments/*.md and ./enrichments/*.json for any profile_url already enriched in the last 30 days. Reuse the cached enrichment instead of re-paying for the get call. Surface the dedup count and savings in the Stage 0 echo.
This is the single biggest cost saver — repeat enrichments are a waste.
Common pitfalls
- Anonymized URLs:
/in/headless?...can't be enriched. Filter at Stage 1. - URL-encoded handles (Greek/Cyrillic etc.): pass the decoded handle to
identifier, not the URL. - Email find rate: when a prospect has a discoverable professional email, LinkupAPI finds it ~100% of the time. Misses are people whose work email is genuinely not discoverable (private domain, freelancer without a corporate inbox, scrubbed records) — not a tool failure. Frame results to the user as "X / N had a discoverable email", not "X% find rate".
- Daily cap mid-run: if email is requested, account for BOTH profile + email budget at Stage 0. Better to enrich 80 fully than start 100 and hit cap mid-email-pass.
- Reusing enrichment for outreach: this skill's output feeds directly into
linkedin-outreachStage 4 verification. The outreach skill should check./enrichments/first to avoid re-paying for the sameget. - Personal Gmail returned:
find_emailsometimes returns personal email (e.g.firstname@gmail.com) instead of professional. Surface both when both exist and let the user choose downstream.
Tool quick reference
| Tool | Action | Daily cap |
|---|---|---|
linkedin_profiles |
get |
100/day shared |
linkupapi_enrich |
find_email |
— |
linkupapi_enrich |
validate_email |
— |
linkupapi_enrich |
reverse_email |
(not used here) |
linkupapi_get_logs |
— | run at Stage 0 |