Linkedin outreach

End-to-end LinkedIn outreach campaign builder.

How to use it

  1. Hit Copy the whole skill.
  2. Claude: ⋯ → Download .md, then Customize → Skills → Add → Upload skill.
    ChatGPT: make a Project and paste it into Instructions.
    Neither? Paste it at the top of a new chat — it works for that chat.
  3. Describe your job in plain words. The AI follows the skill from there.
Claude Code — installs the whole folder, not just SKILL.md
npx degit gooseworks-ai/goose-skills/skills/outreach/capabilities/linkedin-outreach#main ~/.claude/skills/linkedin-outreach

For one project only, change the path to .claude/skills/linkedin-outreach.

Not working?
  • Check which app you pasted it into — the steps above name the right one.
  • Some skills need the paid tier of Claude or ChatGPT.
Step-by-step guide with screenshots · Ask in the forum

Paste into Claude, ChatGPT or Cursor.

Show the full text424 lines
linkedin-outreach/SKILL.md424 lines17.4 KBpushed 96d agoRawView on GitHub

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-qualification and 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:

  1. Ask user in Phase 0: "Which LinkedIn outreach tool do you use?"
  2. Generate tool-specific import CSV based on selection
  3. 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

  1. What's the objective? (book meetings, drive demo requests, get replies, build relationships, nurture)
  2. What's the outreach angle or hook? (hiring signal, competitor displacement, event-based, pain-based, cold database, KOL engagement, mutual connection)
  3. What should we name this campaign?

Outreach Tool

  1. Which LinkedIn outreach tool do you use? (Dripify / Botdog / Expandi / PhantomBuster / Other / Just give me a CSV)

Lead Selection

  1. 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_score above a threshold
    • Specific source (crustdata, apollo, linkedin, etc.)
    • Custom filter (describe what you want)
  2. Any exclusions? (specific companies, recently contacted leads, certain titles)
  3. Max campaign size? (default: 100 — LinkedIn tools have lower daily limits than email)

Tone & Style

  1. 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.
  2. Any reference messages that have worked well? (paste examples — these override tone presets)

Sequence Structure

  1. How many follow-ups after connection? (default: 3)
  2. Timing between messages? (default: Day 0 connection / Day 3 FU1 / Day 7 FU2 / Day 14 FU3)
  3. Include InMail as a separate step for leads who don't accept the connection? (default: yes)

Personalization

  1. What signal data is available for these leads? (comment text, post they engaged with, mutual connections, hiring signals, event attendance)
  2. 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:

  1. Query outreach_log for person_ids with sent_date in 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}
    
  2. Collect those person_ids into an exclusion set
  3. 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
5 Day 7* InMail (if not accepted) Standalone pitch with context Meeting request

*InMail is sent to leads who haven't accepted the connection request by Day 7.

Key differences from email sequences:

  • Connection request is the gatekeeper — it must earn the accept. No selling in the connection note.
  • Follow-ups are conversational, not broadcast. They should read like DMs, not emails.
  • No subject lines except for InMail.
  • Shorter is almost always better. A 2-sentence message outperforms a 5-sentence one on LinkedIn.

Get user approval on the structure before generating copy in Phase 3.

Phase 3: Message Generation

Generate messages directly in this skill (no external sub-skill needed — LinkedIn messages are short enough to handle inline).

Signal-Aware Template Selection

Select the appropriate sequence template based on lead signal data:

Signal Type Template Key Personalization Variable
Pain-language engager (has comment text) templates/sequence-templates/pain-language.md {comment_snippet}, {pain_topic}
Competitor post engager templates/sequence-templates/competitor-engagement.md {competitor_name}, {post_topic}
KOL engager templates/sequence-templates/kol-engagement.md {kol_name}, {post_topic}
Database search (lean signal) templates/sequence-templates/database-search.md {title}, {company}, {industry}
Hiring signal templates/sequence-templates/hiring-signal.md {role_hiring_for}, {job_posting_detail}
Event attendee templates/sequence-templates/event-attendee.md {event_name}, {event_topic}

Tone Calibration

  1. Load the selected tone preset from templates/tone-presets.json
  2. If user provided reference messages, those override the preset — analyze the reference messages for tone, length, structure, and vocabulary
  3. Apply tone guidelines to all generated messages

Calibration Loop

  1. Generate sample messages for 3-5 leads first (pick leads with different signal richness levels)
  2. Present to user for review — show the full sequence for each sample lead
  3. Iterate until approved (max 3 rounds)
  4. Batch generate remaining messages after approval

Writing Guidelines

Connection Request (300 chars max):

  • Lead with the signal (what they did/said that caught your attention)
  • One sentence of relevance (why you're connecting)
  • No pitch, no CTA, no "I'd love to..." — just be interesting enough to accept
  • MUST be under 300 characters. Count every character.

Follow-up 1 (value-first):

  • Thank for connecting (briefly — one clause, not a whole sentence)
  • Share something genuinely useful: insight, article, observation about their company/industry
  • End with a question, not a pitch

Follow-up 2 (social proof):

  • Reference a relevant customer or case study
  • Connect it to their specific situation
  • Make a specific, low-commitment ask (15-min call, async question)

Follow-up 3 (breakup):

  • Acknowledge you've been reaching out
  • One-line value recap
  • Leave the door open without pressure
  • Shortest message in the sequence

InMail (standalone pitch):

  • Subject: 200 chars max — curiosity-driven, not salesy
  • Body: 1,900 chars max — must work standalone since they haven't accepted your connection
  • Include context for why you're reaching out (the signal)
  • Must work even if they've never heard of you

Merge Variables

Standard variables available for all leads:

  • {first_name}, {last_name}, {company}, {title}, {industry}, {location}

Signal-specific variables (available based on source):

  • {comment_snippet} — the text of their LinkedIn comment
  • {pain_topic} — the pain point they engaged with
  • {competitor_name} — the competitor whose post they engaged with
  • {kol_name} — the KOL whose post they engaged with
  • {post_topic} — what the post was about
  • {event_name} — the event they attended
  • {role_hiring_for} — the role they're hiring for
  • {job_posting_detail} — a detail from the job posting

Phase 4: Campaign Export

Step 1: Generate Universal CSV

Core columns for all exports:

linkedin_url, first_name, last_name, company, title, connection_request, followup_1, followup_2, followup_3, inmail_subject, inmail_body

Step 2: Format for Selected Tool

Dripify:

  • Column mapping: Profile URL → linkedin_url, Note → connection_request, Message 1 → followup_1, etc.
  • Dripify expects one row per lead with all messages in separate columns
  • Export format: CSV with headers matching Dripify's import template

Botdog:

  • Column mapping: linkedin_profile_url → linkedin_url, connection_note → connection_request, message_1 → followup_1, etc.
  • Export format: CSV

Expandi:

  • Column mapping: LinkedIn URL → linkedin_url, Connection message → connection_request, Follow-up #1 → followup_1, etc.
  • Supports InMail columns: InMail subject, InMail message
  • Export format: CSV

PhantomBuster:

  • Column mapping: profileUrl → linkedin_url, message → connection_request
  • PhantomBuster typically handles one action at a time — may need separate CSVs for connection + follow-ups
  • Export format: CSV

Manual / Other:

  • Use the universal CSV format
  • Provide column descriptions and tool-agnostic import instructions
  • Ask user what format their tool expects, adjust if needed

Step 3: Save Files

skills/linkedin-outreach/output/{campaign-name}-{YYYY-MM-DD}.csv

Create the output/ directory if it doesn't exist.

Step 4: Optional Google Sheet

If user wants a review sheet, use google-sheets-write capability to create a sheet with:

  • Tab 1: Lead list with all messages (one row per lead)
  • Tab 2: Sequence templates (the master templates used)
  • Tab 3: Campaign config summary

Phase 5: Review & Approval

Present campaign summary:

Campaign: {name}
Tool: {dripify/botdog/expandi/etc.}
Leads: {count}
Sequence: Connection + {followup_count} follow-ups + InMail
Timing: Day 0 → Day {last_day}
Tone: {preset_name}
Signal types: {breakdown by signal type}
Leads with rich signal: {count} ({percentage}%)
Leads with lean signal: {count} ({percentage}%)
Export file: {file_path}

Hard Approval Gate

Do NOT mark the campaign as ready without explicit user confirmation. Present the summary, then ask: "Ready to finalize? Type 'yes' to mark as ready for import."

After approval:

  • Tell user the file is ready for import into their LinkedIn tool
  • Provide the file path
  • Give tool-specific import instructions (see Phase 4)
  • Remind user to verify the first 5-10 messages look correct after import

Phase 6: Logging

Database Write Policy

All database writes in this phase require the user's prior approval from the finalization gate in Phase 5. Since LinkedIn campaigns are always exported (never auto-launched), confirm with the user before logging to outreach_log — they may not have actually imported the campaign into their LinkedIn tool yet. Only log after the user confirms the export is final.

Log to Supabase

After export and user confirmation, insert records into outreach_log:

POST /rest/v1/outreach_log
Prefer: return=minimal

[
  {
    "person_id": "{person_uuid}",
    "campaign_name": "{campaign_name}",
    "channel": "linkedin",
    "tool": "{dripify/botdog/expandi/phantombuster/manual}",
    "sent_date": "{ISO timestamp}",
    "status": "exported",
    "client_name": "{client_name}"
  },
  ...
]

Or use the shared client:

client.log_outreach(entries)

Status is "exported", not "sent". LinkedIn tools are browser-based — we can't confirm delivery. The status changes to "sent" when the user confirms they launched the campaign in their tool.

Update People Records

Update last_contacted on the people table for all people in this campaign:

PATCH /rest/v1/people?id=in.({person_ids})
{ "last_contacted": "{ISO timestamp}" }

Present Summary

Campaign: {name}
{count} people logged to outreach_log (channel: linkedin)
last_contacted updated for {count} people
Cooldown active until: {date + 84 days}
Next eligible re-contact: {date}
File ready: {file_path}

Cooldown Enforcement Rules

Reference section for cooldown logic used throughout this skill. Shared with cold-email-outreach.

Rule Detail
Default cooldown 84 days (12 weeks) from sent_date
Cross-channel Cooldown applies across email AND LinkedIn. A lead emailed recently is in cooldown for LinkedIn too.
Bounced leads Exempt from cooldown (email only — LinkedIn doesn't bounce). Filter: status=neq.bounced when checking cooldown
Active campaign leads Always ineligible — if a lead is in an active campaign on any channel, they cannot be added to another campaign
User override User can explicitly override cooldown for specific leads — ask for confirmation before allowing
Null last_contacted Leads never contacted are always eligible

Output Directory

Campaign exports are saved to:

skills/linkedin-outreach/output/

Create this directory if it doesn't exist. Files are named {campaign-name}-{YYYY-MM-DD}.csv.

1---
2name: linkedin-outreach
3description: >
4 End-to-end LinkedIn outreach campaign builder. Takes leads from Supabase,
5 upstream skills, or CSV. Aligns on campaign goal and tone, writes personalized
6 LinkedIn message sequences (connection request + follow-ups + optional InMail),
7 presents for review, and exports for the user's outreach tool (Dripify, Botdog,
8 Expandi, or manual CSV). Logs to Supabase outreach_log.
9tags: [outreach]
10---
11 
12# LinkedIn Outreach
13 
14The 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.
15 
16**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.
17 
18## When to Auto-Load
19 
20Load this skill when:
21- User says "LinkedIn outreach", "connect with these leads on LinkedIn", "send LinkedIn messages", "set up a LinkedIn campaign"
22- An upstream skill connects with "create LinkedIn campaign" or "passes: supabase-eligible-leads" and user specifies LinkedIn
23- User completes `lead-qualification` and wants to reach out via LinkedIn
24 
25## Supported Outreach Tools
26 
27This skill does NOT assume a specific tool. It asks first, then adapts.
28 
29| Tool | Integration | How It Works |
30|------|------------|--------------|
31| **Dripify** | CSV import | Generate CSV matching Dripify's import format, user uploads manually |
32| **Botdog** | CSV import | Generate CSV with Botdog-compatible columns |
33| **Expandi** | CSV import | Generate CSV matching Expandi import format |
34| **PhantomBuster** | CSV import | Generate CSV for PhantomBuster LinkedIn sequences |
35| **Manual / Other** | CSV + instructions | Export leads + messages as generic CSV, provide setup instructions |
36 
37**Tool selection logic:**
381. Ask user in Phase 0: "Which LinkedIn outreach tool do you use?"
392. Generate tool-specific import CSV based on selection
403. 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
41 
42## Prerequisites
43 
44### Supabase
45 
46People 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.
47 
48Environment variables in `.env`:
49```
50SUPABASE_URL=https://xxx.supabase.co
51SUPABASE_SERVICE_ROLE_KEY=eyJ...
52```
53 
54### LinkedIn Tool
55 
56Just need CSV export — no API keys required. The user imports the CSV into their tool manually.
57 
58## Character Limits
59 
60LinkedIn enforces strict character limits. **All generated messages must respect these.**
61 
62| Message Type | Limit | Notes |
63|-------------|-------|-------|
64| Connection request note | 300 characters | Hard limit. Every character counts. |
65| Regular message | 8,000 characters | Sent after connection accepted |
66| InMail subject | 200 characters | Only for InMail (premium feature) |
67| InMail body | 1,900 characters | Only for InMail |
68 
69**Enforcement:** After generating any message, count characters. If over the limit, rewrite — do not truncate. Truncated messages look broken.
70 
71## Phase 0: Intake
72 
73Ask all questions at once. Organize by category. Skip any already answered by an upstream skill.
74 
75### Campaign Goal
761. What's the objective? (book meetings, drive demo requests, get replies, build relationships, nurture)
772. What's the outreach angle or hook? (hiring signal, competitor displacement, event-based, pain-based, cold database, KOL engagement, mutual connection)
783. What should we name this campaign?
79 
80### Outreach Tool
814. Which LinkedIn outreach tool do you use? (Dripify / Botdog / Expandi / PhantomBuster / Other / Just give me a CSV)
82 
83### Lead Selection
845. Which leads should we target? Options:
85 - All leads for a specific `client_name`
86 - Specific `icp_segment`
87 - Title patterns (e.g., "VP Operations", "Director of Sales")
88 - Industry or location filters
89 - `qualification_score` above a threshold
90 - Specific `source` (crustdata, apollo, linkedin, etc.)
91 - Custom filter (describe what you want)
926. Any exclusions? (specific companies, recently contacted leads, certain titles)
937. Max campaign size? (default: 100 — LinkedIn tools have lower daily limits than email)
94 
95### Tone & Style
968. Which tone preset? Present these options:
97 - **Casual Professional** — Friendly, human, slightly informal. Like messaging a peer. (default)
98 - **Thought Leader** — Lead with insight or a contrarian take. Position sender as an expert.
99 - **Provocative** — Challenge assumptions, pattern-interrupt. Higher risk, higher reward.
100 - **Enterprise Formal** — Polished, structured. For regulated industries or C-suite targets.
101 - **Custom** — Paste reference messages that worked before, or describe the vibe.
1029. Any reference messages that have worked well? (paste examples — these override tone presets)
103 
104### Sequence Structure
10510. How many follow-ups after connection? (default: 3)
10611. Timing between messages? (default: Day 0 connection / Day 3 FU1 / Day 7 FU2 / Day 14 FU3)
10712. Include InMail as a separate step for leads who don't accept the connection? (default: yes)
108 
109### Personalization
11013. What signal data is available for these leads? (comment text, post they engaged with, mutual connections, hiring signals, event attendance)
11114. Any proof points or case studies to reference? (customer names, metrics, testimonials)
112 
113## Phase 1: Lead Selection from Supabase
114 
115### Connect
116 
117Use the shared Supabase client:
118 
119```python
120import sys, os
121sys.path.insert(0, os.path.join("tools", "supabase"))
122from supabase_client import SupabaseClient
123 
124client = SupabaseClient(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_ROLE_KEY"])
125```
126 
127### Build Filters
128 
129Map user criteria to PostgREST query parameters on the `people` table:
130 
131| User Says | PostgREST Filter |
132|-----------|-----------------|
133| "VP Operations" | `title=ilike.*VP Operations*` |
134| Client "happy-robot" | `client_name=eq.happy-robot` |
135| Score > 7 | `qualification_score=gte.7` |
136| Has LinkedIn URL | `linkedin_url=neq.` (not empty) |
137| Industry "logistics" | `industry=ilike.*logistics*` |
138| Location "San Francisco" | `location=ilike.*San Francisco*` |
139| Source "crustdata" | `source=eq.crustdata` |
140| Not contacted in 84 days | `or=(last_contacted.is.null,last_contacted.lt.{84_days_ago})` |
141 
142**Critical:** For LinkedIn outreach, people MUST have a `linkedin_url`. Filter out people without one — they can't be contacted via LinkedIn.
143 
144### Cooldown Filter (Mandatory)
145 
146**Always** exclude people contacted within 84 days (12 weeks) on ANY channel (email or LinkedIn). This is not optional.
147 
148Use the shared client's `check_cooldown()` method:
149```python
150in_cooldown = client.check_cooldown(client_name="happy-robot", cooldown_days=84)
151# Returns set of person_id strings still in cooldown
152```
153 
154Or query directly:
1551. Query `outreach_log` for `person_id`s with `sent_date` in the last 84 days:
156 ```
157 GET /rest/v1/outreach_log?select=person_id&sent_date=gte.{84_days_ago}&status=neq.bounced&client_name=eq.{client}
158 ```
1592. Collect those `person_id`s into an exclusion set
1603. Add `id=not.in.({excluded_ids})` to the people query
161 
162**Note:** Cooldown applies across channels. A person emailed 30 days ago is still in cooldown for LinkedIn. This prevents multi-channel bombardment.
163 
164### Present & Confirm
165 
166Show a sample table (10-15 leads) with:
167- Name, Title, Company, Industry, Score, LinkedIn URL, Last Contacted, Signal Type
168 
169Tell user: total eligible leads, how many excluded by cooldown, how many excluded for missing LinkedIn URL.
170 
171Ask user to confirm or adjust filters before proceeding.
172 
173## Phase 2: Sequence Design
174 
175Present the sequence plan as a table before writing any copy:
176 
177| Step | Timing | Message Type | Approach | CTA |
178|------|--------|-------------|----------|-----|
179| 1 | Day 0 | Connection request (300 chars) | Signal-based personalized note | Soft — just connect |
180| 2 | Day 3 | Follow-up 1 (after accepted) | Value-first: insight, resource, or observation | Question or offer |
181| 3 | Day 7 | Follow-up 2 | Social proof or case study | Specific ask |
182| 4 | Day 14 | Follow-up 3 | Breakup / last touch | Open door |
183| 5 | Day 7* | InMail (if not accepted) | Standalone pitch with context | Meeting request |
184 
185*InMail is sent to leads who haven't accepted the connection request by Day 7.
186 
187**Key differences from email sequences:**
188- Connection request is the gatekeeper — it must earn the accept. No selling in the connection note.
189- Follow-ups are conversational, not broadcast. They should read like DMs, not emails.
190- No subject lines except for InMail.
191- Shorter is almost always better. A 2-sentence message outperforms a 5-sentence one on LinkedIn.
192 
193Get user approval on the structure before generating copy in Phase 3.
194 
195## Phase 3: Message Generation
196 
197Generate messages directly in this skill (no external sub-skill needed — LinkedIn messages are short enough to handle inline).
198 
199### Signal-Aware Template Selection
200 
201Select the appropriate sequence template based on lead signal data:
202 
203| Signal Type | Template | Key Personalization Variable |
204|-------------|----------|------------------------------|
205| Pain-language engager (has comment text) | `templates/sequence-templates/pain-language.md` | `{comment_snippet}`, `{pain_topic}` |
206| Competitor post engager | `templates/sequence-templates/competitor-engagement.md` | `{competitor_name}`, `{post_topic}` |
207| KOL engager | `templates/sequence-templates/kol-engagement.md` | `{kol_name}`, `{post_topic}` |
208| Database search (lean signal) | `templates/sequence-templates/database-search.md` | `{title}`, `{company}`, `{industry}` |
209| Hiring signal | `templates/sequence-templates/hiring-signal.md` | `{role_hiring_for}`, `{job_posting_detail}` |
210| Event attendee | `templates/sequence-templates/event-attendee.md` | `{event_name}`, `{event_topic}` |
211 
212### Tone Calibration
213 
2141. Load the selected tone preset from `templates/tone-presets.json`
2152. If user provided reference messages, those override the preset — analyze the reference messages for tone, length, structure, and vocabulary
2163. Apply tone guidelines to all generated messages
217 
218### Calibration Loop
219 
2201. Generate sample messages for 3-5 leads first (pick leads with different signal richness levels)
2212. Present to user for review — show the full sequence for each sample lead
2223. Iterate until approved (max 3 rounds)
2234. Batch generate remaining messages after approval
224 
225### Writing Guidelines
226 
227**Connection Request (300 chars max):**
228- Lead with the signal (what they did/said that caught your attention)
229- One sentence of relevance (why you're connecting)
230- No pitch, no CTA, no "I'd love to..." — just be interesting enough to accept
231- MUST be under 300 characters. Count every character.
232 
233**Follow-up 1 (value-first):**
234- Thank for connecting (briefly — one clause, not a whole sentence)
235- Share something genuinely useful: insight, article, observation about their company/industry
236- End with a question, not a pitch
237 
238**Follow-up 2 (social proof):**
239- Reference a relevant customer or case study
240- Connect it to their specific situation
241- Make a specific, low-commitment ask (15-min call, async question)
242 
243**Follow-up 3 (breakup):**
244- Acknowledge you've been reaching out
245- One-line value recap
246- Leave the door open without pressure
247- Shortest message in the sequence
248 
249**InMail (standalone pitch):**
250- Subject: 200 chars max — curiosity-driven, not salesy
251- Body: 1,900 chars max — must work standalone since they haven't accepted your connection
252- Include context for why you're reaching out (the signal)
253- Must work even if they've never heard of you
254 
255### Merge Variables
256 
257Standard variables available for all leads:
258- `{first_name}`, `{last_name}`, `{company}`, `{title}`, `{industry}`, `{location}`
259 
260Signal-specific variables (available based on source):
261- `{comment_snippet}` — the text of their LinkedIn comment
262- `{pain_topic}` — the pain point they engaged with
263- `{competitor_name}` — the competitor whose post they engaged with
264- `{kol_name}` — the KOL whose post they engaged with
265- `{post_topic}` — what the post was about
266- `{event_name}` — the event they attended
267- `{role_hiring_for}` — the role they're hiring for
268- `{job_posting_detail}` — a detail from the job posting
269 
270## Phase 4: Campaign Export
271 
272### Step 1: Generate Universal CSV
273 
274Core columns for all exports:
275 
276```
277linkedin_url, first_name, last_name, company, title, connection_request, followup_1, followup_2, followup_3, inmail_subject, inmail_body
278```
279 
280### Step 2: Format for Selected Tool
281 
282**Dripify:**
283- Column mapping: `Profile URL` → linkedin_url, `Note` → connection_request, `Message 1` → followup_1, etc.
284- Dripify expects one row per lead with all messages in separate columns
285- Export format: CSV with headers matching Dripify's import template
286 
287**Botdog:**
288- Column mapping: `linkedin_profile_url` → linkedin_url, `connection_note` → connection_request, `message_1` → followup_1, etc.
289- Export format: CSV
290 
291**Expandi:**
292- Column mapping: `LinkedIn URL` → linkedin_url, `Connection message` → connection_request, `Follow-up #1` → followup_1, etc.
293- Supports InMail columns: `InMail subject`, `InMail message`
294- Export format: CSV
295 
296**PhantomBuster:**
297- Column mapping: `profileUrl` → linkedin_url, `message` → connection_request
298- PhantomBuster typically handles one action at a time — may need separate CSVs for connection + follow-ups
299- Export format: CSV
300 
301**Manual / Other:**
302- Use the universal CSV format
303- Provide column descriptions and tool-agnostic import instructions
304- Ask user what format their tool expects, adjust if needed
305 
306### Step 3: Save Files
307 
308```
309skills/linkedin-outreach/output/{campaign-name}-{YYYY-MM-DD}.csv
310```
311 
312Create the `output/` directory if it doesn't exist.
313 
314### Step 4: Optional Google Sheet
315 
316If user wants a review sheet, use `google-sheets-write` capability to create a sheet with:
317- Tab 1: Lead list with all messages (one row per lead)
318- Tab 2: Sequence templates (the master templates used)
319- Tab 3: Campaign config summary
320 
321## Phase 5: Review & Approval
322 
323Present campaign summary:
324 
325```
326Campaign: {name}
327Tool: {dripify/botdog/expandi/etc.}
328Leads: {count}
329Sequence: Connection + {followup_count} follow-ups + InMail
330Timing: Day 0 → Day {last_day}
331Tone: {preset_name}
332Signal types: {breakdown by signal type}
333Leads with rich signal: {count} ({percentage}%)
334Leads with lean signal: {count} ({percentage}%)
335Export file: {file_path}
336```
337 
338### Hard Approval Gate
339 
340**Do NOT mark the campaign as ready without explicit user confirmation.** Present the summary, then ask: "Ready to finalize? Type 'yes' to mark as ready for import."
341 
342After approval:
343- Tell user the file is ready for import into their LinkedIn tool
344- Provide the file path
345- Give tool-specific import instructions (see Phase 4)
346- Remind user to verify the first 5-10 messages look correct after import
347 
348## Phase 6: Logging
349 
350### Database Write Policy
351 
352**All database writes in this phase require the user's prior approval from the finalization gate in Phase 5.** Since LinkedIn campaigns are always exported (never auto-launched), confirm with the user before logging to `outreach_log` — they may not have actually imported the campaign into their LinkedIn tool yet. Only log after the user confirms the export is final.
353 
354### Log to Supabase
355 
356After export and user confirmation, insert records into `outreach_log`:
357 
358```
359POST /rest/v1/outreach_log
360Prefer: return=minimal
361 
362[
363 {
364 "person_id": "{person_uuid}",
365 "campaign_name": "{campaign_name}",
366 "channel": "linkedin",
367 "tool": "{dripify/botdog/expandi/phantombuster/manual}",
368 "sent_date": "{ISO timestamp}",
369 "status": "exported",
370 "client_name": "{client_name}"
371 },
372 ...
373]
374```
375 
376Or use the shared client:
377```python
378client.log_outreach(entries)
379```
380 
381**Status is `"exported"`, not `"sent"`.** LinkedIn tools are browser-based — we can't confirm delivery. The status changes to `"sent"` when the user confirms they launched the campaign in their tool.
382 
383### Update People Records
384 
385Update `last_contacted` on the people table for all people in this campaign:
386 
387```
388PATCH /rest/v1/people?id=in.({person_ids})
389{ "last_contacted": "{ISO timestamp}" }
390```
391 
392### Present Summary
393 
394```
395Campaign: {name}
396{count} people logged to outreach_log (channel: linkedin)
397last_contacted updated for {count} people
398Cooldown active until: {date + 84 days}
399Next eligible re-contact: {date}
400File ready: {file_path}
401```
402 
403## Cooldown Enforcement Rules
404 
405Reference section for cooldown logic used throughout this skill. Shared with `cold-email-outreach`.
406 
407| Rule | Detail |
408|------|--------|
409| **Default cooldown** | 84 days (12 weeks) from `sent_date` |
410| **Cross-channel** | Cooldown applies across email AND LinkedIn. A lead emailed recently is in cooldown for LinkedIn too. |
411| **Bounced leads** | Exempt from cooldown (email only — LinkedIn doesn't bounce). Filter: `status=neq.bounced` when checking cooldown |
412| **Active campaign leads** | Always ineligible — if a lead is in an active campaign on any channel, they cannot be added to another campaign |
413| **User override** | User can explicitly override cooldown for specific leads — ask for confirmation before allowing |
414| **Null last_contacted** | Leads never contacted are always eligible |
415 
416## Output Directory
417 
418Campaign exports are saved to:
419```
420skills/linkedin-outreach/output/
421```
422 
423Create this directory if it doesn't exist. Files are named `{campaign-name}-{YYYY-MM-DD}.csv`.
424 

Discussion