End-to-end LinkedIn outreach campaign builder. Takes leads from Supabase, upstream skills, or CSV. Aligns on campaign goal and tone, writes personalized LinkedIn message sequences (connection request + follow-ups + optional InMail), presents for review, and exports for the user's outreach tool (Dripify, Botdog, Expandi, or manual CSV). Logs to Supabase outreach_log.
npx gooseworks install --all # then, in Claude Code, Cursor, or Codex: /gooseworks use the linkedin-outreach skill
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.
Load this skill when:
lead-qualification and wants to reach out via LinkedInThis 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:
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 requirementsPeople 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...Just need CSV export — no API keys required. The user imports the CSV into their tool manually.
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.
Ask all questions at once. Organize by category. Skip any already answered by an upstream skill.
client_nameicp_segmentqualification_score above a thresholdsource (crustdata, apollo, linkedin, etc.)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"])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.
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 cooldownOr query directly:
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}person_ids into an exclusion setid=not.in.({excluded_ids}) to the people queryNote: Cooldown applies across channels. A person emailed 30 days ago is still in cooldown for LinkedIn. This prevents multi-channel bombardment.
Show a sample table (10-15 leads) with:
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.
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:
Get user approval on the structure before generating copy in Phase 3.
Generate messages directly in this skill (no external sub-skill needed — LinkedIn messages are short enough to handle inline).
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} |
templates/tone-presets.jsonConnection Request (300 chars max):
Follow-up 1 (value-first):
Follow-up 2 (social proof):
Follow-up 3 (breakup):
InMail (standalone pitch):
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 postingCore columns for all exports:
linkedin_url, first_name, last_name, company, title, connection_request, followup_1, followup_2, followup_3, inmail_subject, inmail_bodyDripify:
Profile URL → linkedin_url, Note → connection_request, Message 1 → followup_1, etc.Botdog:
linkedin_profile_url → linkedin_url, connection_note → connection_request, message_1 → followup_1, etc.Expandi:
LinkedIn URL → linkedin_url, Connection message → connection_request, Follow-up #1 → followup_1, etc.InMail subject, InMail messagePhantomBuster:
profileUrl → linkedin_url, message → connection_requestManual / Other:
skills/linkedin-outreach/output/{campaign-name}-{YYYY-MM-DD}.csvCreate the output/ directory if it doesn't exist.
If user wants a review sheet, use google-sheets-write capability to create a sheet with:
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}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:
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.
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 last_contacted on the people table for all people in this campaign:
PATCH /rest/v1/people?id=in.({person_ids})
{ "last_contacted": "{ISO timestamp}" }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}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 |
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.
lead-qualification and wants to reach out via LinkedInRender a 'model comparison grid' video from a config — a fal-style "same prompt, N contenders" showcase — a dark real-DOM stage where per beat a monospace prompt fades in centered, docks to a small top strip, then a labeled 2-4 panel grid (static images OR muted video clips, mixable per cell) staggers in and holds for comparison, plus a minimal end card — frame-stepped via Playwright (video cells are frame-seeked deterministically) and encoded with FFmpeg. Deterministic assembly, FREE (cell media comes from create-image-fal / create-video-fal, music from create-music-elevenlabs), text stays pixel-crisp. Use for the model-comparison-grid format.
Render a punchy ~12s vertical (9:16) music-only direct-response OFFER ad as a 4-beat kinetic-typography film — HEADLINE slam → real PRODUCT drop → CLAIM/proof → CTA pill — from one config of copy slots, a real product photo, a brand palette, fonts, bpm, and beat split. DETERMINISTIC + FREE (a bundled Remotion project; springs + interpolate, no AI-gen for visuals). Backgrounds are engine gradient divs off the palette, props are inline SVG, the ONLY composited bitmap is the REAL product photo (objectFit:contain, never stretched), and ALL headline/claim/CTA/URL/wordmark text is typeset in the engine — never AI-rendered (the format's credibility guard). A driver binds the config to Remotion input props, renders the 9:16 master, and derives a 1:1 center-crop with ffmpeg. Two gating checks run before render (claim verbs must match the product's physical format; the claim beat needs an edge-entry mechanism prop). Use for the motion-graphics-offer-ad format.
Assemble a myth-vs-fact kinetic-typography explainer video ad (≈29.5s, 9:16) from N myth/fact pairs + hook / turn / punch copy + palette + a brand end-card PNG + a VO track — a hook, 3 red-strike MYTH cards that flip to teal-check FACT cards (per-line strikethrough that crosses EVERY wrapped line), a "what actually works" turn, an optional proof reveal, a punch line, and a static end card. DETERMINISTIC assembly with ZERO AI-gen visuals — HTML hyperframes rendered frame-exact via Playwright (`window.renderAt(t)`, animation a pure function of beat-local time), Whisper beat-snap to VO word onsets, concat at a uniform fps, karaoke `.ass` captions burned last (suppressed on the proof + end-card beats), and a VO + optional music mix (music −20 dB, `amix normalize=0`, tail fade). FREE (Python + Playwright + ffmpeg); the recipe supplies the copy / palette / end-card / VO and gates the paid VO / music / Whisper calls to their own capabilities. Use for the myth-vs-fact format.