---
name: linkedin-extension
description: Payload contract and DOM-selector rules for the LinkedIn one-click ingest Chrome extension. Carries no extraction prompts — classification stays in `document-ingest` and stays generic. Reference for extension authors and for tests pinning the wire shape between the extension and `platform/ui/server/routes/admin/linkedin-ingest.ts`.
---

# LinkedIn Extension — payload & selectors

This skill carries two contracts. Neither is enforced by an LLM; both are enforced by the route schema and the extractor unit tests.

## 1. Wire payload

Every POST from the extension to `/api/admin/linkedin-ingest` carries one JSON envelope:

```ts
type Payload = {
  source: 'linkedin'              // literal — gates the route's source enum
  kind: 'profile' | 'thread'      // dispatches to the matching extractor branch
  capturedAt: string              // ISO 8601 timestamp from the extension clock
  pageUrl: string                 // canonical https://www.linkedin.com/... URL
  dispatchId: string              // RFC-4122 UUID minted on extension side
  profile?: ProfilePayload        // present iff kind === 'profile'
  thread?: ThreadPayload          // present iff kind === 'thread'
}

type ProfilePayload = {
  name: string                    // headline-card name
  headline: string                // tagline under the name
  location: string                // city / country line
  currentTitle: string | null     // top experience row, title side
  currentCompany: string | null   // top experience row, company side
  bodyText: string                // full visible text of the main column
}

type ThreadPayload = {
  participants: Array<{
    name: string                  // displayed name (deduped, in chronological order of first appearance)
    profileUrl: string | null     // /in/<slug>/ when present in the DOM
  }>
  bodyText: string                // chronological transcript: "Name [time]: text" per message
}
```

The route rejects with 400 if any required field is missing, the `source` is not `linkedin`, or `kind` is not in the enum. `pageUrl` must be an `https://www.linkedin.com/...` URL — otherwise 400.

`dispatchId` is the single cross-cut key. The route logs it, `database-operator` echoes it in its initial-message header, and every `[linkedin-ingest-write]` log line carries it.

## 2. DOM selectors

Selectors are versioned with the test fixtures (`extension/__tests__/fixtures/{profile,thread}.html`, captured 2026-05-22). Each field has a primary selector and one fallback. If both miss for a required field, the extractor returns a structured error listing the missing fields — never a partial payload.

### Profile (`linkedin.com/in/<slug>/`)

| Field | Primary | Fallback |
|---|---|---|
| `name` | `h1.text-heading-xlarge` | `section.top-card-layout h1` |
| `headline` | `[data-test-headline]` | `.text-body-medium.break-words` |
| `location` | `[data-test-location]` | `.text-body-small.inline.t-black--light:first-of-type` |
| `currentTitle` | `[data-test-current-position] .position-title` | `section#experience li:first-child .t-bold > span:first-child` |
| `currentCompany` | `[data-test-current-position] .position-company` | `section#experience li:first-child .t-normal > span:first-child` |
| `bodyText` | `main` | `body` |

Required: `name`, `headline`, `bodyText`. `location`, `currentTitle`, `currentCompany` are optional — `null` for missing.

### Thread (`linkedin.com/messaging/thread/<id>/`)

| Field | Primary | Fallback |
|---|---|---|
| Thread root | `.msg-thread` | `[data-test-thread-root]` |
| Message item | `.msg-s-message-list__event` (within root) | `[data-test-message-item]` |
| Sender name | `.msg-s-event-listitem__name` | `[data-test-sender-name]` |
| Sender profile URL | `a.msg-s-event-listitem__link[href*="/in/"]` | `[data-test-sender-link]` |
| Message timestamp | `time.msg-s-event-listitem__timestamp[datetime]` | `[data-test-message-time][datetime]` |
| Message body | `.msg-s-event-listitem__body` | `[data-test-message-body]` |

Required: at least one message with a sender and a body. Empty threads → structured error.

## Adding a new selector

When LinkedIn ships a DOM change that breaks the extractor:

1. Capture a fresh fixture by saving the live page HTML into `extension/__tests__/fixtures/<kind>.html` (overwrite).
2. Update the relevant primary selector. Keep the prior selector as fallback if it might still hit on lagging clients.
3. Re-run `vitest extension`. The fixture-driven tests pin the wire shape — they will fail loudly if the change drops a required field.

The skill version date in this file's preamble is the capture date for the fixtures. Bump it when the fixtures change.

## What this skill is NOT for

- Classification prompts — those live in `platform/plugins/memory/skills/document-ingest/` and stay generic across every future communication surface.
- Graph-write Cypher — that lives in `platform/plugins/memory/mcp/`.
- Selectors for any other LinkedIn surface (search, company pages, posts, Sales Navigator). Those are separate future tasks.
