> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mirage.strukto.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Notion

> Mount Notion pages as a virtual filesystem.

The Notion VFS exposes a Notion workspace as a virtual filesystem
mounted at a prefix such as `/notion/`.

For API key setup, see [Notion Setup](/home/setup/notion).

## Config

```python theme={null}
import os

from mirage import MountMode, Workspace
from mirage.vfs.notion import NotionConfig, NotionVFS

config = NotionConfig(api_key=os.environ["NOTION_API_KEY"])
vfs = NotionVFS(config=config)
ws = Workspace({"/notion": vfs}, mode=MountMode.WRITE)
```

| Field | Required | Default | Description |
| - | - | - | - |
| `api_key` | yes | | Notion integration token |
| `base_url` | no | `https://api.notion.com/v1` | API base URL |
| `api_version` | no | the client's generation | Pins the `Notion-Version` header |

## Filesystem Layout

```text theme={null}
/notion/
  pages/
    <page-title>__<page-id>/
      page.json
      <child-page-title>__<child-id>/
        page.json
        ...
  databases/
    <database-title>__<database-id>/
      database.json
      <data-source-name>__<data-source-id>/
        data_source.json
        rows.jsonl
```

Example:

```text theme={null}
/notion/
  pages/
    Project_Roadmap__a1b2c3d4/
      page.json
      Q1_Goals__e5f6g7h8/
        page.json
      Q2_Goals__i9j0k1l2/
        page.json
    Meeting_Notes__m3n4o5p6/
      page.json
  databases/
    Tasks__4a3b21915e77/
      database.json
      Tasks__d5000000-2222-3333-4444-555566667777/
        data_source.json
        rows.jsonl
```

The `pages/` hierarchy mirrors Notion's standalone page tree. Each page
directory contains a `page.json` with the page metadata and content, and
child pages appear as nested directories.

The `databases/` hierarchy is one level deeper than the page tree,
because the `2025-09-03` API generation split a database into a
container plus one or more **data sources**. The column schema and the
rows both live on the data source: `data_source.json` and `rows.jsonl`.
The name stutters for a single-source database because Notion names the
auto-created data source after its database; that disappears the moment
a database holds two.

A data source lists its rows as one file, not as a directory each. A
query answers a hundred rows' cells in one call, while every row
directory costs calls of its own to enter, so a `grep -r` or `find` over
a large database would cost calls per row. A row is still a page with a
`page.json`, at `<data-source-dir>/<row-title>__<page-id>/page.json`: it
is not listed, and its path is on the row's line in `rows.jsonl`.

### database.json

The container's identity. It carries `database_id`, `title`, `url`,
timestamps, `parent`, `archived`, `is_inline`, and the `data_sources`
stubs that name the directories beneath it. It **does not carry
`properties`**: at this API version the column schema lives on the data
source, and `GET /v1/databases/{id}` no longer answers with one.

```json theme={null}
{
  "database_id": "eeee1111-2222-3333-4444-555566667777",
  "title": "Tasks",
  "url": "https://www.notion.so/eeee1111222233334444555566667777",
  "created_time": "2026-01-01T00:00:00.000Z",
  "last_edited_time": "2026-01-02T00:00:00.000Z",
  "parent": { "type": "workspace", "workspace": true },
  "archived": false,
  "is_inline": false,
  "data_sources": [
    { "id": "d5000000-2222-3333-4444-555566667777", "name": "Tasks" }
  ]
}
```

### data\_source.json

The typed column schema (Notion's own property objects), with no rows
inline:

```json theme={null}
{
  "data_source_id": "d5000000-2222-3333-4444-555566667777",
  "database_id": "eeee1111-2222-3333-4444-555566667777",
  "title": "Tasks",
  "created_time": "2026-01-01T00:00:00.000Z",
  "last_edited_time": "2026-01-02T00:00:00.000Z",
  "database_parent": { "type": "workspace", "workspace": true },
  "archived": false,
  "properties": {
    "Name": { "id": "title", "type": "title", "title": {} },
    "Priority": { "id": "pri", "type": "number", "number": { "format": "number" } },
    "Due": { "id": "du", "type": "date", "date": {} }
  }
}
```

A data source id is **not** its database id. Turn one into the other
with `ntn datasources resolve <database-id>`.

### rows.jsonl

Every row of the data source, one JSON object per line, in the order the
query returns them. A line is the row's `page.json` without the body,
which a query does not carry, plus `path`, where that `page.json` is
below the data source directory:

```json theme={null}
{"page_id":"ffff1111-2222-3333-4444-555566667777","title":"Write spec","path":"Write_spec__ffff1111-2222-3333-4444-555566667777/page.json","url":"https://www.notion.so/ffff1111222233334444555566667777","created_time":"2026-01-01T00:00:00.000Z","last_edited_time":"2026-01-02T00:00:00.000Z","parent_type":"data_source_id","parent_id":"d5000000-2222-3333-4444-555566667777","archived":false,"created_by":"faa6ebe0-0686-466d-96b3-2480ddda715b","last_edited_by":"faa6ebe0-0686-466d-96b3-2480ddda715b","properties":{"Name":{"id":"title","type":"title","title":[{"type":"text","plain_text":"Write spec"}]},"Priority":{"id":"pri","type":"number","number":2},"Done":{"id":"dn","type":"checkbox","checkbox":true}}}
```

Reading it costs one query call per hundred rows, however many rows
match.

### page.json

A page, including a database row. Notion blocks render to `markdown` and
stay available raw under `blocks`; a row's cell values are under
`properties`, as Notion's own property objects, answering to the schema
in the `data_source.json` one level up:

```json theme={null}
{
  "page_id": "ffff1111-2222-3333-4444-555566667777",
  "title": "Write spec",
  "url": "https://www.notion.so/ffff1111222233334444555566667777",
  "created_time": "2026-01-01T00:00:00.000Z",
  "last_edited_time": "2026-01-02T00:00:00.000Z",
  "parent_type": "data_source_id",
  "parent_id": "d5000000-2222-3333-4444-555566667777",
  "archived": false,
  "created_by": "faa6ebe0-0686-466d-96b3-2480ddda715b",
  "last_edited_by": "faa6ebe0-0686-466d-96b3-2480ddda715b",
  "properties": {
    "Name": {
      "id": "title",
      "type": "title",
      "title": [{ "type": "text", "plain_text": "Write spec" }]
    },
    "Priority": { "id": "pri", "type": "number", "number": 2 },
    "Done": { "id": "dn", "type": "checkbox", "checkbox": true }
  },
  "markdown": "",
  "blocks": []
}
```

So a table's cells are readable with the ordinary tools, and a row's
body by the path its line carries:

```bash theme={null}
jq -r 'select(.properties.Priority.number > 1) | .title' "$DS/rows.jsonl"
grep -c '"checkbox":true' "$DS/rows.jsonl"
cat "$DS/$(jq -r 'select(.title == "Write spec") | .path' "$DS/rows.jsonl")"
```

Blocks with children embed them recursively under a `children` key, and
the Markdown renders nested blocks with indentation. A standalone page's
`properties` holds only its title, which is what the API returns for
one.

## Cache

Uses `IndexCacheStore` for page metadata. No separate content
cache - file content caching is handled by the workspace `IOResult`
mechanism.

## Example

```python theme={null}
import asyncio
import os

from dotenv import load_dotenv

from mirage import MountMode, Workspace
from mirage.vfs.notion import NotionConfig, NotionVFS

load_dotenv(".env.development")

config = NotionConfig(api_key=os.environ["NOTION_API_KEY"])
vfs = NotionVFS(config=config)


async def main():
    ws = Workspace({"/notion": vfs}, mode=MountMode.WRITE)

    # List top-level pages
    r = await ws.shell("ls /notion/pages/")
    print(await r.stdout_str())

    # List shared databases, then their data sources and row files
    r = await ws.shell("ls /notion/databases/")
    print(await r.stdout_str())
    r = await ws.shell("tree -L 3 /notion/databases/")
    print(await r.stdout_str())

    # Read a page
    r = await ws.shell(
        'cat "/notion/pages/Project_Roadmap__a1b2c3d4/page.json"'
    )
    print(await r.stdout_str())

    # Read every row's cells
    r = await ws.shell(
        'jq -r ".title" '
        '"/notion/databases/Tasks__4a3b21915e77'
        '/Tasks__d5000000-2222-3333-4444-555566667777/rows.jsonl"'
    )
    print(await r.stdout_str())

    # Search across all pages
    r = await ws.shell('grep "deadline" /notion/pages/')
    print(await r.stdout_str())

    # Tree view
    r = await ws.shell("tree -L 2 /notion/")
    print(await r.stdout_str())

    # Search pages with the Notion search API
    r = await ws.shell('ntn api v1/search -d \'{"query":"Roadmap"}\'')
    print(await r.stdout_str())

    # Create a new page from Markdown
    r = await ws.shell(
        "ntn pages create --content '# New Page' --parent page:a1b2c3d4"
    )
    print(await r.stdout_str())

    # Replace an existing page's body
    r = await ws.shell(
        "ntn pages edit a1b2c3d4 --content '# Replaced body'")
    print(await r.stdout_str())


if __name__ == "__main__":
    asyncio.run(main())
```

## Shell Commands

Standard commands available on the mounted Notion tree:

| Command | Notes |
| - | - |
| `ls` | List pages and child pages |
| `cat` | Read page.json content |
| `head` / `tail` | First/last N lines |
| `grep` / `rg` | Search across pages |
| `jq` | Query page JSON fields |
| `wc` | Line/word/byte counts |
| `stat` | File metadata |
| `find` | Recursive search with `-name`, `-maxdepth` |
| `tree` | Directory tree view |

Acting on Notion (creating, editing and trashing pages, querying data
sources, and every route that has no typed verb) goes through the
[ntn CLI](/python/cli/ntn) when installed. Ids are positional: use the
`<page-id>` / `<database-id>` / `<data-source-id>` from a path segment as
the operand.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.