LinkedIn Outreach
The LinkedIn counterpart to cold-email-outreach. Takes qualified leads from Supabase, builds personalized LinkedIn message sequences, exports for the user's LinkedIn outreach tool, and logs everything back to Supabase.
Tool-agnostic: Asks the user which LinkedIn tool they use. All tools are CSV-import based — no API/MCP automation for LinkedIn tools (they're browser-based). Adapters handle column mapping and format differences per tool.
When to Auto-Load
Load this skill when:
- User says "LinkedIn outreach", "connect with these leads on LinkedIn", "send LinkedIn messages", "set up a LinkedIn campaign"
- An upstream skill connects with "create LinkedIn campaign" or "passes: supabase-eligible-leads" and user specifies LinkedIn
- User completes
lead-qualificationand wants to reach out via LinkedIn
Supported Outreach Tools
This skill does NOT assume a specific tool. It asks first, then adapts.
| Tool | Integration | How It Works |
|---|---|---|
| Dripify | CSV import | Generate CSV matching Dripify's import format, user uploads manually |
| Botdog | CSV import | Generate CSV with Botdog-compatible columns |
| Expandi | CSV import | Generate CSV matching Expandi import format |
| PhantomBuster | CSV import | Generate CSV for PhantomBuster LinkedIn sequences |
| Manual / Other | CSV + instructions | Export leads + messages as generic CSV, provide setup instructions |
Tool selection logic:
- Ask user in Phase 0: "Which LinkedIn outreach tool do you use?"
- Generate tool-specific import CSV based on selection
- If Other or unknown → generate generic CSV (
linkedin_url,first_name,last_name,company,title,connection_request,followup_1,followup_2,followup_3,inmail_subject,inmail_body) and ask user for their tool's import requirements
Prerequisites
Supabase
People must be stored in Supabase with the schema from tools/supabase/schema.sql. The people and outreach_log tables must exist. Run python3 tools/supabase/setup_database.py if setting up fresh.
Environment variables in .env:
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
LinkedIn Tool
Just need CSV export — no API keys required. The user imports the CSV into their tool manually.
Character Limits
LinkedIn enforces strict character limits. All generated messages must respect these.
| Message Type | Limit | Notes |
|---|---|---|
| Connection request note | 300 characters | Hard limit. Every character counts. |
| Regular message | 8,000 characters | Sent after connection accepted |
| InMail subject | 200 characters | Only for InMail (premium feature) |
| InMail body | 1,900 characters | Only for InMail |
Enforcement: After generating any message, count characters. If over the limit, rewrite — do not truncate. Truncated messages look broken.
Phase 0: Intake
Ask all questions at once. Organize by category. Skip any already answered by an upstream skill.
Campaign Goal
- What's the objective? (book meetings, drive demo requests, get replies, build relationships, nurture)
- What's the outreach angle or hook? (hiring signal, competitor displacement, event-based, pain-based, cold database, KOL engagement, mutual connection)
- What should we name this campaign?
Outreach Tool
- Which LinkedIn outreach tool do you use? (Dripify / Botdog / Expandi / PhantomBuster / Other / Just give me a CSV)
Lead Selection
- Which leads should we target? Options:
- All leads for a specific
client_name - Specific
icp_segment - Title patterns (e.g., "VP Operations", "Director of Sales")
- Industry or location filters
qualification_scoreabove a threshold- Specific
source(crustdata, apollo, linkedin, etc.) - Custom filter (describe what you want)
- All leads for a specific
- Any exclusions? (specific companies, recently contacted leads, certain titles)
- Max campaign size? (default: 100 — LinkedIn tools have lower daily limits than email)
Tone & Style
- Which tone preset? Present these options:
- Casual Professional — Friendly, human, slightly informal. Like messaging a peer. (default)
- Thought Leader — Lead with insight or a contrarian take. Position sender as an expert.
- Provocative — Challenge assumptions, pattern-interrupt. Higher risk, higher reward.
- Enterprise Formal — Polished, structured. For regulated industries or C-suite targets.
- Custom — Paste reference messages that worked before, or describe the vibe.
- Any reference messages that have worked well? (paste examples — these override tone presets)
Sequence Structure
- How many follow-ups after connection? (default: 3)
- Timing between messages? (default: Day 0 connection / Day 3 FU1 / Day 7 FU2 / Day 14 FU3)
- Include InMail as a separate step for leads who don't accept the connection? (default: yes)
Personalization
- What signal data is available for these leads? (comment text, post they engaged with, mutual connections, hiring signals, event attendance)
- Any proof points or case studies to reference? (customer names, metrics, testimonials)
Phase 1: Lead Selection from Supabase
Connect
Use the shared Supabase client:
import sys, os
sys.path.insert(0, os.path.join("tools", "supabase"))
from supabase_client import SupabaseClient
client = SupabaseClient(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_ROLE_KEY"])
Build Filters
Map user criteria to PostgREST query parameters on the people table:
| User Says | PostgREST Filter |
|---|---|
| "VP Operations" | title=ilike.*VP Operations* |
| Client "happy-robot" | client_name=eq.happy-robot |
| Score > 7 | qualification_score=gte.7 |
| Has LinkedIn URL | linkedin_url=neq. (not empty) |
| Industry "logistics" | industry=ilike.*logistics* |
| Location "San Francisco" | location=ilike.*San Francisco* |
| Source "crustdata" | source=eq.crustdata |
| Not contacted in 84 days | or=(last_contacted.is.null,last_contacted.lt.{84_days_ago}) |
Critical: For LinkedIn outreach, people MUST have a linkedin_url. Filter out people without one — they can't be contacted via LinkedIn.
Cooldown Filter (Mandatory)
Always exclude people contacted within 84 days (12 weeks) on ANY channel (email or LinkedIn). This is not optional.
Use the shared client's check_cooldown() method:
in_cooldown = client.check_cooldown(client_name="happy-robot", cooldown_days=84)
# Returns set of person_id strings still in cooldown
Or query directly:
- Query
outreach_logforperson_ids withsent_datein the last 84 days:GET /rest/v1/outreach_log?select=person_id&sent_date=gte.{84_days_ago}&status=neq.bounced&client_name=eq.{client} - Collect those
person_ids into an exclusion set - Add
id=not.in.({excluded_ids})to the people query
Note: Cooldown applies across channels. A person emailed 30 days ago is still in cooldown for LinkedIn. This prevents multi-channel bombardment.
Present & Confirm
Show a sample table (10-15 leads) with:
- Name, Title, Company, Industry, Score, LinkedIn URL, Last Contacted, Signal Type
Tell user: total eligible leads, how many excluded by cooldown, how many excluded for missing LinkedIn URL.
Ask user to confirm or adjust filters before proceeding.
Phase 2: Sequence Design
Present the sequence plan as a table before writing any copy:
| Step | Timing | Message Type | Approach | CTA |
|---|---|---|---|---|
| 1 | Day 0 | Connection request (300 chars) | Signal-based personalized note | Soft — just connect |
| 2 | Day 3 | Follow-up 1 (after accepted) | Value-first: insight, resource, or observation | Question or offer |
| 3 | Day 7 | Follow-up 2 | Social proof or case study | Specific ask |
| 4 | Day 14 | Follow-up 3 | Breakup / last touch | Open door |