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 --claude # Then in your agent: /gooseworks <prompt> --skill 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.
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 LinkedInCheck and improve your brand's visibility across AI search engines (ChatGPT, Perplexity, Gemini, Grok, Claude, DeepSeek). Set up tracking, run visibility analyses, audit your website for AI readability, and get actionable recommendations. Uses the npx goose-aeo@latest CLI.
Extract competitor and customer intelligence from any company's landing page HTML. Discovers tech stack, analytics tools, ad pixels, customer logos, SEO metadata, CTAs, hidden elements, and more. No API keys required.
Discover all customers of a given company by scanning websites, case studies, review sites, press, social media, job postings, and more. Use when you need competitive intelligence on who a company sells to.