Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Read this when you need to use LinkedIn.
.claude/skills/kunanonj-aside-site-linkedin/SKILL.md| Model | Eval pass | Runs |
|---|---|---|
| gemini-3.6-flash | 100% | 2 |
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-06 | ✗→✓ | ▲ Improved | 131% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 122% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 165% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 188% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 155% | 0% |
Use the linkedin global in the REPL tool. It allows you to control the LinkedIn website with API interface- no tab open needed.
js// Current viewer profile. Response shape is Voyager-native: // { data: { plainId, '*miniProfile' }, included: [MiniProfile, ...] } const me = await linkedin.getMe(); const miniProfile = me.included?.find((item) => item.$type === 'com.linkedin.voyager.identity.shared.MiniProfile'); console.log(miniProfile?.publicIdentifier, me.data?.plainId); // Public profile lookup (public identifier or full profile URL) const profile = await linkedin.getProfile('johndoe'); console.log(profile.fullName, profile.headline); // Search people / companies const people = await linkedin.searchPeople('software engineer at openai'); console.log(people.results.map((item) => item.title)); const companies = await linkedin.searchCompanies('openai'); console.log(companies.results.map((item) => item.title)); // Company / job / posts const company = await linkedin.getCompany('microsoft'); const job = await linkedin.getJob('4242424242'); const posts = await linkedin.getUserPosts('johndoe'); // Messaging — inbox + conversation history (paginate by timestamp, not offset) const inbox = await linkedin.getInbox(); const convo = await linkedin.getConversation(inbox.conversations[0].threadId); // Reply to an existing thread await linkedin.sendMessage({ threadId: inbox.conversations[0].threadId, text: 'Hey!' }); // Start a new thread (accepts public identifiers OR profile URNs) await linkedin.sendMessage({ recipients: ['johndoe'], text: 'Hi, nice to meet you.' }); // Connection requests await linkedin.sendInvitation({ identifier: 'johndoe', customMessage: 'Would love to connect.' }); const received = await linkedin.getReceivedInvitations(); if (received.invitations[0]) { await linkedin.acceptInvitation(received.invitations[0]); // or: linkedin.ignoreInvitation(received.invitations[0]) } // Withdrawing a previously-sent invitation (URN captured from sendInvitation's response) // await linkedin.withdrawInvitation(invitationUrn); // If LinkedIn rotates the session cookies: linkedin.invalidateCache();
linkedin.getMe(): Promise<object>Fetch the authenticated viewer from /voyager/api/me. Returns the raw Voyager payload { data: { plainId, '*miniProfile', ... }, included: [MiniProfile, ...] }; the viewer's publicIdentifier, firstName, lastName, etc. live on the MiniProfile entry inside included.
linkedin.getProfile(identifier: string): Promise<LinkedInProfile>Get a public profile by LinkedIn public identifier or profile URL.
tsinterface LinkedInProfile { entityUrn?: string; publicIdentifier?: string; firstName?: string; lastName?: string; fullName: string; headline?: string; summary?: string; location?: string; industryName?: string; occupation?: string; profilePicture?: string; backgroundPicture?: string; experience: Array<{ title?: string; companyName?: string; description?: string; location?: string; startDate?: string; endDate?: string; }>; education: Array<{ schoolName?: string; degreeName?: string; fieldOfStudy?: string; startDate?: string; endDate?: string; }>; raw: object; }
linkedin.searchPeople(query: string, opts?: { offset?: number, limit?: number }): Promise<LinkedInSearchResponse>Search people results through Voyager search.
linkedin.searchCompanies(query: string, opts?: { offset?: number, limit?: number }): Promise<LinkedInSearchResponse>Search company results through Voyager search.
tsinterface LinkedInSearchResponse { paging: { offset: number; count: number; total: number }; results: Array<{ entityUrn: string; title: string; headline?: string; subline?: string; summary?: string; navigationUrl?: string; image?: string; type?: string; distance?: string; }>; }
linkedin.getCompany(slug: string): Promise<object | undefined>Fetch a company by its LinkedIn public slug (for example microsoft).
linkedin.getJob(jobId: string): Promise<object>Fetch a job posting by numeric job ID or urn:li:jobPosting:*.
linkedin.getUserPosts(identifier: string, opts?: { start?: number, count?: number }): Promise<LinkedInPost[]>Fetch posts authored by a user.
tsinterface LinkedInPost { urn?: string; postUrl?: string; text?: string; authorName?: string; authorHeadline?: string; publishedAt?: string; commentCount?: number; likeCount?: number; shareCount?: number; }
linkedin.getInbox(opts?: { createdBefore?: number }): Promise<LinkedInInboxResponse>List the viewer's messaging inbox. LinkedIn paginates by timestamp cursor, not offset: pass the previous page's nextCreatedBefore back in as createdBefore to fetch older conversations. The response is intentionally compact: no raw Voyager dump, just practical thread metadata plus a parsed preview of the latest message.
tsinterface LinkedInParticipant { profileUrn?: string; profileUrl?: string; fullName?: string; headline?: string; distance?: string; verified?: boolean; isSelf?: true; } type LinkedInMessageAttachmentKind = | 'audio' | 'conversation_ad' | 'external_media' | 'file' | 'forwarded_message' | 'image' | 'inmail' | 'message_ad' | 'replied_message' | 'unavailable' | 'video' | 'video_meeting'; interface LinkedInConversationMessagePreview { messageId?: string; sender?: LinkedInParticipant; subject?: string; text?: string; sentAt?: string; // ISO string format?: string; attachmentKinds: LinkedInMessageAttachmentKind[]; } interface LinkedInConversation { threadId?: string; // stable tail like "2-ABC==" url?: string; title?: string; conversationType?: string; categories: string[]; isGroupChat?: boolean; isArchived?: boolean; createdAt?: string; // ISO string lastReadAt?: string; // ISO string lastActivityAt?: string; // ISO string participants: LinkedInParticipant[]; // current viewer excluded unreadCount?: number; read?: boolean; canReply: boolean; lastMessage?: LinkedInConversationMessagePreview; } interface LinkedInInboxResponse { nextCreatedBefore?: number; // Pass as opts.createdBefore on next call conversations: LinkedInConversation[]; }
linkedin.getConversation(threadIdOrConversationUrn: string, opts?: { createdBefore?: number }): Promise<LinkedInConversationResponse>Fetch messages in a single thread, newest first. You can pass either the full conversation URN or the inbox threadId. Same timestamp-cursor pagination.
tsinterface LinkedInTextLink { url: string; text?: string; } interface LinkedInMessageAttachmentAction { label?: string; type: 'external_website' | 'human_handoff' | 'lead_gen' | 'not_interested'; url?: string; leadGenFormUrn?: string; } interface LinkedInMessageAttachment { kind: LinkedInMessageAttachmentKind; title?: string; text?: string; url?: string; previewImageUrl?: string; mediaType?: string; sizeBytes?: number; assetUrn?: string; hostProfileUrn?: string; inmailType?: string; advertiserLabel?: string; campaignUrn?: string; status?: string; actions?: LinkedInMessageAttachmentAction[]; } interface LinkedInMessage { messageId?: string; threadId?: string; sender?: LinkedInParticipant; subject?: string; text?: string; sentAt?: string; // ISO string format?: string; links: LinkedInTextLink[]; attachments: LinkedInMessageAttachment[]; } interface LinkedInConversationResponse { messages: LinkedInMessage[]; nextCreatedBefore?: number; }
linkedin.sendMessage(opts: { threadId?: string; recipients?: string[]; text: string }): Promise<LinkedInSendMessageResult>Send a direct message. Provide EITHER threadId (reply to an existing thread) OR recipients (array of public identifiers or profile URNs — starts a new thread). Returns the new message ID plus thread identifier.
linkedin.subscribeMessages(onMessage, opts?: { pollIntervalMs?: number }): Promise<void>Poll-based subscription to new messages across all conversations. Calls onMessage(msg, conversation) once per new message. Returns when the REPL run aborts. Defaults to a 20s poll interval (minimum 5s — shorter intervals trigger throttling fast). LinkedIn's SSE realtime/connect endpoint requires session-bound headers that are not recoverable from cookies alone, so polling is the idiomatic approach here.
linkedin.sendInvitation(opts: { identifier: string; customMessage?: string }): Promise<object>Send a connection request. identifier can be a public identifier (johndoe), profile URL, or urn:li:fsd_profile:* URN. customMessage is capped at 300 characters and is Premium-only as of 2024 — free accounts that pass a note silently drop it.
linkedin.getReceivedInvitations(opts?: { start?: number; count?: number }): Promise<LinkedInInvitationListResponse>List pending invitations the viewer has received.
tsinterface LinkedInInvitation { entityUrn?: string; invitationId?: string; sharedSecret?: string; // required to accept/ignore — echo back unchanged type?: 'sent' | 'received'; message?: string; sentAt?: number; counterpart: { profileUrn?: string; publicIdentifier?: string; firstName?: string; lastName?: string; headline?: string }; raw: object; }
linkedin.acceptInvitation(invitation: LinkedInInvitation | string, sharedSecret?: string): Promise<void>Accept a received invitation. Pass the invitation object from getReceivedInvitations directly (it carries the required sharedSecret). If you pass a string URN/id, you MUST also provide sharedSecret as the second argument.
linkedin.ignoreInvitation(invitation: LinkedInInvitation | string, sharedSecret?: string): Promise<void>Reject a received invitation. Same argument shape as acceptInvitation.
linkedin.withdrawInvitation(invitation: LinkedInInvitation | string): Promise<void>Withdraw a previously-sent invitation. Accepts an invitation URN/id string. Capture the URN from sendInvitation's response payload. Listing sent invitations is not currently supported by Voyager — scrape https://www.linkedin.com/mynetwork/invitation-manager/sent/ via the browser tools if you need to enumerate pending sends.
linkedin.invalidateCache(): voidClear cached LinkedIn session cookies for the current Chrome profile.
If you have to manually open a browser tab, navigate to these URLs:
https://www.linkedin.com/feedhttps://www.linkedin.com/messaging/https://www.linkedin.com/search/results/${all|people|posts|companies|products|schools}/?keywords=${query}https://www.linkedin.com/mynetwork/invitation-manager/https://www.linkedin.com/notifications/?filter=allhttps://www.linkedin.com/sales/homehttps://www.linkedin.com/sales/accounts/dashboardhttps://www.linkedin.com/sales/lists/peoplehttps://www.linkedin.com/sales/inbox/LinkedIn aggressively throttles automation. The linkedin global already enforces a ~1–1.75s per-request floor with jitter per account, which is enough for read traffic at interactive pace but not enough to hide bulk write traffic. The risk is not just a rate-limit: sustained abuse can trigger CAPTCHA challenges, temporary restrictions, or a permanent ban of the user's real account.
HIGH RISK — confirm explicitly with the user, then throttle hard:
sendInvitation — LinkedIn enforces ~100 invitations/week as a hard cap.Acceptance rate below ~20% accelerates restrictions. Default to ≤15/day and space them 2–5 min apart with jitter.
sendMessage to recipients the user has never messaged before (cold outreach).Default to ≤20/day with 3–10 min spacing and never send identical copy.
withdrawInvitation in bulk (looks like spam cleanup; same cap applies).MEDIUM RISK — fine at interactive pace, throttle bulk jobs:
sendMessage replies in existing threads (warm conversations). Cap at ≤50/day.getProfile / searchPeople at scale. ≤100/day on free; ≤300/day Premium.subscribeMessages with pollIntervalMs < 10_000.LOW RISK — ambient use is fine:
getMe, getInbox, getConversation, getReceivedInvitations, getCompany,getJob, getUserPosts, searchCompanies.
| Operation | Min spacing | Daily cap (established account) | Weekly cap | |---|---|---|---| | sendInvitation | 2–5 min | 15–25 | 80 hard (LinkedIn: 100) | | sendMessage (cold) | 3–10 min | 20–30 | — | | sendMessage (reply) | 1–3 min | 40–50 | — | | getProfile / search | 5–15 s | 100–150 | — | | subscribeMessages poll | ≥20 s | — | — |
Multiply caps by 0.5× for accounts <30 days old or dormant for 2+ weeks (LinkedIn flags "slide & spike": inactive → sudden burst is the single strongest automation signal). Multiply by 0× for brand-new accounts: do a week of manual-only activity first.
429 → back off exponentially (1s → 2s → 4s → 8s → 16s, max 5 retries), orhonor Retry-After if present. After 3 consecutive 429s: stop for 1–2 hours.
999 → IP-level block from LinkedIn's WAF. Stop immediately, wait 45s minimum.401 / 403 → session invalidated. linkedin.invalidateCache() is calledautomatically, but the user must re-authenticate before retrying.
200 with no expected data, or a redirect to /checkpoint/... → account isbeing challenged. Stop all writes for 24–48 hours.
sendMessage bodies in a batch — LinkedIn has message-similarityML. Vary at least the greeting line.
sendInvitation or bulk sendMessage right after the user logged back infrom a new device/IP — LinkedIn scrutinizes fresh sessions for several hours.
LinkedInVoyagerError during writes: on 429/999/403 you MUSTstop the whole batch, not retry the next item.
For any loop that does >3 write operations (sendInvitation, cold sendMessage), confirm the full list with the user first, cap at the daily limit above, and emit progress via replPrint so the user can interrupt.
sleep a bit to load more posts.linkedin REPL global for profile/search/company/job/post data before falling back to DOM scraping.sendMessage, sendInvitation), read the Bot detection & throttling section above and throttle accordingly.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-08 | fail→fail | 7,946 | 5,934 | -25% | 1 | 1 | 0% | 1,445 | 4,774 | +230% | 0 | 0 | — |
case-06 | fail→pass | 11,413 | 4,688 | -59% | 1 | 1 | 0% | 2,229 | 5,146 | +131% | 0 | 0 | — |
case-07 | fail→pass | 14,900 | 4,911 | -67% | 1 | 1 | 0% | 2,264 | 5,017 | +122% | 0 | 0 | — |
case-01 | fail→fail | 4,666 | 8,913 | +91% | 1 | 1 | 0% | 636 | 4,583 | +621% | 0 | 0 | — |
case-02 | fail→fail | 5,368 | 6,462 | +20% | 1 | 1 | 0% | 721 | 4,520 | +527% | 0 | 0 | — |
case-03 | fail→fail | 3,866 | 10,155 | +163% | 1 | 1 | 0% | 530 | 4,618 | +771% | 0 | 0 | — |
case-04 | pass→pass | 11,044 | 4,630 | -58% | 1 | 1 | 0% | 2,098 | 5,063 | +141% | 0 | 0 | — |
case-05 | fail→pass | 9,027 | 3,421 | -62% | 1 | 1 | 0% | 1,747 | 4,634 | +165% | 0 | 0 | — |
case-09 | fail→pass | 10,373 | 8,005 | -23% | 1 | 1 | 0% | 1,870 | 5,386 | +188% | 0 | 0 | — |
case-10 | pass→pass | 9,581 | 6,687 | -30% | 1 | 1 | 0% | 1,527 | 5,036 | +230% | 0 | 0 | — |
case-11 | fail→pass | 11,864 | 6,189 | -48% | 1 | 1 | 0% | 2,065 | 5,268 | +155% | 0 | 0 | — |
case-12 | pass→pass | 10,083 | 4,588 | -54% | 1 | 1 | 0% | 1,595 | 4,853 | +204% | 0 | 0 | — |
case-13 | fail→pass | 9,962 | 5,896 | -41% | 1 | 1 | 0% | 1,997 | 5,194 | +160% | 0 | 0 | — |
case-14 | fail→pass | 10,124 | 3,720 | -63% | 1 | 1 | 0% | 2,182 | 4,840 | +122% | 0 | 0 | — |
case-15 | pass→pass | 8,768 | 4,173 | -52% | 1 | 1 | 0% | 1,669 | 4,823 | +189% | 0 | 0 | — |
case-16 | fail→pass | 9,448 | 2,192 | -77% | 1 | 1 | 0% | 1,683 | 4,437 | +164% | 0 | 0 | — |
case-17 | fail→pass | 12,476 | 1,335 | -89% | 1 | 1 | 0% | 1,813 | 4,244 | +134% | 0 | 0 | — |
case-18 | fail→pass | 7,376 | 4,117 | -44% | 1 | 1 | 0% | 1,222 | 4,801 | +293% | 0 | 0 | — |
case-19 | pass→fail | 10,751 | 15,208 | +41% | 1 | 1 | 0% | 1,870 | 7,167 | +283% | 0 | 0 | — |
case-20 | pass→fail | 6,487 | 7,787 | +20% | 1 | 1 | 0% | 1,161 | 4,557 | +293% | 0 | 0 | — |
case-21 | fail→fail | 12,689 | 4,865 | -62% | 1 | 1 | 0% | 2,952 | 4,416 | +50% | 0 | 0 | — |
case-22 | fail→pass | 6,170 | 8,772 | +42% | 1 | 1 | 0% | 1,094 | 5,125 | +368% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted, and 18 counted toward the lift figure. The other 4 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +41 percentage points is the difference between those two pass rates over the 18 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.