# Reference: Connections.csv

Creates a `:Person` for every LinkedIn connection, a `:Organization` for every company named on those rows, `[:WORKS_FOR {title}]` edges from connections to their current employer, and `[:CONNECTED_ON_LINKEDIN {connectedOn}]` edges from the archive owner to every connection.

Every edge in this reference is expressed by the source CSV: connections are explicitly labelled as "connected to the archive owner", and each connection row explicitly names a current position. Nothing synthetic.

## Source

`Connections.csv`.

**Parser note — skip the header preamble.** The file begins with three lines LinkedIn injects:

```
Notes:
"When exporting your connection data, …"
<blank line>
First Name,Last Name,URL,Email Address,Company,Position,Connected On
```

The real column header is **line 4**. Either skip the first three lines before parsing, or parse with no header and align columns from row 4 onward.

## Columns → properties and edges

| CSV column | Maps to |
|------------|---------|
| First Name | `Person.givenName` |
| Last Name | `Person.familyName` |
| URL | `Person.linkedinUrl` (natural key) |
| Email Address | `Person.email` (only when non-empty) |
| Company | `Organization.name` |
| Position | `[:WORKS_FOR].title` AND `Person.currentTitle` (denormalised) |
| Connected On | `[:CONNECTED_ON_LINKEDIN].connectedOn` (ISO 8601) |

The `Position` column is written **twice**: once on the `[:WORKS_FOR]` edge as the source of truth (preserves the per-employer history when a connection later moves), and again as `Person.currentTitle` so Neo4j's `entity_search` BM25 index reaches it. Edge properties are invisible to a node-only fulltext index, so without the denormalisation an operator searching "director" against 5K connections returns near-zero hits regardless of how many directors are in the graph. The SET is unconditional — re-importing a row with an empty Position clears `currentTitle`, matching the LinkedIn export's "current snapshot" semantics. To filter your import to a specific role before writing, use the [Selective-ingest threshold](#selective-ingest-threshold) gate.

LinkedIn only emits email for connections who opted in, so most rows have a blank email. The MCP tool writes `email` only when non-empty — avoids colliding with `person_email_unique` on empty strings.

## Natural keys

| Entity | Key | Why |
|--------|-----|-----|
| `:Person` | `linkedinUrl` (indexed in `platform/neo4j/schema.cypher`) | Stable across exports; display-name collisions are frequent |
| `:Organization` | `(accountId, name)` | Matches existing `memory-write` convention. Schema-base.md requires `accountId` + `name` on every Organization |

## Anchor

Resolved at skill start via the owner-confirmation flow. The owner is either an `:AdminUser` (the operator's own archive — the common case) or a confirmed `:Person` (an external archive ingested for reference). Both flow through the same write tool — `memory-archive-write` matches by `elementId(owner)` and accepts either label set.

## Selective-ingest threshold

**100 rows.** When the parsed `rows[]` count exceeds this, the skill pauses before the write call and asks the operator to filter by Company, Position, or Connected On range. See [SKILL.md § Selective-ingest threshold](../SKILL.md#selective-ingest-threshold-bulk-archives) for the doctrine. The MCP tool accepts whatever rows are passed; this gate is conversational.

## Write surface

This reference invokes a single MCP tool: `mcp__plugin_memory_memory__memory-archive-write` with `archiveType: "linkedin-connections"`. The Cypher body — Person upsert by `linkedinUrl`, `CONNECTED_ON_LINKEDIN` edge from owner, optional `:Organization` + `WORKS_FOR` when company is non-empty — lives server-side in [`platform/plugins/memory/mcp/src/tools/memory-archive-write.ts`](../../../../memory/mcp/src/tools/memory-archive-write.ts). The agent does not author or pipe Cypher; it parses CSV rows into the tool's row schema and dispatches one (or more, for filtered re-imports) tool call.

### Tool input shape

```json
{
  "archiveType": "linkedin-connections",
  "ownerNodeId": "<elementId of :AdminUser or :Person — from owner-confirmation flow>",
  "rows": [
    {
      "givenName": "Dee",
      "familyName": "Odus",
      "linkedinUrl": "https://www.linkedin.com/in/deeodus",
      "email": null,
      "company": "Female Founders Fund",
      "title": "Partner",
      "connectedOn": "2026-04-23"
    }
  ],
  "sessionId": "<UUID generated once per skill run>"
}
```

The parser converts:
- Blank cells → `null` (especially `email`, `company`, `title`).
- `Connected On` from `"23 Apr 2026"` → ISO 8601 `"2026-04-23"`. The tool rejects rows with non-ISO dates loudly — never let the agent paper over a parser bug.

### What the server does (informational, not the agent's responsibility)

Per 500-row batch the handler runs one transaction with two phases:

1. Upsert each row's `:Person` (natural key `linkedinUrl`), stamp provenance + email when present, then upsert the owner→Person `CONNECTED_ON_LINKEDIN` edge with `connectedOn` on the edge.
2. For rows whose `company` is non-empty, upsert the `:Organization` (natural key `accountId + name`), then upsert the Person→Organization `WORKS_FOR` edge with `title` on the edge.

Provenance stamped on every node: `source='linkedin'`, `createdByAgent='linkedin-import'`, `createdBySource='linkedin-import'`, `createdBySession=<sessionId>`, `createdAt=<now>`.

Counters come from the Neo4j driver's per-statement summary (`nodesCreated`, `relationshipsCreated`). The tool returns `{processedRows, createdPersons, mergedPersons, createdOrganizations, createdEdges, errors[]}`.

## Edge semantics — why these and no others

- **`(owner)-[:CONNECTED_ON_LINKEDIN]->(:Person)`** — each row of Connections.csv is a declaration that the archive owner and this person are LinkedIn connections. That's the edge.
- **`(:Person)-[:WORKS_FOR]->(:Organization)`** — each row's Company/Position columns state where the connection currently works. That's the edge. Schema-base calls this `WORKS_FOR`.

Rows missing a company produce a Person with a `CONNECTED_ON_LINKEDIN` edge and no WORKS_FOR — because the row didn't express a WORKS_FOR relationship. No placeholder Organization gets minted.

Rows missing a position but present with a company produce a `WORKS_FOR` edge with null `title` — the connection works there, we just don't know their role.

## Date parsing

`Connected On` arrives as `"23 Apr 2026"`. Convert to ISO 8601 (`2026-04-23`) in the parser before passing to the tool. The server converts to Neo4j `date()` internally — the agent never invokes Cypher functions.

## Expected shape

- ~3,000–10,000 rows typical for a long-running account. The selective-ingest gate (above) keeps a typical write at well under 1,000 rows.
- 500 rows per transaction. The MCP tool handles batching internally; the agent passes the full filtered `rows[]` in one call.

## Post-import verification (operator-side, not agent-side)


```cypher
// Owner → connections count
MATCH (owner:AdminUser {userId: $ownerUserId})-[:CONNECTED_ON_LINKEDIN]->(p:Person)
RETURN count(p) AS connections;

// LinkedIn-origin organizations count
MATCH (o:Organization {accountId: $accountId, source: 'linkedin'})
RETURN count(o) AS organizations;
```

These are **read queries**, not writes. Cypher writes from the agent are forbidden.

## Failure modes

| Symptom | Cause | Fix |
|---------|-------|-----|
| Every row parsed as "Notes:,NaN,…" | Header preamble not skipped | Skip first 3 lines before the CSV parser |
| Tool error "row connectedOn is not ISO 8601" | Parser left `Connected On` in `"23 Apr 2026"` form | Convert to `YYYY-MM-DD` before passing to the tool |
| Tool error "ownerNodeId not found" | Owner-confirmation flow not run, or operator typed the wrong id | Re-run owner confirmation; pass the resulting `elementId` as `ownerNodeId` |
| Tool error `ownerNodeId <id> belongs to account <X>, not <Y>. Cross-account archive ingest refused.` | The confirmed owner lives in a different account than the calling session — typically the operator confirmed a Person/AdminUser from a different brand, or `accountId` was passed wrong | Re-run owner confirmation inside the correct account, or pass the calling session's `accountId` |
| `WORKS_FOR` count « connection count | Many rows have blank company | Expected — LinkedIn doesn't force connections to list a current employer |
| Tool not present in `init` frame | `specialists:librarian` spawned without the `mcp__plugin_memory_memory__memory-archive-write` token | Loud-fail per librarian's prerogatives. Do not improvise via Bash. Operator must remediate (re-seed specialist templates) |
