# Linkedin Control

> Read and operate LinkedIn from an agent by driving the user's own logged-in browser, because LinkedIn cannot be fetched or scraped from outside a session. Use when a task needs a LinkedIn profile, company page, post, comment thread, activity history, connection list, invitation queue, inbox, search result, or Sales Navigator record; when drafting a connection note, DM, comment, or post that a human will send; or when a LinkedIn selector, click, or extraction just broke. Covers the shadow-DOM layout, the durable addressing attributes, the wrong-person targeting hazard, the rate and policy limits, and the failure catalogue. Do not use for sending at scale or for bulk data collection.

- Skill: `seryozh/linkedin-control` (Agent Skill, multi-file: 9 files)
- Install (CLI): `npx skillmds@latest add seryozh/linkedin-control`
- Raw SKILL.md: https://api.skillmd.com/api/skills/seryozh/linkedin-control/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: Seryozh (https://skillmd.com/u/seryozh)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/seryozh/linkedin-control

---


# LinkedIn Control

LinkedIn is the one major surface that refuses every normal extraction route. A plain request
gets `HTTP 999` with an authwall body no matter which user agent you send, so `curl`, `WebFetch`,
and every headless fetch return nothing usable. The reflex is to give up and buy a data vendor.

The way through is that the block is on *anonymous* access, not on the page. Inside a browser
that already holds the user's session, LinkedIn is an ordinary web application that can be read,
navigated, and operated. This skill is the map of that application as it actually exists, written
from live probing rather than from selectors copied out of old blog posts.

One idea underneath everything here: **you are not scraping a site, you are operating a logged-in
application on behalf of its owner, and the whole craft is addressing the right element and
stopping before the irreversible click.**

## Control plane

This skill owns how LinkedIn is addressed, read, and operated, plus the safety gate on anything
that leaves the account. It does not own message wording, which belongs to the user's own voice
and drafting skills, and it does not own campaign strategy or list building.

The rule that governs every write path, adopted after a real misfire described below:

> **The agent reads LinkedIn. The human clicks the buttons that other people can see.**

Reading, navigating, extracting, and drafting need no permission. Anything that becomes visible to
another person, meaning an invitation, a message, a comment, a reaction, a post, a follow, or an
accept, requires the user's explicit approval for that specific action in that specific turn.
Approval for one send is never approval for the next one.

## Step 0: use a surface that carries the session

Four browser surfaces commonly exist in these runtimes and they are not interchangeable. Only some
of them carry the user's LinkedIn cookies.

| Surface | Carries the session | Use it for |
|---|---|---|
| Chrome extension tools (`claude-in-chrome`, real Chrome) | Yes | Default for everything. Takes an explicit `tabId`, so page state cannot drift. |
| AppleScript Chrome control (`Control_Chrome`) | Yes | Fallback when the extension disconnects. Acts on whichever tab is active, so it drifts. |
| A CDP Chrome on a dedicated debug profile | Yes, after one manual login | Long unattended runs and scripted multi-step work. See `references/cdp-rig.md`. |
| The in-app preview pane (`Claude_Browser`) | **No** | Never use for LinkedIn. It has none of the user's cookies and lands on the login page. |

With the AppleScript surface, return `location.href` inside every evaluated payload and assert it
before trusting the result, because it reads whatever tab happens to be frontmost and will happily
hand you a different person's page.

## Step 1: probe before you select, every time

LinkedIn is mid-migration between two front-end stacks and the same account sees both on
different routes on the same day. Verified live on 2026-08-17:

- The **new stack** renders inside an open shadow root on `div#interop-outlet`. Plain
  `document.querySelector` finds nothing there. Class names are hashed into forms like
  `_77e57c5d _3f9c3a5c`, so every class-based selector ever published for LinkedIn is dead.
  Seen on the feed, My Network, search results, and profiles.
- The **classic Ember stack** is ordinary light DOM with the old readable class names such as
  `msg-form__contenteditable` and `li.msg-conversation-listitem`. Seen on messaging and on
  `/recent-activity/`.

So never assume. Run the probe first, then choose selectors based on what it reports:

```js
({
  url: location.href,
  newStack: !!document.querySelector('#interop-outlet'),
  scroller: !!document.querySelector('main#workspace'),
  h1s: document.querySelectorAll('h1').length,
})
```

`h1s` is usually `0` on profiles, which is why the common "find the heading, then scope to its
container" trick finds nothing. The real scroll container on the new stack is `main#workspace`
with its own overflow, so `window.scrollTo(0, 0)` is a no-op and in-page scrolling must target
that element.

Load `scripts/li-probe.js` into the page once per tab and you get `__deep(selector)`, which
queries through shadow roots, plus the targeting helpers used below. Everything in this file
assumes it is loaded.

## Step 2: the addressing ladder

Use the highest rung that the page offers, and never drop to a lower rung to save effort.

1. **`componentkey` with a member URN.** The strongest handle on the new stack, because it is
   machine-readable identity rather than text. A connect control carries
   `componentkey="ConnectButtonstate:invitation:urn:li:member:<MEMBER_ID>_connect"`, and the
   profile top card is `section[componentkey*="Topcard" i]`, which matches exactly one element.
2. **Person-scoped `aria-label`.** LinkedIn labels most person-level controls with the person's
   name, which is what makes safe targeting possible at all: `Invite <Full Name> to connect`,
   `Withdraw invitation sent to <Full Name>`, `Accept <Full Name>'s invitation`,
   `Ignore an invitation to connect from <Full Name>`, `Follow <Full Name>`,
   `Open control menu for post by <Full Name>`. Note that the possessive uses a curly
   apostrophe (U+2019), so match loosely on that character.
3. **`href` substrings.** `a[href*="/in/"]`, `a[href*="/company/"]`, `a[href*="/sales/lead/"]`.
   Reliable for harvesting, useless for buttons.
4. **Container scoping plus visible text.** For controls LinkedIn leaves unlabelled, such as
   Comment and Repost, anchor on a labelled sibling to find the post container and search only
   inside it. `scripts/li-probe.js` exposes `__postBox(name)` for exactly this.
5. **Raw coordinates.** Last resort only. Click coordinates are in *screenshot* pixel space, which
   is not the same as CSS pixel space when the screenshot is scaled, so a rectangle read from
   `getBoundingClientRect()` will be off by the scale factor and land on the wrong element. When
   you must click, prefer `el.focus()` or `el.click()` from JavaScript, both of which LinkedIn's
   handlers accept.

### The wrong-person hazard, which is the single most important thing in this file

On a profile page there are typically a dozen controls whose visible text is exactly `Connect`,
because the "More profiles for you" and "People you may know" rails each carry their own. Those
rails are inside an `<aside>` that is itself nested **inside `<main>`**, so scoping to `main` does
not exclude them, and DOM order puts rail cards near the top, so taking the first match selects a
stranger.

This is not hypothetical. Matching on button text scoped to `main` and taking `.first` sent
invitations to two strangers from the sidebar and they had to be withdrawn by hand. Reproduced
again during the verification for this skill: on one profile the only `aria-label` containing
"to connect" that a naive query returned belonged to a sidebar suggestion, not the profile owner.

The safe procedure, and the only one this skill endorses:

1. Read the intended person's name and member id from the top card.
2. Select by `componentkey` member id **and** require `aria-label === "Invite <Owner Name> to connect"`.
3. Reject any candidate with an `<aside>` or rail heading ancestor.
4. Filter to visible elements, because the same control appears two or three times including a
   hidden copy at zero width and a sticky-header copy.
5. If the checks disagree with each other, raise and stop. Never guess, and never fall back to
   "the first one".

Also note that `get_by_role("button", name="Connect")` returns zero matches, because the
`aria-label` overrides the accessible name, and that on the sent-invitations page the withdraw
control is an `<a>` rather than a `<button>`, which is why searching for a button whose text is
"Withdraw" fails.

## Reading LinkedIn

Reading is the high-value, low-risk half of this skill and it needs no approval.

**Entry points that work.** Profiles at `/in/<slug>/`, activity at `/in/<slug>/recent-activity/all/`,
companies at `/company/<slug>/about/` and `/people/`, people search at
`/search/results/people/?keywords=...`, company search at `/search/results/companies/?keywords=...`,
the inbox at `/messaging/`, invitations at `/mynetwork/invitation-manager/received/` and `/sent/`,
and Sales Navigator at `/sales/`.

Skip `/search/results/all/`. The combined tab leads with Jobs and Posts and frequently returns
neither the person nor the company you searched for, while the typed verticals work first try.

**Never guess a company slug.** A wrong slug silently redirects to `/company/unavailable/` while
navigation still reports success, and the tell is that text extraction then fails with "No text
content found". Resolve the real slug through company search first. One real case had the obvious
guess wrong and the actual slug carried an extra suffix.

**Judge success from the page, not from the navigate call.** Immediately after navigating, the tab
title is still the bare hostname and resolves only on a later call, so "Navigated to ..." tells you
nothing about whether the app rendered. Wait two to three seconds, then read, then judge from the
returned title and URL.

**Extraction order.** Whole-page text is the cheapest and usually enough for profiles, search
results, and inboxes. The accessibility tree is best when you need refs and labels, though it
reports many anonymous buttons on the new stack because the label and the role often sit on
different elements. In-page JavaScript is the most precise and is required for anything inside the
shadow root.

**Trim the boilerplate.** Every profile extraction carries roughly 1.2 KB of recommendation cards,
footer links, and a thirty-entry language selector. Cut at the "More profiles for you" or "Explore
Premium profiles" marker before reasoning over the text. If that boilerplate is the *entire*
payload, the page had not rendered and you should wait and re-read rather than conclude the profile
is empty.

**Harvesting long lists.** Activity feeds are virtualized, so items scrolled past are removed from
the DOM and a single query after scrolling to the bottom loses most of them. Collect on every
scroll step into a map keyed by a stable per-item string, which recovered 83 posts on a page where
the scroll-then-query approach found 63. Keep any single in-page script under about fifteen seconds
of wall time, because long scroll loops are the main thing that trips the extension into
disconnecting.

**Post permalinks.** The permalink anchor is usually absent from activity cards. Read the card's
`data-urn` instead, which yields `urn:li:activity:<id>`, and build
`https://www.linkedin.com/feed/update/urn:li:activity:<id>/`.

**Contact info.** Navigating straight to `/in/<slug>/overlay/contact-info/` never renders the
overlay and silently returns the base profile. Load the plain profile and click the control. Even
then the modal renders outside `<main>`, so whole-page text extraction misses it and you need a
screenshot or a direct shadow-piercing query.

**When the inbox search finds nothing**, check the Sales Navigator inbox at `/sales/inbox/` before
concluding there is no thread. Regular message search does not see InMail threads and will report
a clean miss for a person you are actively talking to.

## Writing to LinkedIn

Everything below is verified to work up to the final control. **Stop there and get the user's
explicit approval for that specific action before the last click.**

**Composing a post.** The composer does not open from `/feed/?shareActive=true` any more; that URL
now resolves to an unrelated modal. Click `div[aria-label="Start a post"]`, noting that the label
sits on an inner element while `role="button"` sits on its parent. The dialog is
`div[role="dialog"][aria-labelledby="share-to-linkedin-modal__header"]`. The editor is
`[data-test-ql-editor-contenteditable="true"]`, also reachable as
`[role="textbox"][aria-label="Text editor for creating content"]`.

The editor is Quill, and the instance hangs off the editor's **parent** as `__quill`. That is by
far the cleanest way to compose, because it keeps the internal model in sync and handles multi-line
text without simulating hundreds of keystrokes:

```js
const ed = __deep('[data-test-ql-editor-contenteditable="true"]')[0];
ed.parentElement.__quill.setText("line one\nline two\n");
```

Verified: real typing, raw DOM insertion, and the Quill API all land text and all flip the Post
button from disabled to enabled, and Quill's model stayed consistent with the DOM in each case.
Toolbar controls are labelled `Add media`, `Create an event`, `Celebrate an occasion`, `More`,
`Schedule post`, `Open Emoji Keyboard`, and `Dismiss`. To abandon a draft cleanly, set the text
back to `"\n"` first and then click `Dismiss`, otherwise LinkedIn keeps it as a saved draft.

**Sending a message.** The compose box is `[role="textbox"][aria-label="Write a message…"]` with a
real ellipsis character, inside `form.msg-form`, and the send control is
`button.msg-form__send-button`, which stays disabled until the box has content. This box is plain
contenteditable rather than Quill, so set the HTML and dispatch a bubbling `input` event.

**Enter sends the message in LinkedIn messaging.** Never press Return while focus is in that box.
Type by script, verify the text, then let the human click Send.

`/messaging/` always redirects to the currently open thread rather than staying on the inbox URL,
which is normal and not an error. Thread URLs have the shape `/messaging/thread/2-<base64ish>/`.
Clicking a conversation row by accessibility ref is a silent no-op that reports success while
nothing changes, so switch threads by navigating to the thread URL directly.

**Invitations.** Follow the wrong-person procedure above without shortcuts. Withdrawing is an `<a>`
with `aria-label="Withdraw invitation sent to <Full Name>"`. Withdrawing carries an official
three-week cooldown before you can invite that person again, so it is not a free undo.

**Reading state right after a click is unreliable.** In one batch, 23 of 77 rows still displayed
"Connect" after the invitation had genuinely been sent. Treat an ambiguous read as unknown rather
than as failure, and never re-send on it. Verify in a separate later pass.

## Rate limits and policy, stated honestly

This matters more than the selectors, because getting it wrong costs the user their account.

**What LinkedIn actually publishes.** The User Agreement effective 2025-11-03, still current in
August 2026, prohibits in section 8.2 the use of "software, devices, scripts, robots" to scrape or
copy the service, the use of "bots or other unauthorized automated methods" to send messages or to
create, comment on, like, or share posts, the circumvention of access controls or use limits, and
the copying or distribution of information obtained from the service without consent. A separate
Prohibited Software and Extensions page bans third-party tools that "scrape, modify the appearance
of, or automate activity on" LinkedIn, and states plainly that members using them risk having
their accounts restricted or shut down. `robots.txt` carries a blanket prohibition on automated
access without express permission.

Read that plainly: **automating activity on LinkedIn violates the User Agreement.** Pacing an agent
to stay under a detection threshold is not a defense, since section 8.2 separately prohibits
circumventing use limits. This skill therefore documents the read-and-draft posture, where the
agent gathers and prepares and a human performs the visible actions, and it deliberately does not
provide bulk sending machinery.

**Enforcement is real and increasingly instrumented.** LinkedIn's own restriction pages name three
invitation triggers: sending many invitations in a short time, having many invitations ignored or
marked as spam, and suspected automation. Profile views are explicitly capped daily by a
velocity-based check, and LinkedIn states it will neither reveal the threshold nor lift it on
request. A 2026 report found LinkedIn running a fingerprinting script that probes visitors for
thousands of specific Chrome extensions, and LinkedIn confirmed on the record that it looks for
extensions that scrape data and uses the result in enforcement decisions.

**Numbers that are official.** Maximum 30,000 first-degree connections. Free search returns at most
250 results across 25 pages, which corrects the "1,000 results" figure that most guides still
repeat. Sales Navigator caps at 2,500 lead results across 100 pages. Monthly InMail credits are 5
on Premium Career, 15 on Premium Business, 50 on Sales Navigator Core, and 30 on Recruiter Lite,
renewing on the billing-cycle date rather than the first of the calendar month, with credits
refunded when an InMail is accepted, declined, or answered within 90 days. The commercial use limit
resets at midnight PST on the first of each month and is lifted by Premium Business, Sales
Navigator, and Recruiter Lite, but not by Premium Career.

**Numbers that are folklore.** The widely quoted ~100 invitations per week, ~20 to 25 per day,
500 pending invitation cap, and 30% acceptance-rate throttle have no primary source. They come
almost entirely from automation vendors marketing their own products, which is the dominant kind of
result for these queries. Treat them as rough community priors, say so when you cite them, and
never present them as LinkedIn's published policy.

## Failure catalogue

The full list with exact error strings is in `references/failure-modes.md`. The ones worth knowing
before you start:

- **The extension disconnects mid-run**, which is the single most frequent hard block. Retry once;
  if the connected-browser list comes back empty, switch to the AppleScript surface.
- **A batched call gets refused by the harness classifier** while the identical actions pass as
  individual calls. Outward-facing clicks may be refused outright, which is correct and should be
  reported to the user rather than worked around.
- **Text extraction fails with a null `innerText` TypeError** on a bot-check interstitial titled
  "Just a moment...". Wait a few seconds and re-read. Never attempt the challenge.
- **Profile URLs without `www`** cause aborted navigations and timeouts. Normalize to
  `https://www.linkedin.com/in/<slug>/` before navigating.
- **The accessibility tree comes back empty** on some pages while text extraction works fine, and
  ref-based search inherits that blindness. Fall back to in-page JavaScript.

## What was verified, and when

Everything marked verified here was exercised live against a real logged-in account on
**2026-08-17**: the anonymous `HTTP 999` authwall, the `#interop-outlet` shadow root, hashed class
names, the mixed stacks across routes, the `componentkey` member URN, the single-match `Topcard`
section, zero `h1` elements, the `main#workspace` scroller, the composer path from trigger to an
enabled Post button including the Quill instance, the messaging box and its send-button gating,
the person-scoped labels across My Network and both invitation tabs, people search extraction, and
Sales Navigator reachability. Draft state was cleared afterwards and nothing was published or sent.

Two paths are documented but **not** live-verified end to end, and are marked as such because the
final click was deliberately not performed: publishing a post, and sending an invitation. The
invitation click was additionally refused by the harness safety classifier during verification,
which is the correct outcome.

LinkedIn ships changes constantly. When something here stops matching, re-run the Step 1 probe and
trust the page over this file, then update this file. A selector that was true in August 2026 is
evidence about the past, not a fact about today.

