From agi-super-team
Automates LinkedIn interactions via Chrome DevTools Protocol using mouse/keyboard/screenshots. Collect messages, search profiles, send connection requests.
How this skill is triggered — by the user, by Claude, or both
Slash command
/agi-super-team:linkedin-cdpThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> LinkedIn automation via Chrome DevTools Protocol. Input-only (mouse/keyboard/screenshots). Zero DOM access.
LinkedIn automation via Chrome DevTools Protocol. Input-only (mouse/keyboard/screenshots). Zero DOM access.
LinkedInMessagesLinkedInMessagesLinkedInMessages.send_message()LinkedInSearchLinkedInProfileLinkedInConnectLinkedInConnect.screenshot_invitations()pip install websocket-client requests--remote-debugging-port=9222 (separate instance)| What | Path |
|---|---|
| Script | $HOME/linkedin-cdp/linkedin_cdp.py |
| Modules | $HOME/linkedin-cdp/linkedin_*.py |
| Rate limiter | $HOME/linkedin-cdp/rate_limiter.py |
| Screenshots | /tmp/li_screenshots/shot_*.jpg |
IMPORTANT: Use the binary path directly, NOT open -a 'Google Chrome'.
open -a on macOS opens a tab in existing Chrome instead of a separate instance.
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 \
'--remote-allow-origins=*' \
--user-data-dir="$HOME/chrome-debug-profile" \
"https://www.linkedin.com" > /dev/null 2>&1 &
Run in background (run_in_background: true), then verify:
sleep 3 && curl -s "http://localhost:9222/json/version"
--user-data-dir MUST differ from user's main Chrome profile$HOME/chrome-debug-profile persists login session between runsEvery session may have different window size / DPR. On first run, take a calibration screenshot and determine the coordinate mapping:
import sys, subprocess, time
sys.path.insert(0, '$HOME/linkedin-cdp')
from linkedin_cdp import LinkedInBot
bot = LinkedInBot()
bot.connect()
# Take calibration screenshot — returns file path
path = bot.take_screenshot()
print(path) # /tmp/li_screenshots/shot_0001.jpg
# Get image dimensions to determine DPR
result = subprocess.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path],
capture_output=True, text=True)
print(result.stdout)
bot.close()
Then read the calibration screenshot with Read tool. Calculate:
Store the DPR for all subsequent coordinate calculations in this session.
All click_at(), _click() calls use CSS coordinates, not image pixels.
Formula: CSS_coord = image_pixel_coord / DPR
Typical Retina Mac (DPR=2, viewport ~1531x801):
import sys
sys.path.insert(0, '$HOME/linkedin-cdp')
from linkedin_cdp import LinkedInBot
bot = LinkedInBot()
bot.connect()
# All actions use CSS coordinates
bot.click_at(x, y) # click + screenshot
bot.type_text("text") # type with human-like delays
bot.scroll_wheel(delta_y=500) # scroll (keyword arg only!)
bot.take_screenshot() # returns file path (JPEG)
bot.navigate_to(url) # navigate + auto reconnect
bot.reconnect_to_tab() # reconnect WebSocket after page change
bot.close()
These modal/dialog coordinates are consistent across sessions at the same viewport size. Recalibrate if window size changes.
After clicking "Connect" on a profile:
| Element | CSS (x, y) | Notes |
|---|---|---|
| "Add a note" button | (751, 239) | White/outline button |
| "Send without a note" button | (911, 239) | Blue button |
| Note text field (click to focus) | (752, 259) | After clicking "Add a note" |
| "Send" button (with note) | (968, 393) | Blue, active after typing |
| "Cancel" button | (889, 393) |
| Element | CSS (x, y) | Notes |
|---|---|---|
| "Connect" button | (~270, 467-499) | y varies by profile layout (banner height, etc.) |
| "Message" button | (~383, 467-499) | Next to Connect |
/messaging/ — conversation list on left, active thread on right.
| Element | CSS (x, y) | Notes |
|---|---|---|
| "Write a message..." text field | (715, 604) | Click to focus, then type_text() |
| Send button | (885, 724) | Active (blue) only after typing |
/mynetwork/invitation-manager/ — lists received/sent invitations.
| Element | CSS (x, y) | Notes |
|---|---|---|
| "Accept" button (first invite) | (~920, 274) | Approximate — verify via screenshot |
| "Ignore" button (first invite) | (~838, 274) | Next to Accept |
| "Received" / "Sent" tabs | (~265, 142) / (~350, 142) | Top of page |
| Element | CSS (x, y) | Notes |
|---|---|---|
| First result name | (~305, 155) | Approximate — always verify via screenshot |
| Second result name | (~305, 280) | Approximate — positions shift with ads/banners |
LinkedInBot (linkedin_cdp.py) -- base class
|-- CDP core: connect(), _send(), close(), reconnect_to_tab()
|-- Mouse: _bezier(), _human_path(), _move_to(), _click(), _maybe_fake_hover()
|-- Keyboard: type_text(), press_key()
|-- Scroll: scroll_wheel()
|-- Screenshot: take_screenshot() -> path, take_screenshot_base64(), save_screenshot()
|-- Navigation: navigate_to(), wait_for_page()
|-- Convenience: click_at(x,y) -> screenshot, scroll_and_screenshot()
|
+-- LinkedInMessages (linkedin_messages.py)
+-- LinkedInSearch (linkedin_search.py)
+-- LinkedInProfile (linkedin_profile.py)
+-- LinkedInConnect (linkedin_connect.py)
| File | Class | Key Methods |
|---|---|---|
linkedin_cdp.py | LinkedInBot | click_at(), type_text(), scroll_wheel(), take_screenshot() -> path, take_screenshot_base64(), navigate_to() |
linkedin_messages.py | LinkedInMessages | screenshot_conversations(), open_conversation(), send_message(), collect_screenshots() |
linkedin_search.py | LinkedInSearch | search_people(), search_companies(), next_page() |
linkedin_profile.py | LinkedInProfile | view_profile(), screenshot_full_profile(), scroll_to_section() |
linkedin_connect.py | LinkedInConnect | view_profile(), send_connection_note(), screenshot_invitations(), accept_invitation() |
rate_limiter.py | RateLimiter | can_search(), can_view_profile(), wait_if_needed() |
import sys, time
sys.path.insert(0, '$HOME/linkedin-cdp')
from linkedin_cdp import LinkedInBot
bot = LinkedInBot()
bot.connect()
# 1. Search
bot.navigate_to('https://www.linkedin.com/search/results/people/?keywords=Name%20Company')
time.sleep(4)
bot.reconnect_to_tab()
time.sleep(2)
# take_screenshot() returns file path — read with Read tool to find coordinates
# 2. Click profile (coordinates from screenshot / DPR)
path = bot.click_at(305, 155) # first result — verify from screenshot!
time.sleep(4)
bot.reconnect_to_tab()
# Read path with Read tool to verify correct profile
# 3. Click Connect (~270, 467-499 — verify from screenshot)
bot.click_at(270, 499)
time.sleep(2)
# Modal appears
# 4. Click "Add a note" (fixed coordinate)
bot.click_at(751, 239)
time.sleep(1.5)
# 5. Click text field + type note
bot._click(752, 259)
time.sleep(0.5)
bot.type_text("Hi Name, personalized note here...")
time.sleep(1)
# 6. Click Send (fixed coordinate)
bot.click_at(968, 393)
# Done — verify "Pending" status + "Invitation sent" toast
bot.close()
from linkedin_messages import LinkedInMessages
lm = LinkedInMessages()
lm.connect()
path = lm.screenshot_conversations()
# path = '/tmp/li_screenshots/shot_NNNN.jpg' — read with Read tool
path = lm.open_conversation(200, 350) # coordinates from screenshot
# Read path with Read tool
lm.close()
import time
from linkedin_profile import LinkedInProfile
prof = LinkedInProfile()
prof.connect()
prof.navigate_to('https://linkedin.com/in/username')
time.sleep(4)
prof.reconnect_to_tab()
paths = [prof.take_screenshot()]
for _ in range(4):
prof.scroll_wheel(delta_y=500)
time.sleep(1.5)
paths.append(prof.take_screenshot())
# paths = ['/tmp/li_screenshots/shot_0001.jpg', ...] — read each with Read tool
prof.close()
| Problem | Solution |
|---|---|
WebSocketConnectionClosedException after navigate | Call reconnect_to_tab() — page change breaks WebSocket |
| Click misses target | Verify DPR: image_pixels / DPR = CSS coords. Re-run calibration. |
screenshot_full_profile() crashes | Use manual navigate + reconnect + scroll loop (see View Profile example) |
scroll_wheel() TypeError | Use keyword arg only: scroll_wheel(delta_y=500), NOT positional args |
| Chrome opens tab in existing browser | Use binary path directly, NOT open -a 'Google Chrome' |
| CDP port conflict | Change --remote-debugging-port=9223 and update CDP_PORT in linkedin_cdp.py |
| Modal coordinates wrong | Window resized? Re-run calibration. Fixed coords assume viewport ~1531x801. |
scroll_wheel() takes delta_y keyword arg only. Not positional.navigate_to() or page-changing click, always reconnect_to_tab().sys.path.insert(0, '$HOME/linkedin-cdp') before imports./tmp/li_screenshots/shot_*.jpg — read them with Read tool. Old files auto-cleaned (keeps last 50).| Action | Conservative | Moderate |
|---|---|---|
| Profile views | 50 | 100 |
| Searches | 20 | 50 |
| Connection requests | 15 | 25 |
| Messages sent | 30 | 50 |
Zero DOM access — NEVER Runtime.evaluate, querySelector, innerText.
Screenshot-based reading — Page.captureScreenshot (JPEG, quality 80) saved to files, not base64 in memory.
Human-like mouse — unique Bezier curves, tremor, overshoot, micro-pauses, speed variation.
Real Chrome — not headless. Normal fingerprint.
Rate limiting — built-in daily caps.
Human-in-the-loop — Claude reads screenshots, adds natural irregularity.
Runtime.evaluate or CDP Runtime domain — #1 way bots get caughtAfter sending a connection request or message, ALWAYS log to CRM:
companies.csv (if new)people.csv (if new)leads.csv (stage=new, source=linkedin, source_direction=outbound)activities.csv with the exact message text in notes field:
type: outreachchannel: linkedindirection: outboundsubject: "LinkedIn connection request with note" (or "LinkedIn message")notes: the full text of the message sentDo NOT skip step 4. The message text must be preserved in CRM for follow-up context.
add-lead — add found contacts to CRMupdate-lead — update lead status after outreachlog-activity — log activity to activities.csvemail-send-bulk — follow up via email after LinkedIn connectnpx claudepluginhub aaaaqwq/agi-super-team --plugin agi-super-teamAutomates LinkedIn actions via browser relay or cookies: messaging, profile viewing, searching, and connection requests. Includes safety rules and rate limiting guidance.
Guides lhremote MCP usage for automating LinkedIn tasks via LinkedHelper and CDP, covering discovery, instance lifecycle, and collection workflows.
Automates LinkedIn interactions: fetch profiles, search people/companies, send messages, manage connections, create posts, and more via a CLI tool backed by a cloud browser.