Turn LinkedIn into an AI-powered sales channel. Discover prospects via search, warm them up with authentic engagement, send personalized connection requests, and run post-connection DM sequences. Complete LinkedIn sales funnel from discovery to meeting booked.
The complete LinkedIn sales funnel: prospect discovery, warm-up engagement, personalized connection requests, and post-connection DM sequences. Unlike email-only tools (Reply.io, Outreach), this skill turns LinkedIn into a proper sales channel.
await requestApproval({
reason: "Need Sheets access to track LinkedIn outreach pipeline",
type: "service_auth",
payload: { provider: "google", service: "drive" }
})
// Construct LinkedIn search URL from ICP criteria
var searchParams = {
keywords: targetRole, // "VP Marketing"
location: targetLocation, // "San Francisco Bay Area"
industry: targetIndustry, // "Computer Software"
companySize: targetSize, // "51-200" or "201-500"
connectionDegree: ["S", "O"] // 2nd and 3rd degree (exclude 1st - already connected)
}
var searchUrl = "https://www.linkedin.com/search/results/people/?keywords=" +
encodeURIComponent(searchParams.keywords)
// Add filters if specified
if (searchParams.location) searchUrl += "&geoUrn=" + encodeURIComponent(searchParams.location)
var page = await browser.newtab(searchUrl)
await page.wait({ waitTime: 3 + Math.floor(Math.random() * 3) })
var prospects = []
var maxPages = 3 // HARD LIMIT
for (var pageNum = 1; pageNum <= maxPages; pageNum++) {
var snap = await page.snapshot()
// Parse each result card:
// - Full name
// - Headline (title + company)
// - Location
// - Profile URL
// - Connection degree (2nd, 3rd)
// - Mutual connections count
// - "Connect" button presence (can we send request?)
// Store qualified prospects
// Filter: headline must contain target role keywords
if (pageNum < maxPages) {
// Navigate to next page
// Click "Next" button
await page.wait({ waitTime: 5 + Math.floor(Math.random() * 5) })
}
}
console.log("Found " + prospects.length + " matching prospects")
Visit top 10 profiles for deeper intel:
for (var i = 0; i < Math.min(prospects.length, 10); i++) {
var prospect = prospects[i]
await page.goto({ url: prospect.profileUrl })
await page.wait({ waitTime: 8 + Math.floor(Math.random() * 7) }) // 8-15 sec delay
var snap = await page.snapshot()
// Extract:
prospect.enriched = {
fullTitle: "", // "VP Marketing at Acme Corp"
tenure: "", // "2 years 3 months"
about: "", // First 200 chars of About section
previousRoles: [], // Last 2-3 positions
education: "",
recentPosts: [], // Last 3 posts: topic + date
isActive: false, // Posted in last 30 days?
mutualConnections: [], // Names of shared connections
skills: [] // Top 5 endorsed skills
}
console.log("Enriched " + (i + 1) + "/10: " + prospect.name +
" | " + prospect.enriched.fullTitle +
" | Active: " + prospect.enriched.isActive)
}
var sheet = await google.sheets.createSpreadsheet({
title: "LinkedIn Prospects - " + campaignName + " - " + new Date().toISOString().slice(0, 10),
sheets: ["Prospects", "Outreach Tracking", "Warm-Up Log"]
})
var headers = [[
"Name", "Title", "Company", "LinkedIn URL", "Connection Degree",
"Mutual Connections", "Active on LinkedIn", "Recent Post Topic",
"Best Outreach Hook", "Warm-Up Status", "Connection Status", "DM Status", "Notes"
]]
var rows = prospects.map(p => [
p.name,
p.enriched?.fullTitle || p.headline,
p.company,
p.profileUrl,
p.connectionDegree,
(p.enriched?.mutualConnections || []).join(", "),
p.enriched?.isActive ? "Yes" : "No",
p.enriched?.recentPosts?.length > 0 ? p.enriched.recentPosts[0] : "",
"", // Best outreach hook (filled after hook generation)
"Not Started", // Warm-up status
"Not Sent", // Connection status
"Not Sent", // DM status
""
])
await google.sheets.updateValues({
spreadsheetId: sheet.id,
range: "'Prospects'!A1",
values: headers.concat(rows)
})
console.log("Prospect sheet: https://docs.google.com/spreadsheets/d/" + sheet.id)
Why: Commenting on someone's post before sending a connection request increases acceptance rate from ~30% to ~60%+. This is Account-Based Marketing (ABM) applied to LinkedIn.
Timeline: 3-5 days of warm-up before connection request.
// For each prospect in the warm-up queue:
for (var i = 0; i < warmUpQueue.length; i++) {
var prospect = warmUpQueue[i]
// Visit their activity/posts page
await page.goto({ url: prospect.profileUrl + "/recent-activity/all/" })
await page.wait({ waitTime: 5 + Math.floor(Math.random() * 3) })
var snap = await page.snapshot()
// Check for posts within last 48 hours
// If found: prepare engagement
// If not: note "no recent posts, skip warm-up, go direct"
prospect.warmUpOpportunity = {
hasRecentPost: false,
postTopic: "",
postUrl: "",
suggestedComment: ""
}
}
For each prospect with a recent post, draft a 1-2 sentence comment:
// Comment rules:
// DO:
// - Reference specific points from their post
// - Add a relevant insight or personal experience
// - Ask a genuine follow-up question
// - Keep to 1-2 sentences max
//
// DON'T:
// - "Great post!" or "Well said!" (generic = suspicious)
// - Anything that sounds automated
// - Mention your product or company
// - Write paragraphs
// - Use emojis excessively
// Example comments by post type:
// If post is about industry challenge:
// "The [specific challenge] is real - we found that [relevant insight from
// experience]. Curious what's worked best for your team?"
// If post is sharing a win:
// "Impressive results on [specific metric they shared]. The [specific approach
// they mentioned] is underrated - more teams should try this."
// If post is thought leadership:
// "[Specific point] is spot on. One thing I'd add: [relevant addendum].
// Have you seen this play out differently in [specific context]?"
// Present all comments for user approval first
for (var prospect of warmUpQueue.filter(p => p.warmUpOpportunity.hasRecentPost)) {
console.log("Prospect: " + prospect.name)
console.log("Post topic: " + prospect.warmUpOpportunity.postTopic)
console.log("Suggested comment: " + prospect.warmUpOpportunity.suggestedComment)
console.log("---")
}
// After user approval:
await requestApproval({
reason: "Like and comment on " + approvedCount + " LinkedIn posts for warm-up engagement"
})
for (var prospect of approved) {
// Navigate to the post
await page.goto({ url: prospect.warmUpOpportunity.postUrl })
await page.wait({ waitTime: 3 })
// Like the post
var snap = await page.snapshot()
// Find and click Like button
// Comment
// Find comment box, click, type comment, submit
console.log("Engaged with " + prospect.name + "'s post")
// CRITICAL: Long delay between engagements
await page.wait({ waitTime: 120 + Math.floor(Math.random() * 60) }) // 2-3 minutes
}
// Update tracking sheet
// Columns: Date | Prospect | Action | Post Topic | Comment Text | Their Response?
// 4 template strategies (pick best fit per prospect):
var templates = {
// 1. Mutual connection reference
mutual: function(prospect) {
return "Hi " + prospect.firstName + ", I noticed we both know " +
prospect.enriched.mutualConnections[0] + ". I work in " + userIndustry +
" and your work at " + prospect.company + " caught my eye. Would love to connect!"
},
// 2. Engaged with their content (after warm-up)
engaged: function(prospect) {
return "Hi " + prospect.firstName + ", really enjoyed your recent post about " +
prospect.warmUpOpportunity.postTopic + ". I'm in a similar space and would " +
"love to stay connected on this."
},
// 3. Shared background
shared: function(prospect) {
return "Hi " + prospect.firstName + ", fellow " + sharedContext + " here! " +
"Working on " + relevantTopic + " and thought we'd have a lot to discuss."
},
// 4. Direct value (no prior interaction)
direct: function(prospect) {
return "Hi " + prospect.firstName + ", I've been following " + prospect.company +
"'s growth in " + prospect.industry + ". Working on something that might be " +
"relevant to " + prospect.enriched.fullTitle.split(" at ")[0] + "s. Happy to " +
"share insights!"
}
}
// For each prospect, pick the best template:
for (var prospect of prospects) {
var note = ""
if (prospect.warmUpOpportunity?.engaged) {
note = templates.engaged(prospect) // Best: we already interacted
} else if (prospect.enriched?.mutualConnections?.length > 0) {
note = templates.mutual(prospect) // Good: shared connections
} else {
note = templates.direct(prospect) // Default: cold but personalized
}
// CRITICAL: Must be under 300 characters
if (note.length > 300) {
note = note.substring(0, 297) + "..."
}
prospect.connectionNote = note
console.log(prospect.name + " (" + note.length + "/300): " + note)
}
// Present all for review
console.log("Connection requests ready: " + prospects.length)
for (var p of prospects) {
console.log(" " + p.name + " @ " + p.company)
console.log(" Note: " + p.connectionNote)
console.log(" ---")
}
await requestApproval({
reason: "Send " + approvedProspects.length + " LinkedIn connection requests"
})
for (var i = 0; i < approvedProspects.length; i++) {
var prospect = approvedProspects[i]
// Navigate to profile
await page.goto({ url: prospect.profileUrl })
await page.wait({ waitTime: 3 + Math.floor(Math.random() * 3) })
var snap = await page.snapshot()
// Find "Connect" button and click
// Click "Add a note"
// Type the personalized connection note
// Click "Send"
console.log("Sent " + (i + 1) + "/" + approvedProspects.length + ": " + prospect.name)
// Update tracking sheet
// Set connection status to "Sent" with date
// CRITICAL: Long delay between requests
if (i < approvedProspects.length - 1) {
var delay = 90 + Math.floor(Math.random() * 90) // 90-180 seconds
console.log("Waiting " + Math.round(delay / 60) + " min before next request...")
await page.wait({ waitTime: delay })
}
}
// Check pending connections (run daily or on-demand)
await page.goto({
url: "https://www.linkedin.com/mynetwork/invitation-manager/sent/"
})
await page.wait({ waitTime: 3 })
// Parse sent invitations:
// - Who accepted? -> Move to DM queue
// - Who is still pending? -> Check again tomorrow
// - Who declined / withdrew? -> Mark as closed
// Also check new connections:
await page.goto({
url: "https://www.linkedin.com/search/results/people/?network=%5B%22F%22%5D&origin=FACETED_SEARCH&sortBy=%22recently_connected%22"
})
// Cross-reference with prospect list to find new acceptances
After a prospect accepts your connection request, run a warm DM sequence:
// Goal: Start a conversation, NOT sell
var dm1Template = function(prospect) {
return "Hey " + prospect.firstName + "! Thanks for connecting. " +
"Really enjoyed your perspective on [" + prospect.warmUpOpportunity?.postTopic ||
prospect.enriched?.about?.substring(0, 50) + "]. " +
"I'm working on [brief one-liner about your product] - would love to hear " +
"how you're currently handling [relevant pain point] at " + prospect.company + ". " +
"No pitch, genuinely curious!"
}
// Rules:
// - Lead with THEM, not you
// - Reference something specific (their post, their company, shared interest)
// - Ask a genuine question
// - "No pitch" or "genuinely curious" signals good intent
// - Keep under 500 characters
// Goal: Transition to meeting request
// CRITICAL: Only send if they replied positively to Touch 1
var dm2Template = function(prospect, theirReply) {
return "That's really interesting about [reference their reply]. " +
"We've seen similar challenges with teams at [similar company/role]. " +
"Would you be open to a 15-min chat? I have some ideas that might " +
"help with [specific thing they mentioned]. Happy to share regardless!"
}
// Rules:
// - Reference their actual reply (proves you read it)
// - Keep the ask small (15 min, not 30 or 60)
// - "Happy to share regardless" reduces pressure
// - If they said "not interested" -> thank them and STOP
// Goal: Value-add touch, no ask
// This is the LAST touch if they haven't responded
var dm3Template = function(prospect) {
return "Hey " + prospect.firstName + ", just wanted to share this " +
"[relevant resource/insight/article] I came across - thought it might " +
"be useful for [their situation at their company]. No worries if " +
"you're swamped!"
}
// Rules:
// - Provide genuine value (article, insight, data point)
// - No ask whatsoever
// - Short (2-3 sentences max)
// - "No worries if you're swamped" = graceful exit
// - If still no reply after this: STOP. Do not send Touch 4.
1. NEVER send Touch 2 if they didn't reply to Touch 1
2. Maximum 3 DMs per prospect total (lifetime, not per session)
3. If they reply positively -> offer to schedule a call
4. If they reply "not interested" or negatively -> thank them, STOP forever
5. ALL DMs require user approval before sending
6. Minimum 24 hours between DMs to the same person
7. Each DM must be personalized (no copy-paste across prospects)
8. Never pitch in Touch 1 (it's a conversation starter, not a sales email)
9. Track all DMs in Google Sheet with dates and responses
await requestApproval({
reason: "Send " + dmQueue.length + " LinkedIn DMs to accepted connections"
})
for (var i = 0; i < dmQueue.length; i++) {
var dm = dmQueue[i]
// Navigate to messaging with this prospect
await page.goto({
url: "https://www.linkedin.com/messaging/compose/?recipients=" + dm.profileUrn
})
// Or navigate to their profile and click "Message"
await page.wait({ waitTime: 3 })
var snap = await page.snapshot()
// Type message in the message box
// Click Send
console.log("DM sent to " + dm.name + " (Touch " + dm.touchNumber + ")")
// Update tracking sheet
// Set DM status, date, touch number
// Rate limiting
if (i < dmQueue.length - 1) {
var delay = 90 + Math.floor(Math.random() * 90)
console.log("Waiting " + Math.round(delay / 60) + " min before next DM...")
await page.wait({ waitTime: delay })
}
}
Name | Title | Company | LinkedIn URL | Connection Degree |
Warm-Up Status | Warm-Up Date | Connection Status | Connection Date |
DM1 Status | DM1 Date | DM2 Status | DM2 Date | DM3 Status | DM3 Date |
Meeting Booked? | Notes
Date | Prospect | Action Type | Details | Response | Next Action Due
Date | Prospect | Post Topic | Action (Like/Comment) | Comment Text | Their Response
Track and report on:
Connection acceptance rate: Target >50% (with warm-up)
DM response rate: Target >25%
Meeting book rate: Target >10% of connections
Average time to meeting: Target <14 days from first touch
Account safety: Target 100% (zero restrictions/bans)