linkedin-outreach — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited linkedin-outreach (Agent Skill) and scored it 92/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 2 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 2 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
linkupapi MCP connected. Verify with linkupapi_list_accounts.linkupapi_login directly (platform=linkedin, with email+password OR a login_token). On checkpoint_required → run linkupapi_checkpoint.accepted_invitation event, propose to create one (linkupapi_create_webhook) so accepts trigger automated follow-up.Per LinkedIn account, this skill respects:
linkedin_profiles get)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 todaylinkedin_profiles/get calls → profile gets used todaylinkedin_profiles/search_* calls → searches used todayCompute 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:
Volume cap > remaining_invites → trim Volume cap to remaining_invites (never override).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.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.
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.
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."
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 connectionNow 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
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:
profile_url.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.
| 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) |
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:
["AI recruitment", "ATS"] = either match)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
}
}offset + limit (preferred) or start_page + end_page.industry and location split out (V2 post-processing splits "industry • city" into separate fields), so filtering in Stage 2 is straightforward.Look at each result's industry, name, location, employee count. Drop:
Staffing and Recruiting if the user wants RecTech vendors (not agencies).Show the filtered shortlist to the user before continuing if filtering is aggressive (>50% dropped). Otherwise continue.
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 exactcompany_urlvalue LinkupAPI returned in Stage 1 (search_companiesresults). Do NOT guess or build one from the company name (e.g. inferringlinkedin.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.
{"past_company": ["https://linkedin.com/company/acme"], "title": ["Engineer", "Senior Engineer"]}{"school_url": ["https://linkedin.com/school/hec-paris/"], "title": ["Product Manager", "CPO"]}{"connection_of": ["https://linkedin.com/in/your-investor"]}{"follower_of": ["https://linkedin.com/in/famous-vc"]}search_people call counts against the 15 searches / day cap.LinkedIn Member are private/anonymous → discard. Their URL contains /search/results/people/headless?... not /in/<handle>.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>"}
}get call counts against the 100 profile gets / day cap.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.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": "..."
}}Before sending every connection request, check the current relationship via linkedin_network action check_invitation to avoid:
PENDINGOUT_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 invitePENDING → already sent, skipCONNECTED (member_distance=1) → already connected, skipThen for every NO_INVITATION profile, send the invite:
{
"account_id": "...",
"action": "invite",
"params": {
"identifier": "<handle>",
"message": "<optional ≤300 char personal note>"
}
}sleep 1 in bash). LinkedIn flags burst patterns.linkedin_network action list_sent with count=20 to confirm the invites appear as "Sent today".Keep notes ≤ 250 chars. Patterns that work:
Hi {first_name}, I saw {Company}'s focus on {theme} — same space we're operating in. Would love to compare notes. — {sender_first_name}Hey {first_name}, your work at {Company} on {topic} caught my eye. Always glad to connect with operators in {space}.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).
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"]
}
}url) → events are stored, fetch via linkupapi_get_webhook_events (poll) or via SSE on the stream_url.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>"
}
}
}The "Message-after-accept" question in Step 0.3 has exactly three options — present them in this order:
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}?
{first_name}, {Company}, {job_title}, {theme} placeholders. Substitute and send verbatim.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).
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.
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/noWrite 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:
# | Name | Company | Network | invitation_urn. The invitation_urn is the key for later withdraw calls and accept-tracking.This file is what the user reads in 7 days to compute accept rate and decide whether to scale or change the ICP.
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.
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./search/results/people/headless?... not /in/<handle>. The invite call will fail. Filter them in Stage 4.search_people results in larger companies are ex-employees.https://www.linkedin.com/in/%CE%B4...), pass the raw handle to identifier, not the URL.| 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 |
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~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.