---
name: notion-import
description: Import a Notion workspace export (markdown + CSV) into a {{productName}} Neo4j graph. Triggers when the user asks to import a Notion workspace, ingest a Notion export, or mentions a `Export-*.zip` / unzipped Notion export directory. Pages become `:KnowledgeDocument` nodes, database rows become typed entities (or `:KnowledgeDocument` if no schema mapping fits), database schemas become `:KnowledgeDocument` siblings, hierarchy is preserved via `:HAS_SECTION`, attachments are copied locally and written as `:DigitalDocument`.
---

# Notion Import

**Invoked from `specialists:archive-ingest-operator`** when the operator drops a Notion workspace export directory (or `Export-*.zip` path) and asks to import it. The admin agent dispatches the archive-ingest-operator; the archive-ingest-operator loads this skill.

**Anchor model.** A Notion export does not have a single subject the way a LinkedIn archive does — pages have multiple authors, databases are shared workspaces, mentions span the whole org. The skill anchors **import provenance** on the importing `:AdminUser` via `IMPORTED_BY` (so every node in the import can be traced back to who pushed the button); page authorship is captured separately via `AUTHORED` when the export records an author. The `:AdminUser` is identity, not subject — pages are not "owned" by the importer.

Every node this skill writes carries an `importId` property (UUID per skill run) so a future dream-cycle pass can detect orphans or selectively re-ingest.

## Import-provenance confirmation (mandatory first step)

A Notion export is dropped by one operator on behalf of one workspace. Before any file is read, the skill confirms two things:

1. **Importing AdminUser.** The `:AdminUser` who pushed the button. Default is the live operator (the session's userId); confirm by echoing `userId=<id> name=<name>` and accepting yes/no. If the operator is importing on behalf of someone else — a partner, a client whose workspace they're staging — accept either an existing AdminUser userId or a new `:Person` natural-key (`givenName` + `familyName` + at least one of `email` / `telephone`) as the importing identity. Persist as `$importerNodeId`.
2. **Workspace label.** A short human label (`"Joel's personal workspace"`, `"Rubytech ops"`) used as the value of `:NotionWorkspace.name`. One `:NotionWorkspace` node is MERGEd per `(accountId, name)` natural key; re-imports of the same workspace match it.

The skill refuses to read the export directory until both values are echo-confirmed.

## Anchor shape

```
(:AdminUser {userId: $importerUserId})
  -[:IMPORTED { importId, source: 'notion', startedAt }]->
    (:NotionWorkspace { accountId, name })
      -[:HAS_SECTION]-> (:KnowledgeDocument { kind: 'notion-page', notionId, importId })
      -[:HAS_SECTION]-> (:KnowledgeDocument { kind: 'notion-database', notionId, importId })
```

`:NotionWorkspace` is the only new label the skill needs; everything below it reuses {{productName}} canonical labels (`:KnowledgeDocument`, `:Person`, `:Organization`, `:DigitalDocument`). Page hierarchy from Notion's parent-child relation is written as `:HAS_SECTION` between `:KnowledgeDocument` pages.

## Invariants

1. **Schema first.** No new constraints or indexes are required; the canonical schema in [`platform/plugins/memory/references/schema-base.md`](../../../../plugins/memory/references/schema-base.md) covers every label this skill writes. If the operator's workspace surfaces a database shape no canonical label fits, the rows are written as `:KnowledgeDocument` with `kind: 'notion-row'` and the gap is reported to admin — the skill does not mint new labels at runtime.
2. **Provenance confirmed first.** No file is read until `$importerNodeId` (AdminUser or external Person) and `$workspaceName` are persisted and echo-confirmed.
3. **Closed edge vocabulary.** Edges written are drawn from `schema-base.md` only. Unmapped Notion relations (a custom DB-to-DB link the operator named `sponsored_by`, say) land as `:MENTIONS` from the source page's `:KnowledgeDocument`, with the Notion relation label stored as a property (`mentionContext: '<relation-name>'`). `RELATES_TO` is **not** a schema edge and is not used.
4. **Account isolation on fuzzy resolves.** Every inline `@person` mention resolves against `:Person {accountId: $accountId}` — never label-only. A "John Smith" mention in the importing account never matches a `:Person` in another account, even if the names are identical. When no in-account match is found, the mention is written as a new `:Person` with `givenName` / `familyName` parsed from the mention text and `source='notion'`.
5. **Idempotent MERGE on Notion IDs.** Every Notion page and row carries a stable Notion UUID. Pages MERGE on `(accountId, notionId)`; database rows MERGE on `(accountId, notionId)`; attachments MERGE on `(accountId, sha256)`. Re-import of the same export is a property-update no-op on existing nodes — `skipped` counter advances, no duplicates.
6. **Provenance stamps.** Every new node: `createdByAgent='notion-import'`, `createdBySource='notion'`, `createdBySession=$sessionId` (UUID per skill run), `createdAt=datetime()`, `importId=$importId`, plus `source='notion'`.
7. **Bulk writes route through `memory-archive-write`; per-node `memory-write` is only used for the workspace anchor.** The walk produces three buffered payloads (pages, database rows per label, deferred relations), each submitted as one `mcp__plugin_memory_memory__memory-archive-write` call with the matching `archiveType`. The server batches 500 rows per transaction, returns aggregate counters, and gives the import per-chunk atomicity. `mcp__plugin_memory_memory__memory-search` is still used for inline-mention and author resolution (per-mention lookup happens during the walk, before any write). No raw Cypher, no Bash improvisation. If a write shape the skill needs is missing from the archive-write surface, the gap is filed as a follow-up task — never improvised.

## Execution model

1. Run the provenance-confirmation flow. Persist `$importerNodeId`, `$workspaceName`, `$importId` (fresh UUID), `$sessionId`.
2. MERGE the `:NotionWorkspace { accountId, name }` and the `(:AdminUser)-[:IMPORTED]->(:NotionWorkspace)` edge via `memory-write`. Persist the resulting `:NotionWorkspace` `elementId` as `$workspaceNodeId` — every subsequent archive-write call passes it as `ownerNodeId`.
3. Walk the export directory per [workspace-export.md](references/workspace-export.md) — classify each entry as page (`.md`), database schema (`<db-name>.csv` paired with row CSVs), database rows, attachment subdirectory, or nested page subdirectory. The walk **buffers** parsed output into three lists: `pageBuffer`, `dbRowBufferByLabel` (keyed by canonical label + databaseNotionId), and `deferredRelations`. No writes are issued during the walk except inline-mention `memory-search` lookups.
4. For each shape, follow the matching reference to fill the buffers:
   - Pages → [page-tree.md](references/page-tree.md). Each parsed page becomes one `NotionPageRow` in `pageBuffer`. Top-level pages set `parentNotionId: null`; nested pages keep the enclosing page's `notionId`. Inline `@person` mentions resolved per Invariant 4 — each match appends a `MENTIONS` entry to `deferredRelations`. Page-to-page `HAS_SECTION` (every non-workspace parent) is appended to `deferredRelations` with `edgeType: 'HAS_SECTION'`, regardless of in-batch ordering — the server resolves them once every node is MERGEd in pass-2.
   - Databases → [databases.md](references/databases.md). The database itself is MERGEd via `memory-write` as `:KnowledgeDocument {kind: 'notion-database', notionId}` before any rows are queued (the row handler verifies the parent exists). Each row joins `dbRowBufferByLabel[label][databaseNotionId]`. Notion's typed relation columns become entries in `deferredRelations` (canonical edge type when the schema maps, `MENTIONS` with `mentionContext` otherwise).
   - Attachments → [attachments.md](references/attachments.md). Copied to `{accountDir}/archive/notion/<importId>/`, content-hashed, written as `:DigitalDocument {kind: 'notion-attachment', sha256}` via `memory-write` (one per attachment — attachments are not bulk-batched because the file copy itself is the long pole). Each attachment appends a `MENTIONS` or canonical `HAS_ATTACHMENT` entry to `deferredRelations` when wired into the typed-edge schema.
5. **Flush phase — three batched archive-write calls.** After the walk completes:
   1. `memory-archive-write archiveType='notion-pages' ownerNodeId=$workspaceNodeId importId=$importId rows=pageBuffer` — server MERGEs every page and attaches workspace-root `HAS_SECTION` for `parentNotionId: null` rows. Optional `AUTHORED` edges write when `authorPersonNodeId` is set.
   2. For each `(label, databaseNotionId)` key: `memory-archive-write archiveType='notion-database-rows' ownerNodeId=$workspaceNodeId importId=$importId databaseNotionId=<…> label=<…> rows=<bufferedRows>`.
   3. `memory-archive-write archiveType='notion-relations-pass-2' ownerNodeId=$workspaceNodeId rows=deferredRelations` — server buckets relations by `edgeType` from the closed allowlist (`MENTIONS`, `WORKS_FOR`, `OWNS`, `ASSIGNED_TO`, `PART_OF`, `ATTENDED`, `HAS_SECTION`, `AUTHORED`). Unknown edge types and missing endpoints are counted, not thrown.
6. Emit `[notion-import] importId=<id> pages=<n> rows=<n> relations=<n> mentions=<n> attachments=<n> skipped=<n> ms=<elapsed>` at completion, aggregating the counters returned by each archive-write call. Per-file errors emit `[notion-import:error] file=<path> reason=<…>` during the walk; the skill continues past parse failures so a single bad file does not abort the entire import. Per-batch transaction failures surface in the archive-write `errors[]` array — log each as `[notion-import:batch-error] archiveType=<…> offset=<…> reason=<…>` and continue with subsequent batches; the same `importId` re-run is idempotent and replays only the chunks that did not commit.

## Selective-ingest gate (bulk imports)

A Notion workspace typically contains 200–5,000 pages. Writing all of them in one shot defeats compression-on-write — most pages will never be queried, and the noise compounds with every subsequent import.

**Threshold:** when the export walk discovers more than **200 pages or 500 database rows**, pause and ask the operator to filter before any write begins. Natural filter axes for Notion:

- **Top-level page subtree** — "only pages under `Engineering/`", "only `Personal/Journal/`".
- **Database name** — "only the `People` and `Projects` DBs", "skip the `Daily Standup` DB".
- **Last-edited date** — "only pages edited since 2024-01-01".

The operator picks one axis or a combination. The skill applies the filter to the walk's candidate set before issuing any write. Re-importing later with a wider filter hits the same `(accountId, notionId)` natural keys; existing nodes are property-updated, only the new-only delta is created.

## File-shape roster

The Notion export is a directory of:

| Shape | Detection | Reference |
|-------|-----------|-----------|
| Page (markdown) | `*.md` file at any depth, with a Notion UUID suffix in the filename | [page-tree.md](references/page-tree.md) |
| Database schema | `<name>.csv` with a sibling directory of the same name containing per-row `.md` files | [databases.md](references/databases.md) |
| Database row | `<row-name>.md` inside a database subdirectory, with the parent CSV's column set as front-matter properties | [databases.md](references/databases.md) |
| Attachment | Any file under an `<image>/`, `<pdf>/`, or other media-bearing subdirectory adjacent to its referencing page | [attachments.md](references/attachments.md) |
| Nested workspace subtree | A directory whose name carries a Notion UUID and contains both `.md` pages and CSV databases | [workspace-export.md](references/workspace-export.md) |

## Explicitly out of scope

- **Notion API live sync.** This is one-shot from a manual export only. Live API sync (incremental delta, webhook listening, two-way write-back) is a separate task if commissioned.
- **Formulas, rollups, synced blocks.** The export materialises computed values as text; the skill imports the materialised text but not the formula source. Re-computing rollups against the graph is downstream work.
- **Notion Web Clipper saved pages.** Clipper pages live in a different export shape (HTML, not markdown) and have different semantics (third-party source ingest). They land in a future task, not here.
- **Back-sync to Notion.** Writing changes from the graph back into the Notion workspace is not in scope.
- **Streaming progress events from the archive-write server.** Counters are returned only at completion of each archive-write call; per-row progress is a separate observability task if commissioned.

## Schema-mapping table

The skill maps Notion shapes to canonical {{productName}} labels and edges. Schema additions are last resort — every row below uses an existing label from `schema-base.md`.

| Notion shape | Canonical label | Notes |
|--------------|-----------------|-------|
| Workspace | `:NotionWorkspace` | New label, single property `name` plus `accountId`. Only new label introduced by this skill. |
| Page | `:KnowledgeDocument { kind: 'notion-page' }` | Body markdown stored as `text`. Hierarchy via `:HAS_SECTION`. |
| Page authored-by | `(:Person)-[:AUTHORED]->(:KnowledgeDocument)` | When the export records an author, resolve to in-account `:Person`. |
| Database (the table itself) | `:KnowledgeDocument { kind: 'notion-database' }` | Schema preserved as `schema` (JSON-stringified property list). |
| Database row, canonical-fit | `:Person`, `:Organization`, `:Project`, `:Task`, `:Event` | Database name + columns drive label selection. A "People" DB with `email`/`telephone` columns → `:Person`. |
| Database row, no fit | `:KnowledgeDocument { kind: 'notion-row' }` | Properties preserved on the node; surface to admin in the completion log if any DB lands here entirely. |
| Notion relation, schema-mapped | Canonical edge from `schema-base.md` | A relation column named `works_at` between People and Companies → `(:Person)-[:WORKS_FOR]->(:Organization)`. |
| Notion relation, unmapped | `:MENTIONS { mentionContext: '<relation-name>' }` | Source page `:KnowledgeDocument` → target. Preserves the Notion relation name for later audit. |
| Inline `@person` mention | `(:KnowledgeDocument)-[:MENTIONS]->(:Person)` | Account-filtered fuzzy resolve per Invariant 4. |
| Attachment | `:DigitalDocument { kind: 'notion-attachment', sha256 }` | Files copied to `{accountDir}/archive/notion/<importId>/`. |
| Page hierarchy | `(:KnowledgeDocument)-[:HAS_SECTION]->(:KnowledgeDocument)` | Parent-of-child per Notion's nesting. |

If the operator's workspace introduces a database that fits none of the canonical labels and is not satisfied by the `:KnowledgeDocument` fallback, surface the gap to admin in the completion summary — never mint a new label at runtime.

## Natural-edge discipline (why this matters)

Every edge written corresponds to a relationship the Notion export actually expresses. `:HAS_SECTION` is real — Notion explicitly records page parents. `:AUTHORED` is real when the export records it. `:WORKS_FOR` is real when a People DB has a Companies-relation column. Synthetic "attach-everything-to-the-importer" edges are forbidden — the importer is provenance (via `IMPORTED`), not the subject of every page. A workspace with thousands of unrelated pages does not get thousands of edges manufactured to "make it reachable"; the workspace-level `IMPORTED` edge already makes them reachable through the importer.
