---
name: notion-integration
description: >-
  How Notion sync works. Covers connecting, linking pages, pulling from Notion,
  pushing to Notion, and checking sync status.
---

# Notion Integration

The content app can sync documents bidirectionally with Notion. Documents can be linked to Notion pages, pulled from Notion, or pushed to Notion.

## Scripts

### connect-notion-status

Check the Notion connection status.

```bash
pnpm action connect-notion-status
```

Returns whether a Notion integration is connected and which workspace it belongs to.

### link-notion-page

Link a local document to a Notion page for syncing.

```bash
pnpm action link-notion-page --documentId abc123 --notionPageId notion-page-id
```

### list-notion-links

List all documents that are linked to Notion pages.

```bash
pnpm action list-notion-links
```

### pull-notion-page

Pull content from a linked Notion page into the local document.

```bash
pnpm action pull-notion-page --documentId abc123
```

This overwrites the local document's content with the Notion page's content, converted to markdown.

### push-notion-page

Push local document content to the linked Notion page.

```bash
pnpm action push-notion-page --documentId abc123
```

This overwrites the Notion page's content with the local document's markdown, converted to Notion blocks.

## How Sync Works (Architecture)

Documents are stored as **Notion-Flavored Markdown (NFM)** — the exact format
Notion's `/pages/{id}/markdown` API emits and accepts. The storage form is
Notion's _canonical_ form, so a synced document is byte-identical on both sides.

- `shared/nfm.ts` is the single deterministic converter: `nfmToDoc` (NFM →
  ProseMirror JSON) and `docToNfm` (ProseMirror JSON → NFM), plus
  `canonicalizeNfm = docToNfm ∘ nfmToDoc`. It is used by **both** the editor
  (`setContent(nfmToDoc(x))` / `docToNfm(editor.getJSON())`) and the server
  (pull canonicalization + content hashing).
- The converter is a proven **fixpoint**: `docToNfm(nfmToDoc(x)) === x` for all
  canonical NFM `x`, verified by `shared/nfm.spec.ts` (pure) and
  `app/components/editor/nfm-editor.roundtrip.test.ts` (real TipTap schema).
  Because our canonical form equals Notion's emission, pull→edit→push→pull
  never drifts.
- Pulls also materialize accessible Notion child pages referenced by `<page>`
  atoms. Each child becomes a local `documents` row with `parent_id` set to the
  pulled parent and a `document_sync_links` row pointing at the child Notion
  page, so the sidebar tree and page blocks can open the same local subpage.
  Inaccessible child pages remain preserved as NFM page references.
- **Do not** route Notion content through `shared/notion-markdown.ts` (the old
  tiptap-markdown bridge). It is retained only for clipboard copy/paste.

Supported losslessly: paragraphs, headings (incl. toggle headings via
`{toggle="true"}`), bulleted/numbered/to-do lists with tab nesting, real quote
blocks (multi-line via `<br>`), block colors (`{color="…"}`), inline
bold/italic/strike/code/underline/color/background and links, inline + block
equations, code blocks, dividers, `<empty-block/>`, callouts, toggles, columns,
tables (header row/column, cell/row colors), images/audio/video/file/pdf, page
and database references, synced blocks (children preserved), mentions, and
backslash-escaped special characters. Visual indentation is a block `indent`
attribute (Tab indents a block, matching Notion).

## Sync State

The `document_sync_links` table tracks sync relationships:

| Column                     | Description                                                                                                                      |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `document_id`              | Local document ID                                                                                                                |
| `provider`                 | Always "notion"                                                                                                                  |
| `remote_page_id`           | Notion page ID                                                                                                                   |
| `state`                    | "linked", "syncing", "error", "conflict"                                                                                         |
| `last_synced_at`           | Timestamp of last successful sync                                                                                                |
| `last_synced_content_hash` | SHA-256 of the canonical content identical on both sides — the authoritative "did it change" signal (immune to timestamp jitter) |
| `has_conflict`             | Whether both sides changed since last sync (0 or 1)                                                                              |
| `last_error`               | Error message if sync failed                                                                                                     |

Conflict detection is **content-hash based**: a side has "changed" only when its
canonical content hash differs from `last_synced_content_hash`. A no-op sync
(identical canonical content) is never mistaken for an edit — this is what keeps
the two copies from drifting.

## Common Tasks

| User says                      | What to do                                             |
| ------------------------------ | ------------------------------------------------------ |
| "Is Notion connected?"         | `connect-notion-status`                                |
| "Link this doc to Notion"      | `link-notion-page --documentId ... --notionPageId ...` |
| "Pull from Notion"             | `pull-notion-page --documentId ...`                    |
| "Push to Notion"               | `push-notion-page --documentId ...`                    |
| "Show Notion-linked documents" | `list-notion-links`                                    |

## Important Notes

- Notion access is **per-user OAuth only**. Never read `NOTION_API_KEY` from the
  environment or accept a user-pasted token; require editor access for pull/push.
- Pull replaces local content with Notion's; push replaces Notion's with local.
  When both sides changed since the last sync the link enters `conflict` state and
  the user resolves it (pull-wins or push-wins) — there is no line-level merge.
- Because storage is canonical NFM, a no-op sync changes nothing: editing the
  same document in Notion and in the app will not create growing inconsistencies.
- Always check `connect-notion-status` before attempting sync operations.
