| name | linkedin-outreach |
| description | Run a full LinkedIn outreach campaign via the LinkupAPI MCP โ define an ICP (Ideal Customer Profile), find target companies, extract real decision makers (CEO/CTO/Founder), filter false positives, send connection requests, and set up automated follow-up via webhooks. Use when the user says "outreach campaign", "prospect on LinkedIn", "find decision makers", "send connection requests", "build a target list", "campaign LinkedIn", "lead generation", "ICP", "target CEO/CTO", or describes a B2B outreach goal. Requires the `linkupapi` MCP server to be connected with at least one LinkedIn account (status `connected`). |
LinkedIn Outreach Campaign โ LinkupAPI V2
Use this skill to run cold-outreach campaigns on LinkedIn via the linkupapi MCP. It codifies the full prospecting โ connect โ follow-up playbook in 7 stages, with rate-limit awareness and false-positive filtering built in.
Required prerequisites โ check before you start
- The user must have the
linkupapi MCP connected. Verify with linkupapi_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_login directly (platform=linkedin, with email+password OR a login_token). On checkpoint_required โ run linkupapi_checkpoint.
- If a webhook for the chosen account(s) doesn't exist with the
accepted_invitation event, propose to create one (linkupapi_create_webhook) so accepts trigger automated follow-up.
Daily LinkedIn safety caps (MANDATORY โ enforced before Stage 1)
Per LinkedIn account, this skill respects:
- 20 connection requests / day (invites)
- 100 profile gets / day (
linkedin_profiles get)
- 15 searches / day (
search_people / search_companies)
These are shared budgets across ALL linkedin- skills* (linkedin-outreach, linkedin-high-intent, linkedin-feed-engage, linkedin-enrich). Hitting the cap from any one skill carries over.
Before Stage 1, run linkupapi_get_logs filtered to the last 24h on the chosen account. Count today's:
linkedin_network/invite calls โ invites used today
linkedin_profiles/get calls โ profile gets used today
linkedin_profiles/search_* calls โ searches used today
Compute remaining budget per category. Surface in the Stage 0 echo:
Daily caps remaining today (account {email}):
Invites: X / 20 (Y used today)
Profiles: X / 100 (Y used today)
Searches: X / 15 (Y used today)
Hard rules:
- If
Volume cap > remaining_invites โ trim Volume cap to remaining_invites (never override).
- If
searches_needed > remaining_searches (Stage 1 + Stage 3 combined) โ reduce Stage 1 limit and number of search_people calls until it fits, or stop and ask the user to run tomorrow.
- If
profile_gets_needed > remaining_profiles (Stage 4) โ enrich the highest-priority candidates and write the rest to a "deferred" list in the campaign log.
If linkupapi_get_logs is unavailable or empty, fall back to scanning today's ./campaigns/*.md and ./enrichments/*.md for evidence of API calls and counting.
Stage 0 โ Discover intent, then derive the ICP, then validate
Don't run any MCP tool yet. Stage 0 has three steps and is non-negotiable โ it's what protects the user from a wasted run on the wrong audience.
The skill does NOT start with company filters. It starts with why the user is doing outreach. The flow branches from there: a recruiter targets people directly, a salesperson targets companies first then decision makers, a networker targets people by topic/role.
Step 0.1 โ Ask intent (single question)
Use AskUserQuestion with exactly this question (don't bundle other questions yet โ the answer drives every subsequent option list):
questions:
- header: "Goal"
question: "What's the goal of this outreach campaign?"
multiSelect: false
options:
- { label: "Sell to companies", description: "B2B sales โ find companies that match an ICP, then reach their decision makers (CEO/CTO/Head of โฆ)" }
- { label: "Recruit talent", description: "Hiring โ find individual candidates by role / skill / seniority, no company-level search" }
- { label: "Network / partnerships", description: "Connect with peers, investors, partners, or thought leaders by topic / role / community" }
- { label: "Custom (I'll explain)", description: "Free-text โ describe the goal in one sentence" }
If AskUserQuestion is unavailable, ask in plain text: "Quick check before I build the campaign โ what are you trying to do? (1) sell to companies, (2) recruit talent, (3) network/partnerships, (4) something else."
Step 0.2 โ Derive a draft ICP from the intent
Based on the chosen goal, propose a draft ICP in plain text (don't ask yet โ show what you'd build). Pick reasonable defaults from the user's product/repo context if available, otherwise leave placeholders.
| Goal | Flow used | What you propose |
|---|
| Sell to companies | Stage 1 โ 2 โ 3 โ 4 โ 6 | Theme/keywords, target sector, target company size, target geo, decision-maker titles |
| Recruit talent | Skip Stage 1โ2. Go directly to Stage 3 (search_people) | Role title(s), seniority level, skills/keywords, location, optionally past_company (poach from competitor) or school_url (alumni) |
| Network / partnerships | Skip Stage 1โ2. Go directly to Stage 3 (search_people) | Topic/keyword, role title(s), location, optionally follower_of or connection_of for warm paths |
| Custom | Ask the user to describe in one sentence, then map to one of the three flows above | โ |
Render the draft as a clean block, e.g. for "Sell to companies":
Here's the ICP I'd build โ tell me what to change:
Goal: Sell to companies
Theme: AI recruitment / RecTech
Target sectors: Software Development, Human Resources Services
Company size: 11-50, 51-200
Geo: France
Personas: CEO / Founder, CTO / VP Engineering
Volume: 25 invites (warm-up)
Note: No note (best accept rate on cold C-level)
Message after accept: Auto-generate a short personalized message per connection
Step 0.3 โ Let the user edit the filters (batched form)
Now use AskUserQuestion with the filter set that matches the chosen flow. Pre-select sensible defaults from Step 0.2 by listing them first / marking (Recommended). The form tool caps at 4 questions per call, so split into 2 batches if needed.
For "Sell to companies" โ ask the company-side filters first, then persona + tactics:
Round A (audience): Theme ยท Sectors ยท Company size ยท Geo
Round B (execution): Persona titles ยท Volume ยท Note style ยท Message-after-accept
For "Recruit talent" โ skip company filters entirely:
Round A (candidate): Role titles ยท Seniority ยท Skills/keywords ยท Location
Round B (execution): Volume ยท Note style ยท Message-after-accept ยท Sourcing twist (past_company / school_url / none)
For "Network / partnerships" โ also skip company filters:
Round A (audience): Topic/keyword ยท Role titles ยท Location ยท Warm-path twist (follower_of / connection_of / none)
Round B (execution): Volume ยท Note style ยท Message-after-accept
Step 0.4 โ Pick the sending account(s)
After the ICP is locked, run linkupapi_list_accounts and present the accounts to the user via AskUserQuestion with multiSelect: true. Each option label is the account's display name; description shows status + email/handle. Only show accounts with status = connected.
question: "Which LinkedIn account(s) should send the invites?"
multiSelect: true
options:
- { label: "<account name 1>", description: "<email> โ connected" }
- { label: "<account name 2>", description: "<email> โ connected" }
...
If the user picks multiple accounts, the campaign is split across them and the same person must NEVER be invited from two different accounts (LinkedIn flags duplicate invites and accept rate collapses). Enforce this:
- Pool all candidate profiles after Stage 4.
- Deduplicate by
profile_url.
- Round-robin assign each unique candidate to exactly one sending account.
- Cap each account at the per-session/weekly safe limits independently (80/session, 100/rolling week per account).
- In Stage 6, run the
check_invitation and invite calls with the assigned account_id for each candidate.
After this step, echo the final ICP + account selection back in 4โ6 lines and ask "Should I proceed?" (yes/no). Do NOT touch any further MCP tool until the user confirms.
How the answers map to the next stages
| Goal | Stage 1 (companies) | Stage 3 (people) | Notes |
|---|
| Sell to companies | runs with keyword+sector+location+company_size | runs with company_url (from Stage 1 results) + title | Full 7-stage flow |
| Recruit talent | skipped | runs with title+location+keyword (skills) + optional past_company / school_url | Stage 4 still runs (verify current role) |
| Network / partnerships | skipped | runs with title+location+keyword + optional follower_of / connection_of | Stage 4 still runs |
| Generic answer | Used in |
|---|
| Volume | Hard cap on Stage 6 invite count, also informs limit in Stage 1/3 (Volume ร 2.5 โ candidates needed since some are filtered) |
| Note style | Stage 6 invite.params.message |
| Message-after-accept | Stage 7 webhook + send-message-on-accept logic (auto-personalized / custom template / none) |
Stage 1 โ Find target companies
Tool: linkedin_profiles action search_companies.
The V2 exposes exactly 4 filters on companies (this is the full list โ there's no industry filter on companies, use sector). Pass values as JSON arrays for OR-stacking โ that's the V2-native syntax. Plain strings are also accepted (single value).
| Param | Type | Purpose |
|---|
keyword | array of strings | Free-text match (name, description, tagline). Example: ["AI recruitment", "applicant tracking"] |
sector | array of strings | LinkedIn industry of the company. Example: ["Software Development", "Internet", "Computer Software"] |
location | array of strings | Geo of the HQ / branch. Example: ["France", "United Kingdom"] |
company_size | array of strings | Employee bracket. Allowed values: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001+ |
Filter combination semantics:
- Multiple values inside one array โ OR (
["AI recruitment", "ATS"] = either match)
- Different fields โ AND (
sector=[...] AND company_size=[...] AND โฆ)
{
"account_id": "<from list_accounts>",
"action": "search_companies",
"params": {
"keyword": ["AI recruitment", "recruiting copilot", "AI sourcing"],
"sector": ["Software Development", "Human Resources Services"],
"location": ["France", "United Kingdom"],
"company_size": ["11-50", "51-200"],
"limit": 25
}
}
- Pagination:
offset + limit (preferred) or start_page + end_page.
- Counts against the 15 searches / day cap.
- The result objects come back with
industry and location split out (V2 post-processing splits "industry โข city" into separate fields), so filtering in Stage 2 is straightforward.
- Save the raw list, then in Stage 2 manually filter out non-fits (staffing agencies dressed as tech, pure consultancies, etc.).
Stage 2 โ Filter companies (LLM judgment)
Look at each result's industry, name, location, employee count. Drop:
- Companies whose industry is
Staffing and Recruiting if the user wants RecTech vendors (not agencies).
- Companies whose name suggests they are a service buyer of the user's product, not a fit.
- Duplicates (same root domain) and obvious test/personal pages.
Show the filtered shortlist to the user before continuing if filtering is aggressive (>50% dropped). Otherwise continue.
Stage 3 โ Extract decision makers per company
Tool: linkedin_profiles action search_people.
Use title, NOT keyword, to filter on roles. keyword matches anywhere in the profile (headline, posts, summary) โ too noisy. title matches the current job title only โ precise.
The V2 exposes 13 filters on people search. Pass values as JSON arrays for OR-stacking; different fields are AND-combined. (Plain strings are accepted for single values too.)
| Param | Type | Purpose | Example |
|---|
title | array | Current job title โ use this for personas | ["CEO", "CTO", "Co-Founder", "Founder", "Chief Executive Officer"] |
company_url | array | Filter to current employees of one or more companies | ["https://www.linkedin.com/company/talentify-io/"] |
company_name | array | Same but by company name string | ["Talentify", "Workable"] |
location | array | Geo of the person | ["France", "United Kingdom", "Paris"] |
industry | array | LinkedIn industry of the person | ["Software Development", "Human Resources"] |
network | string | Connection degree โ single value | "F" (1st), "S" (2nd), "O" (3rd+). Use "S" for best accept rate |
keyword | array | Free-text full-profile search | Only when role isn't well-defined by title |
school_url | array | Alumni filter โ people who studied at X | ["https://www.linkedin.com/school/hec-paris/"] |
past_company | array | Ex-employees of a company (e.g. competitor) | ["https://www.linkedin.com/company/competitor/"] |
connection_of | array | People connected to a specific profile | ["https://www.linkedin.com/in/mutual-friend/"] |
follower_of | array | Followers of a thought leader / brand | ["https://www.linkedin.com/in/influencer/"] |
first_name | array | Look-alike search | ["Othamar", "Sameer"] |
last_name | array | Look-alike search | ["Magiatis"] |
For a standard outreach campaign, the high-precision combo is:
{
"account_id": "...",
"action": "search_people",
"params": {
"company_url": ["<exact company_url returned by Stage 1>"],
"title": ["CEO", "CTO", "Co-Founder", "Founder"],
"network": "S",
"limit": 5
}
}
CRITICAL โ never fabricate company_url. Use the exact company_url value LinkupAPI returned in Stage 1 (search_companies results). Do NOT guess or build one from the company name (e.g. inferring linkedin.com/company/acme/ from "Acme Inc" โ slugs often differ: acme-corp, acme-io, acme-tech). A fabricated URL silently returns 0 results or worse, employees of a different company with a colliding slug. Always copy the URL field from the Stage 1 result object verbatim.
network: "S" (2nd degree) is gold โ these people share at least one mutual connection with the user, which dramatically boosts accept rate (LinkedIn shows the mutual on the invite). Drop network only if the user wants 3rd-degree volume.
Power-user patterns
- Recruit ex-Acme talent:
{"past_company": ["https://linkedin.com/company/acme"], "title": ["Engineer", "Senior Engineer"]}
- Alumni outreach to HEC grads in product roles:
{"school_url": ["https://linkedin.com/school/hec-paris/"], "title": ["Product Manager", "CPO"]}
- Warm intro via mutual:
{"connection_of": ["https://linkedin.com/in/your-investor"]}
- Audience of a thought leader:
{"follower_of": ["https://linkedin.com/in/famous-vc"]}
Operational notes
- Each
search_people call counts against the 15 searches / day cap.
- Run calls sequentially in a bash loop (not parallel) with 1โ2 sec sleeps. LinkedIn rate-limits bursts.
- Profiles shown as
LinkedIn Member are private/anonymous โ discard. Their URL contains /search/results/people/headless?... not /in/<handle>.
Stage 4 โ Enrich profiles & filter false positives (CRITICAL)
LinkedIn search_people regularly returns ex-employees of a company because they appear on the company page from past employment. You MUST verify the current role.
Tool: linkedin_profiles action get with params.identifier=<linkedin handle>.
{
"account_id": "...",
"action": "get",
"params": {"identifier": "<handle from profile_url>"}
}
- Each
get call counts against the 100 profile gets / day cap.
- For each enriched profile, parse
experience[0] (the current job). Keep only if experience[0].company matches (or is similar to) the target company AND experience[0].title matches the persona titles.
- Common false positives to drop:
- Person now at a different company (LinkedIn tagged them due to past employment).
- Person with title "Software Engineer" / "Solutions Architect" when targeting CEO/CTO.
- Generic "Founder @ Stealth" entries (not the target boรฎte).
- Surface to the user a clean two-column table: โ
confirmed targets vs โ false positives with the real current job.
Stage 5 โ (Optional) Find professional emails
If the user wants email-based outreach in parallel to LinkedIn, use linkupapi_enrich:
{"action": "find_email", "params": {"linkedin_url": "<profile_url>"}}
or
{"action": "find_email", "params": {
"first_name": "...", "last_name": "...",
"company_domain": "..."
}}
- Skip this step if pure LinkedIn outreach.
Stage 6 โ Pre-flight check & send invites
Before sending every connection request, check the current relationship via linkedin_network action check_invitation to avoid:
- Re-inviting someone already connected (1st degree)
- Re-sending while a previous invitation is
PENDING
- Sending invites to
OUT_OF_NETWORK profiles (rejected by LinkedIn anyway)
{
"action": "check_invitation",
"params": {"profile_url": "<full linkedin url>"}
}
The response includes invitation_state which is one of:
NO_INVITATION โ safe to invite
PENDING โ already sent, skip
CONNECTED (member_distance=1) โ already connected, skip
- Other values โ log and skip
Then for every NO_INVITATION profile, send the invite:
{
"account_id": "...",
"action": "invite",
"params": {
"identifier": "<handle>",
"message": "<optional โค300 char personal note>"
}
}
- Sleep 1โ2 seconds between invites (
sleep 1 in bash). LinkedIn flags burst patterns.
- Hard cap at 80 invites per session, 100 per rolling week per account.
- After the batch, run
linkedin_network action list_sent with count=20 to confirm the invites appear as "Sent today".
Message templates (when user wants a note)
Keep notes โค 250 chars. Patterns that work:
- Reference shared signal:
Hi {first_name}, I saw {Company}'s focus on {theme} โ same space we're operating in. Would love to compare notes. โ {sender_first_name}
- Genuine compliment + soft ask:
Hey {first_name}, your work at {Company} on {topic} caught my eye. Always glad to connect with operators in {space}.
- Mutual connection:
Hi {first_name}, {mutual_name} suggested I reach out. We're tackling {problem} from the {angle} side โ open to a quick exchange?
Avoid: pitchy openers, "quick question?", "5 minutes of your time?", and any link in the note (LinkedIn flags these).
Stage 7 โ Send message after accepted invitation (webhook-triggered)
If the user doesn't already have a webhook for accepted_invitation on the chosen account(s), create one:
{
"tool": "linkupapi_create_webhook",
"args": {
"account_id": "...",
"events": ["accepted_invitation", "message_received"]
}
}
- Hosted mode (no
url) โ events are stored, fetch via linkupapi_get_webhook_events (poll) or via SSE on the stream_url.
- Custom mode (with
url) โ events POSTed to your endpoint.
For each accepted invitation event you'll receive:
{
"type": "accepted_invitation",
"new_connections": [{"profile_url": "...", "name": "...", "job_title": "..."}]
}
When the user wants to send a message as soon as the invitation is accepted:
{
"tool": "linkedin_messages",
"args": {
"account_id": "...",
"action": "send",
"params": {
"profile_url": "<from event>",
"message_text": "<personalized message>"
}
}
}
Message-after-accept options (asked in Step 0.3 form)
The "Message-after-accept" question in Step 0.3 has exactly three options โ present them in this order:
-
Generate a short, punchy, personalized message (Recommended) โ for each accepted invite, build a custom 2โ4 sentence message using the connection's name, job_title, current Company (from Stage 4 enrichment), and the campaign theme. Do NOT send a generic "thanks for connecting" โ it tanks reply rate. Aim for: a specific hook tied to their work + one sharp value-prop sentence + a low-friction question. Keep โค 600 chars. Generate at send-time, not in advance, so the message references real profile context. Example shape:
{first_name}, saw you're {role} at {Company} โ we just shipped a way for teams like yours to {specific value tied to theme}. Curious if {pain_point_question}?
-
Custom template provided by the user โ the user pastes a template with {first_name}, {Company}, {job_title}, {theme} placeholders. Substitute and send verbatim.
-
No message โ webhook still arms (so accepts are tracked) but no DM is sent. The user handles messaging manually.
Send timing: fire the message immediately when the accepted_invitation event arrives (no artificial 24h wait โ modern outreach plays react fast, and the value-add hook makes the message feel intentional, not robotic).
Stage 8 โ Report & persist (mandatory)
At the end of every campaign run, give the user a concise summary AND always persist a full log file. Don't ask the user โ just write it. The log is the source of truth for funnel analysis, accept-rate tracking, and de-duplication on the next campaign.
8a โ Concise on-screen summary
Campaign: {ICP description}
Account: {account_email} ({account_id})
Companies searched: {n}
Companies kept: {n} ({n_dropped} filtered out)
Profiles enriched: {n}
Decision makers: {n_confirmed} (โ
) / {n_false_pos} (โ)
Skipped (already inv): {n}
Invitations sent: {n}
Daily caps remaining: invites X / 20 ยท profiles Y / 100 ยท searches Z / 15
Webhook armed: yes/no
8b โ Persist the campaign log
Write the full campaign details to ./campaigns/{YYYY-MM-DD}-{slug}.md where slug is a 2-3 word kebab-case theme (e.g. sales-rectech, recruit-engineers-paris, vc-network-fr). If ./campaigns/ doesn't exist, create it.
The log file MUST include:
- ICP block โ every Stage 0 answer (goal, theme, sectors, sizes, geo, personas, volume, note style, message-after-accept).
- Account(s) used โ id + email + country. If multiple accounts, list per-account assignment.
- Funnel table โ counts per stage.
- Companies kept (Stage 2) โ names only, comma-joined.
- Companies dropped (Stage 2) โ name + 1-line reason each.
- False positives dropped pre-enrichment (Stage 3 โ 4) โ name + reason.
- Skipped (already invited / connected) โ name + state + invitation_id if available.
- Invites sent table โ
# | Name | Company | Network | invitation_urn. The invitation_urn is the key for later withdraw calls and accept-tracking.
- Notes / monitoring โ any clusters (multiple invites in same small company), 3rd-degree count, and what to watch for in the next 48-72h. Include the snippet for arming a webhook later if Message-after-accept was "No message".
This file is what the user reads in 7 days to compute accept rate and decide whether to scale or change the ICP.
8c โ De-dup on next campaign
When the user runs /linkedin-outreach again on the same account, before Stage 6, glob ./campaigns/*.md for invitation_urn/profile_url/public_identifier of any candidate in the new batch. If a profile was already invited in any prior log, drop it from the invite list (even if check_invitation returns NO_INVITATION โ that means LinkedIn auto-withdrew an old invite or the prior one was rejected; re-inviting too fast tanks accept rate). Surface the dedup count in the on-screen summary.
Common pitfalls
- Fabricating
company_url in Stage 3 โ never construct a LinkedIn company URL from the company name. Always reuse the exact company_url returned by Stage 1's search_companies. Slugs are unpredictable (acme-corp vs acme-io vs acme-tech); a guessed URL returns 0 results or worse, employees of a different company.
- Inviting from
LinkedIn Member URL โ those are anonymized, the URL contains /search/results/people/headless?... not /in/<handle>. The invite call will fail. Filter them in Stage 4.
- Trusting search_people's listed company โ always verify current role in Stage 4. ~30โ50% of
search_people results in larger companies are ex-employees.
- Sending invites in parallel โ LinkedIn rate-limits. Use a sequential loop with sleep.
- Greek/Cyrillic identifiers โ when the URL is URL-encoded (
https://www.linkedin.com/in/%CE%B4...), pass the raw handle to identifier, not the URL.
- Forgetting to confirm ICP โ running a campaign on the wrong persona is bad UX. Always echo the ICP in plain text and wait for "yes" before Stage 1.
Tool quick reference
| What you want | Tool | Action | Daily cap |
|---|
| List my connected accounts | linkupapi_list_accounts | โ | โ |
| Login to a new LinkedIn account | linkupapi_login / linkupapi_checkpoint | โ | โ |
| Find target companies | linkedin_profiles | search_companies | 15/day shared |
| Find people in a company | linkedin_profiles | search_people | 15/day shared |
| Get full profile info | linkedin_profiles | get | 100/day shared |
| Find professional email | linkupapi_enrich | find_email | โ |
| Validate an email | linkupapi_enrich | validate_email | โ |
| Reverse email โ person | linkupapi_enrich | reverse_email | โ |
| Check invitation status | linkedin_network | check_invitation | โ |
| Send connection request | linkedin_network | invite | 20/day |
| List sent invitations | linkedin_network | list_sent | โ |
| Send a DM | linkedin_messages | send | โ |
| Create webhook | linkupapi_create_webhook | โ | โ |
| Poll webhook events | linkupapi_get_webhook_events | โ | โ |
| Check daily usage | linkupapi_get_logs | โ | run at Stage 0 |
Default campaign template
Stage 0 โ confirm ICP with user + daily-cap budget check
Stage 1 โ search_companies (counts against searches/day cap)
Stage 2 โ LLM filter to ~12-18 real fits
Stage 3 โ search_people on each (counts against searches/day cap)
Stage 4 โ get on every candidate (counts against profiles/day cap)
Stage 5 โ skip (LinkedIn-only campaign)
Stage 6 โ check_invitation + invite on confirmed targets (counts against invites/day cap)
Stage 7 โ ensure webhook is armed
Stage 8 โ report + persist log