# Notion simulator

A stateful Notion API simulator — pages, blocks, databases with their data sources, and search, for agents that work in a workspace.

`notion-twin` is a local, stateful simulator of the Notion API at `Notion-Version: 2026-03-11` — pages, blocks, databases with their data sources, search and the connection's own bot user. It never calls Notion, and no test page lands in a real workspace.

## Quickstart

You need an API key from [Account → API keys](https://www.rystic.ai/account/api-keys) ([how](https://www.rystic.ai/docs/licensing.md#get-an-api-key)).

```bash
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…                                               # your API key
rystic run notion-twin -d --port 0 --scenario notion_team_wiki   # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version
```

Expect `"product":"notion-twin"`. `--scenario notion_team_wiki` boots the same workspace on every plan. The examples below use `localhost:8080`; use the port `rystic run` printed.

On Enterprise, `rystic login ./rystic-license.json` takes the place of the key ([license files](https://www.rystic.ai/docs/licensing.md#enterprise-license-files)).

Add a page under the wiki's root, then write a paragraph into it:

```bash
curl -s localhost:8080/v1/pages -H 'Authorization: Bearer secret_placeholder' \
  -H 'Notion-Version: 2026-03-11' -H 'Content-Type: application/json' \
  -d '{"parent":{"page_id":"a1000000-0000-4000-8000-000000000101"},"properties":{"title":{"title":[{"text":{"content":"Launch checklist"}}]}}}'
# -> {...,"id":"73d01b77-cb6c-4662-8000-000000000001",...,"object":"page",...}

curl -s -X PATCH localhost:8080/v1/blocks/73d01b77-cb6c-4662-8000-000000000001/children \
  -H 'Authorization: Bearer secret_placeholder' -H 'Notion-Version: 2026-03-11' -H 'Content-Type: application/json' \
  -d '{"children":[{"object":"block","type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Ship it"}}]}}]}'
# -> {...,"object":"list","results":[{...,"type":"paragraph",...}],...}
```

## The version pin

Point the SDK at the twin, and set the version: the twin speaks `2026-03-11` only, and the official clients default to `2025-09-03`.

```ts
// @notionhq/client
const notion = new Client({ auth: "secret_placeholder", baseUrl: "http://localhost:8080", notionVersion: "2026-03-11" });
```

```python
# notion-client
notion = Client(auth="secret_placeholder", base_url="http://localhost:8080", notion_version="2026-03-11")
```

1. A request with no `Notion-Version` header answers 400 `missing_version`.
2. `2025-09-03` or any other value answers 400 `missing_version` on every route, as Notion answers a version it does not serve.

## Plans

What `notion-twin` does on each plan ([plans](https://www.rystic.ai/docs/licensing.md#plans)):

| | Free | Pro | Enterprise |
|---|---|---|---|
| Credential | API key | API key | license file |
| Boots | `notion_team_wiki` | an empty workspace, or the `--scenario` you name | same as Pro |
| `POST /_rystic/reset` | re-applies `notion_team_wiki` | wipes to empty | wipes to empty |
| Notion API (all 16 routes) | yes | yes | yes |
| Control plane | `version`, `routes`, `reset` only | every route | every route |

**Free** runs 5 hours of simulator time a month. It serves the whole Notion surface against `notion_team_wiki`. Every control route beyond `GET /_rystic/version`, `GET /_rystic/routes` and `POST /_rystic/reset` answers 403, naming the feature it needs (the loopback-only `GET /_rystic/activity` aside). Free also refuses `--scenario` naming any other world, `--seed` and `RYSTIC_NETWORK`; see [boot inputs](https://www.rystic.ai/docs/control-plane.md#boot-inputs).

**Pro** opens every control route. Without `--scenario` the twin boots an **empty** workspace, and `POST /_rystic/reset` wipes it back to empty.

**Enterprise** is Pro on a license file instead of an API key.

## Auth

Notion reads an internal connection's token as `Authorization: Bearer <token>`. The twin accepts any non-empty bearer token and answers as the world's bot user; no Notion workspace or token is involved.

1. A request with no bearer token answers 401 `unauthorized`.
2. An empty workspace has no bot user, so every request answers 401: boot a scenario or seed a `state_user` bot first.

## Coverage

16 routes. `GET /_rystic/routes` returns the same list from a running twin.

| Resource | Routes |
|---|---|
| Pages (3) | `POST /v1/pages`; `GET`, `PATCH /v1/pages/{page_id}` |
| Blocks (5) | `GET`, `PATCH`, `DELETE /v1/blocks/{block_id}`; `GET`, `PATCH /v1/blocks/{block_id}/children` |
| Databases (3) | `POST /v1/databases`; `GET`, `PATCH /v1/databases/{database_id}` |
| Data sources (3) | `GET`, `PATCH /v1/data_sources/{data_source_id}`; `POST /v1/data_sources/{data_source_id}/query` |
| Search (1) | `POST /v1/search` |
| Users (1) | `GET /v1/users/me` |

Not modelled: comments, file uploads, views, page markdown, page moves, page property items, data source creation and templates, the user and member lists, and the deprecated `POST /v1/databases/{database_id}/query`. An unserved route answers 404 `{"message":"No route in twin for …"}`, not Notion's error envelope.

## The Notion MCP server

`@notionhq/notion-mcp-server` reaches the twin through its `BASE_URL` environment variable. Pass the version with the token, since the server otherwise sends `2025-09-03` on most tools:

```json
{
  "mcpServers": {
    "notion": {
      "command": "npx",
      "args": ["-y", "@notionhq/notion-mcp-server"],
      "env": {
        "BASE_URL": "http://localhost:8080",
        "OPENAPI_MCP_HEADERS": "{\"Authorization\":\"Bearer secret_placeholder\",\"Notion-Version\":\"2026-03-11\"}"
      }
    }
  }
}
```

Of the server's 24 tools, 14 reach a served route. These 10 answer the twin's 404: `API-get-user`, `API-get-users`, `API-retrieve-a-page-property`, `API-retrieve-a-comment`, `API-create-a-comment`, `API-create-a-data-source`, `API-list-data-source-templates`, `API-move-page`, `API-retrieve-page-markdown` and `API-update-page-markdown`.

## Control plane

Every route under `/_rystic/` is listed, with the plan that reaches it, in [routes by plan](https://www.rystic.ai/docs/control-plane.md#routes-by-plan). Notion declares no outbound effects, so `GET /_rystic/egress` is always empty, and it has no WebSocket feed.

## Scenarios

Five shipped worlds, each on `2026-02-01T00:00:00Z`; Free boots `notion_team_wiki`.

| Scenario | What it holds |
|---|---|
| `notion_team_wiki` | a workspace root page with three child pages, a nested block tree and an inline Meeting Notes database with one row |
| `notion_task_tracker` | a Sprint Tasks database with title, select, date, people and checkbox properties and six rows across every status |
| `notion_blank_slate` | one bot, one page holding an empty paragraph and an empty database with only a title property |
| `notion_trash_bin` | a Q3 Planning page in the trash with its subpage, blocks and database, beside a live restore log |
| `notion_locked_vault` | pages, a database and a row carrying `is_locked`, with one unlocked scratch page |

## Seeding state

Pro and Enterprise. `POST /_rystic/seed` takes a register map — `state_page`, `state_block`, `state_database`, `state_data_source`, `state_user` — and lands it directly; `GET /_rystic/registers` lists every field. `--seed <file>` or `RYSTIC_SEED=<file>` boots one.

## Determinism & reset

Ids are a sequence (`73d01b77-cb6c-4662-8000-000000000001`, then `…0002`) and the clock starts at a pinned instant — `2026-01-01T00:00:00Z` empty, `2026-02-01T00:00:00Z` in every scenario — and moves one millisecond per request that stamps a time, never by wall time. Two identical runs from the same reset give byte-identical pages and blocks.

## Limitations

1. The version pin above: send `2026-03-11`; any other version answers 400.
2. Unserved routes answer a plain 404, not Notion's `object_not_found` envelope.
3. A data source query sorts by `created_time` or `last_edited_time` only (a property sort is ignored), has no `page_size` cap, and misses a page created with a `database_id` parent: give rows a `data_source_id` parent.
4. A page's properties are not resolved against its data source's schema.
5. No rate limits: never a 429.
6. Ids and `request_id` values are the twin's own sequences, not Notion's random ids, and page `url`s point at `www.notion.so` but serve nothing.

Fidelity is scored differentially against a live Notion workspace through an internal connection, per response field: the last full-suite round matched **79 of 79 probes on 2026-10-07**. Each release states its own number.

The full guide ships in the release as `HOWTO.md`; `rystic docs notion-twin` prints it.

## Release notes

Newest first.

### v0.0.1 — 2026-10-07

1. **A local Notion API for your integration and the agents that drive it.** The first release of the Notion twin: 16 routes of the Notion API at `Notion-Version: 2026-03-11` — pages, blocks, databases with their data sources, search and the connection's own bot user — served from a model validated against a live Notion workspace. Point `@notionhq/client`, `notion-client` or `@notionhq/notion-mcp-server` at it with any bearer token and that version (`HOWTO.md` shows all three); no page lands in a real workspace. (RYS-1947)

2. **Pages and block trees, written and read back.** `POST /v1/pages` creates a page under a page or a data source, with children if you send them; `GET` and `PATCH /v1/pages/{page_id}` read and update it; `GET`, `PATCH` and `DELETE /v1/blocks/{block_id}` and `GET` and `PATCH /v1/blocks/{block_id}/children` read, edit and append nested blocks. `DELETE` trashes rather than deletes, as on Notion, and `PATCH` with `"in_trash": false` restores. A missing id answers Notion's 404 `object_not_found`. (RYS-1597, RYS-1598, RYS-1884)

3. **Databases, data sources and search.** `POST /v1/databases` creates a database with its first data source; `GET` and `PATCH` read and update a database or a data source's schema; `POST /v1/data_sources/{data_source_id}/query` returns its rows with property and timestamp filters applied and `page_size` paging; `POST /v1/search` finds pages and data sources by title. A property sort is ignored in this release. (RYS-1595, RYS-1596, RYS-1886, RYS-1892)

4. **One API version, enforced on every route.** The twin speaks `Notion-Version: 2026-03-11`: a request with no `Notion-Version` or any other version answers 400 `missing_version`, on every route, as Notion does. The official clients default to `2025-09-03`, so set the version in the client. (RYS-1914)

5. **Five shipped workspaces.** `--scenario notion_team_wiki` boots a wiki with a root page, three child pages, a nested block tree and a Meeting Notes database, and is the world Free boots and `POST /_rystic/reset` returns to. `notion_task_tracker` holds a Sprint Tasks database with six rows across every status, `notion_blank_slate` one page and one empty database, `notion_trash_bin` a trashed page tree beside a live restore log, and `notion_locked_vault` locked pages beside an unlocked one; `SCENARIOS.md` says what each is for. On Pro and Enterprise, `POST /_rystic/seed` lands pages, blocks, databases, data sources and users directly, and `POST /_rystic/reset` rewinds the id sequence and the clock, so two identical runs give byte-identical pages and blocks. (RYS-1947)

6. **Measured against a live Notion workspace.** The 2026-10-07 full-suite round matched 79 of 79 probes against a live Notion workspace, with 0 inconclusive. `HOWTO.md` states the differences this release ships with, each with its ticket: unserved routes answer a plain 404, not Notion's envelope (RYS-1913); a data source query ignores property sorts, has no `page_size` cap and misses a page created with a `database_id` parent (RYS-1596); and a page's properties are not resolved against its data source's schema (RYS-1885). (RYS-1947)

---

Source: https://www.rystic.ai/docs/simulators/notion · Markdown: https://www.rystic.ai/docs/simulators/notion.md
