---
name: linkedin-import
description: Import a LinkedIn Basic Data Export into a {{productName}} Neo4j graph. Triggers when the user asks to import a LinkedIn archive, ingest LinkedIn connections/messages/positions into the graph, or mentions a `Basic_LinkedInDataExport_*.zip` folder. One reference per CSV under `references/`; edges come from what each CSV naturally encodes, never from synthetic "anchor" patterns.
---

# LinkedIn Import

**Invoked from `specialists:librarian`** when the operator drops a LinkedIn Basic Data Export (`Basic_LinkedInDataExport_*`) directory or references one by path. The admin agent dispatches the librarian; the librarian loads this skill.

**Default parent.** Every `:Person`, `:Organization`, `:Position`, `:Skill`, and `:Message` node this skill writes is anchored to the archive-owner `:Person` (the user whose LinkedIn account produced the export). That Person is itself anchored to the account's `:LocalBusiness`. The archive owner's elementId is the first thing this skill confirms — without it, every row would land as an orphan and the importer refuses to start. Cross-hierarchy edges (`CONNECTED_ON_LINKEDIN`, `WORKS_FOR`, `HAS_SKILL`) describe the relationships; the containment edge from the archive owner is what makes the rows reachable.

Ingests a LinkedIn Basic Data Export (unzipped directory of CSVs + subdirs) into a {{productName}} Neo4j graph. Every edge written corresponds to a real relationship expressed by the data — not a convenience anchor.

## Archive-owner confirmation (mandatory first step)

A LinkedIn archive belongs to exactly one person — the user whose LinkedIn account produced the export. That person is the subject of every row in the archive: every `CONNECTED_ON_LINKEDIN` edge points from them to a contact, every `WORKS_FOR` edge on `Positions.csv` belongs to them, every `HAS_SKILL` edge on `Skills.csv` is theirs.

**Who the archive belongs to is not inferred from who runs the skill.** Joel may be ingesting his own archive today, a business partner's archive next week, or both side-by-side. The skill takes the archive owner as an explicit input and refuses to proceed until it is confirmed.

The confirmation flow:

1. List every `:AdminUser` in the graph and display each as `{userId, name, createdAt, accountId(s) via ADMIN_OF}`.
2. Ask the operator to name the archive owner. Accept one of:
   - An existing AdminUser userId from the list (typical — Joel's own archive, or a partner who is already a {{productName}} operator).
   - A new external Person identity (not-yet-an-AdminUser) — create a fresh `:Person` with `givenName`, `familyName`, `email`/`telephone` (at least one) and use its `elementId` as the owner anchor. This path covers ingesting a non-operator's archive for reference.
3. Echo the chosen owner back verbatim (`userId=<id> name=<name>`) and require explicit yes/no confirmation before any CSV is touched.
4. Persist the confirmed owner as `$ownerUserId` (for AdminUser-type owners) or `$ownerPersonId` (for external Person owners) — this parameter flows into every reference.

Refusing to assume is load-bearing: a hard-coded "it's whoever's running this" would misattribute a partner's connections to the operator on first re-use.

## Anchor shape

When the owner is an AdminUser (the common case):

```
(:AdminUser {userId: $ownerUserId}) -[:HAS_PROFILE]-> (:UserProfile {accountId, userId})
```

Both nodes already exist for any {{productName}} operator — the HAS_PROFILE edge is MERGEd by [`platform/ui/app/lib/neo4j-store.ts`](../../../../ui/app/lib/neo4j-store.ts) at session start. The LinkedIn import updates these existing nodes; it does not create parallel operator identities.

- **Identity + account-scoped behaviour** → `:AdminUser` and `:UserProfile` (existing, unchanged).
- **LinkedIn profile enrichment** (headline, summary, websites, industry, geoLocation) → property updates on `:UserProfile`.
- **LinkedIn activity edges** (`CONNECTED_ON_LINKEDIN`, `WORKS_FOR`, `ATTENDED`, `HAS_SKILL`, `HOLDS_CREDENTIAL`, `FOLLOWS`, `AUTHORED`, `PARTICIPANT_IN`) → subjects are the `:AdminUser` (device-level facts: "I know this person") or `:UserProfile` (account-scoped facts: "in this brand I am skilled at X"). Each reference states which and why.

When the owner is an external Person (non-operator archive), the anchor is the confirmed `:Person` node instead — same edges, different anchor.

## Invariants

1. **Schema first.** The LinkedIn additions (`person_linkedin_url` index, `:Credential` constraint) live in [`platform/neo4j/schema.cypher`](../../../../neo4j/schema.cypher) and are applied by `platform/scripts/seed-neo4j.sh` on every install / upgrade. The skill assumes the schema has been seeded; it does not bootstrap schema itself. If a constraint or index is missing, the operator re-runs `seed-neo4j.sh` from the installer — schema-bootstrap is installer-side, never agent-side.
2. **Owner confirmed first.** No reference runs until `$ownerUserId` (or `$ownerPersonId`) is persisted and echo-confirmed. The reference set is parameterised — no hard-coded owner.
3. **Natural edges only.** Every edge written is one the CSV actually expresses. `Connections.csv` encodes "I am connected on LinkedIn to this person" — that becomes `CONNECTED_ON_LINKEDIN`. No synthetic attach-to-owner pattern bolted onto rows that don't describe a relationship to the owner.
4. **Reuse {{productName}} labels.** Schema-extension is last resort. The LinkedIn set maps onto existing labels wherever semantics align:
   - LinkedIn companies → `:Organization` (existing, memory-write registered).
   - LinkedIn schools → `:Organization` with `organizationCategory: 'educational'`.
   - LinkedIn skills → `:DefinedTerm` with `category: 'linkedin-skill'`.
   - LinkedIn articles → `:CreativeWork` with `category: 'linkedin-article'`.
   - LinkedIn recommendations → `:Review`.
   - LinkedIn DMs → routed to the `conversation-archive` skill (Task 397) as a `--source linkedin-messages` invocation. Each DM thread becomes one `:ConversationArchive` parent with `:Section` chunks; no bespoke LinkedIn sublabels.
   - Certifications → `:Credential` (genuinely new — no existing {{productName}} label fits `schema:EducationalOccupationalCredential`).
5. **Schema-base property names.** `givenName` / `familyName` / `telephone` / `dateSent` — not `firstName` / `phone` / `sentAt`. See [`platform/plugins/memory/references/schema-base.md`](../../../../plugins/memory/references/schema-base.md).
6. **Schema-base edge names.** `(:Person)-[:WORKS_FOR]->(:Organization)` for positions. Custom LinkedIn edges (`CONNECTED_ON_LINKEDIN`, `ENDORSED`) only where schema-base has no canonical name.
7. **Provenance stamp.** Every new node: `createdByAgent='linkedin-import'`, `createdBySource='linkedin-import'`, `createdBySession=$sessionId` (UUID per skill run), `createdAt=datetime()`, plus `source='linkedin'` as the data-origin marker.
8. **Idempotent MERGE.** Re-running any reference after a fresh export updates properties without duplicating nodes.

## Execution model

1. Run the owner-confirmation flow, persist `$ownerUserId` / `$ownerPersonId`. The owner identity resolves to a single `ownerNodeId` (elementId of the AdminUser or external Person) used in every write call.
2. For each file the operator approves, load its reference, parse the CSV into typed `rows[]` matching the reference's row schema.
3. **Selective-ingest gate.** Before invoking any write tool, check the parsed row count against the reference's `selectiveIngestThreshold`. If the count exceeds the threshold, pause and ask the operator to filter the import along the natural axes named in the reference (for `Connections.csv`: Company, Position, Connected On). Apply the filter to `rows[]` before continuing. Compress on write, never after — a 5,000-row blanket import is a landfill, a 200-row filtered import is signal. See [§Selective-ingest](#selective-ingest-threshold-bulk-archives).
4. Invoke the deterministic write tool the reference names. For all archive references this is `mcp__plugin_memory_memory__memory-archive-write` with `{archiveType, ownerNodeId, rows}` — the Cypher body is fixed server-side per `archiveType`, so the agent supplies parsed rows, never Cypher. The tool batches rows at 500 per transaction internally.
5. After each file emit `[linkedin-import] file=<name> rows=<n> created=<n> matched=<n> ms=<elapsed>` using the counters returned by the write tool.

**Doctrine:** raw Cypher and `cypher-shell` invocations are forbidden in this skill and its references. Writes route through `mcp__plugin_memory_memory__memory-archive-write` (bulk archives) or `mcp__plugin_memory_memory__memory-write` / `mcp__plugin_memory_memory__memory-update` (single-node enrichments like `profile.md`). If a CSV needs a write shape no current MCP tool supports, file a task to extend `memory-archive-write` with a new `archiveType` handler — never improvise via Bash. See [librarian's LOUD-FAIL prerogative](../../../../templates/specialists/agents/librarian.md#prerogatives).

**LOUD-FAIL on parse errors (structurally enforced).** When LinkedIn parser tools land that follow the `mcp__*__*-import-parse` naming convention, the harness-level `platform/plugins/admin/hooks/archive-ingest-surface-gate.sh` will record an `isError: true` response and block every subsequent tool call this turn until the operator submits the next prompt. The hook also denies edits to `platform/plugins/*/lib/*` and JavaScript test runners (`vitest`, `bun test`, `npm test`, `npx jest`) unconditionally. The skill's "no Bash improvisation" doctrine above is the contract; the hook is the enforcement. See [.docs/hooks.md](../../../../../.docs/hooks.md) for the full gate surface.

## Selective-ingest threshold (bulk archives)

A LinkedIn export typically contains 3,000–10,000 connections. Writing all of them in one shot defeats compression-on-write — most rows will never be queried, and the noise compounds with every subsequent ingest. The skill compresses by interrogating the operator before bulk writes.

**Threshold:** when a parsed reference's `rows[]` exceeds **100 rows**, pause and ask the operator to filter along the reference's natural axes before invoking the write tool.

For `Connections.csv` the natural filter axes are:

- **Company** — "only people at LargeCorp", "only Female Founders Fund alumni"
- **Position** — "only Partners", "only Engineering Managers"
- **Connected On** (date range) — "only my last two years", "since 2024-01-01"

The operator picks one axis or a combination. The agent applies the filter to `rows[]` and writes only the filtered subset.

**Re-importing is idempotent.** Coming back later with a wider filter (`"add anyone at LargeCorp"`, `"include 2022 too"`) hits the same `linkedinUrl` natural key — existing `:Person` nodes are matched and updated; only the new-only delta is created. The operator can grow the slice over time without dedup work.

**Why the threshold lives in the skill, not the server.** Different archive types have different "interesting" thresholds — 100 LinkedIn connections is a lot; 100 LinkedIn skills is small. The MCP tool accepts whatever rows are passed; the conversational gate is the skill's responsibility.

## File roster

What's shipped today:

| File | Reference | Edges produced |
|------|-----------|----------------|
| `Profile.csv` | [profile.md](references/profile.md) | enrichment on `:UserProfile` (no new nodes) |
| `Connections.csv` | [connections.md](references/connections.md) | `(:AdminUser)-[:CONNECTED_ON_LINKEDIN {connectedOn}]->(:Person)` + `(:Person)-[:WORKS_FOR {title}]->(:Organization)` |
| `messages.csv` (and the three other DM CSVs) | conversation-archive (Task 397) | dispatched to `conversation-archive` skill with `--source linkedin-messages`; one `:ConversationArchive` per DM thread + `:Section` chunks + `(:Person)-[:PARTICIPANT_IN]->(archive)` |

Additional CSVs in the export (Positions, Education, Skills, Endorsements, Recommendations, Company Follows, Articles, Jobs, etc.) land as new references when the operator asks for them — each is its own follow-up.

## Explicitly out of scope

- `Ad_Targeting.csv` — LinkedIn-inferred ad segments, no graph value.
- `Rich_Media.csv` — time-limited signed LinkedIn CDN URLs; files 404 after expiry.
- `Private_identity_asset.csv` — duplicates `Profile.csv` summary text.
- `Profile Summary.csv` — empty in this export.
- `Job Applicant Saved Screening Question Responses.csv` — ephemeral Q&A, no stable identity.

Add a new reference if any of these later become useful — nothing silently flows in.

## Natural-edge discipline (why this matters)

The rule isn't "every node must have an edge". The rule is "every node must have the edges its source data actually describes, and no synthetic ones bolted on to satisfy a check." A LinkedIn archive satisfies the orphan doctrine trivially: every row expresses a relationship between the archive owner and someone or something else. If a future file turns out to describe entities with no relationship to the owner (an endorsement between two third parties, say), the reference for that file writes the entities with their natural edges and doesn't fabricate a connection to the owner just because the owner supplied the file.
