# Notion

> Read and write Notion pages, databases (data sources), and blocks via the Notion REST API.

- Skill: `atomicbot-ai/notion` (Agent Skill)
- Install (CLI): `npx skillmds@latest add atomicbot-ai/notion`
- Raw SKILL.md: https://api.skillmd.com/api/skills/atomicbot-ai/notion/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs, AI & ML
- Author: AtomicBot-ai (https://skillmd.com/u/atomicbot-ai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/atomicbot-ai/notion

---


# notion

Talk to the Notion API (`api.notion.com`) directly over HTTP. No SDK required.

## Calling convention (critical)

`os.http.request` is **approval-gated** in atomic-agent. Every call MUST be emitted as a **solo step** — a length-1 array, never combined with another tool in the same step:

DO — solo step, GET shape (use for `/v1/pages/{id}`, `/v1/blocks/{id}/children`, `/v1/users`):

```
[
  {
    "tool": "os.http.request",
    "args": {
      "method": "GET",
      "url": "https://api.notion.com/v1/pages/{page_id}",
      "headers": { "Authorization": "Bearer <key>", "Notion-Version": "2025-09-03" }
    }
  }
]
```

DO — solo step, POST shape (use for `/v1/search`, `/v1/pages` create, `/v1/data_sources/{id}/query`):

```
[
  {
    "tool": "os.http.request",
    "args": {
      "method": "POST",
      "url": "https://api.notion.com/v1/search",
      "headers": {
        "Authorization": "Bearer <key>",
        "Notion-Version": "2025-09-03",
        "Content-Type": "application/json"
      },
      "body": { "query": "page title" }
    }
  }
]
```

DON'T — these are rejected by the runtime with `GrammarError: approval-gated tool 'os.http.request' is forbidden inside a batch`:

```
[ { "tool": "os.http.request", ... }, { "tool": "os.http.request", ... } ]   // two HTTP calls in one step
[ { "tool": "reply", ... },           { "tool": "os.http.request", ... } ]   // reply + HTTP in one step
[ { "tool": "os.http.request", ... }, { "tool": "os.shell.run",   ... } ]   // any other approval-gated peer
```

To fetch N pages, emit **N consecutive solo steps**, each as `[{...}]`. Wait for each `tool_result` before issuing the next call. Do not try to parallelise reads of the Notion API.

`args.body` for POST must be a **JSON object** (the runtime serialises it). Do **not** wrap it in quotes and escape the braces — passing `body: "{\"key\":\"val\"}"` works but is fragile and is the #1 cause of `400 invalid_json` from Notion.

## Setup check (lazy — read the key once, don't re-probe)

Read `NOTION_API_KEY` from the environment **once** when you first need it
this conversation (`printenv NOTION_API_KEY`), then reuse the value in
`Authorization: Bearer …` headers for the rest of the session. Do not
re-probe before every HTTP call. Map failures:

- the key is empty / unset (printenv exits non-zero or prints nothing) → **Setup playbook → "NOTION_API_KEY is not set"**.
- any HTTP call returns `404 object_not_found` → **Setup playbook → "Page not shared with the integration"**.

## Setup playbook (when prerequisites are missing)

When a check fails, the agent's job is to OFFER concrete help and EXECUTE the fix itself — not to dump setup instructions on the user. Use this dialogue shape:

1. State plainly what is missing (one short reply).
2. Offer the most direct remediation the agent can perform via tools.
3. Wait for the user's reply (yes/no, or pasted secret).
4. Execute the fix via tools (the runtime approval gate will surface writes for confirmation).
5. Retry the original request; only then proceed.

### NOTION_API_KEY is not set

The agent CAN write the key into `~/.atomic-agent/.env` itself once the user provides it. Offer two paths in a single reply:

> "You don't have `NOTION_API_KEY` set. Two options:
> (a) If you already have a key — send it here, I'll append it to `~/.atomic-agent/.env` and ask you to restart the agent.
> (b) If you don't have a key — I'll explain how to create an integration in Notion (it takes ~1 minute), and at the end you'll send me the key and I'll append it myself.
> Which do you choose?"

#### Path (a) — user pastes the key

When the user replies with a string starting with `ntn_` or `secret_`, validate the shape (length ≥ 40, no whitespace) and append to the env file:

```
[{ "tool": "os.fs.write", "args": {
   "path": "~/.atomic-agent/.env",
   "mode": "append",
   "content": "\nNOTION_API_KEY=<pasted-key>\n"
} }]
```

Then reply: "The key is saved to `~/.atomic-agent/.env`. Restart the agent (Ctrl+C and start again) so the env variable is picked up — I'll continue from the same place after the restart." Do NOT echo the key back verbatim in subsequent replies; treat it as a secret from this point.

#### Path (b) — guided walkthrough

The agent CANNOT create Notion integrations itself (no Notion API for that — it requires browser-based OAuth-flow). Open the page for the user and walk them through the irreducible manual steps:

```
[{ "tool": "os.shell.run", "args": { "cmd": "open", "args": ["https://www.notion.so/profile/integrations"] } }]
```

Then reply with three short bullets:

> "I opened the integrations page. Do three steps:
> 1. Click \"+ New integration\", choose \"Internal\", give it any name, click Save.
> 2. On the Configuration tab, copy the \"Internal Integration Token\" (starts with `ntn_`).
> 3. Send the token here — I'll take it from there."

When the user pastes the token, switch to Path (a).

### Page not shared with the integration

Notion returns `404 object_not_found` when the integration doesn't have access to a page or database. The agent CANNOT add the integration via API (Notion does not expose the connection-grant endpoint to integrations). Help the user share, but do it concretely — open the page in their browser:

```
[{ "tool": "os.shell.run", "args": { "cmd": "open", "args": ["https://www.notion.so/<page_id_without_dashes>"] } }]
```

Then reply:

> "I opened the page in your browser. In the top-right corner click `…` → `Connections` (or `Connect to`) → choose your integration. After that say \"done\" — I'll retry the request."

## Calling the API

```
os.http.request {
  method: "GET",
  url: "https://api.notion.com/v1/...",
  headers: {
    "Authorization": "Bearer ntn_xxx_your_key_here",
    "Notion-Version": "2025-09-03",
    "Content-Type": "application/json"
  }
}
```

`os.http.request` only supports `GET` and `POST`. For `PATCH` (update page, append blocks) and `DELETE`, fall back to `os.shell.run` with `curl`:

```
os.shell.run {
  cmd: "curl",
  args: [
    "-fsSL", "-X", "PATCH",
    "-H", "Authorization: Bearer ntn_xxx_your_key_here",
    "-H", "Notion-Version: 2025-09-03",
    "-H", "Content-Type: application/json",
    "-d", "{\"properties\": {...}}",
    "https://api.notion.com/v1/pages/{page_id}"
  ]
}
```

Default `http.approvalMode` is `writes`: `GET` goes through silently, `POST`/`os.shell.run` always require approval — present a clear `preview` of what is being changed.

## Common operations

All examples assume the headers from Step 1 above are attached.

### HTTP method cheat sheet

| Method | Endpoints |
|---|---|
| `GET` | `/v1/pages/{id}`, `/v1/blocks/{id}/children`, `/v1/databases/{id}`, `/v1/data_sources/{id}`, `/v1/users`, `/v1/users/{id}` |
| `POST` | `/v1/search`, `/v1/pages` (create), `/v1/data_sources/{id}/query`, `/v1/databases` (create) |
| `PATCH` (curl) | `/v1/pages/{id}` (update properties), `/v1/blocks/{id}/children` (append blocks), `/v1/blocks/{id}` (update block) |
| `DELETE` (curl) | `/v1/blocks/{id}` (archive block) |

If a 400 comes back from Notion, **first** check that the method matches this table — picking GET for a POST endpoint is the most common mistake.

### Search (POST)

> `/v1/search` is **POST-only**. There is no GET variant — `GET /v1/search` and `GET /v1/search?query=…` both return `400`. The query string and any filters go in the **JSON body**, not the URL.

#### Body field reference (every field is optional)

| Field | Type | Allowed shape |
|---|---|---|
| `query` | string | Free-text match against page/database titles. Pass `""` or omit to list everything. |
| `filter` | object | **Exactly two keys**: `{ "property": "object", "value": "page" \| "data_source" }`. Property-predicate filters (`{property:"Status", select:{equals:"…"}}`) are **rejected** here — that shape belongs to `/v1/data_sources/{id}/query`. |
| `sort` | object **(singular, not array)** | **Exactly two keys**: `{ "direction": "ascending" \| "descending", "timestamp": "last_edited_time" }`. Array form (`[{...}]`) is **rejected** with `body.sort should be an object or undefined`. The inner key is `timestamp`, NOT `property`. The only legal value for `timestamp` is `"last_edited_time"`. Direction must be the full word — never `"asc"` / `"desc"`. |
| `page_size` | integer | `1..100`. Default `100`. |
| `start_cursor` | string | Opaque cursor from the previous response's `next_cursor`. |

#### Search vs database-query: side-by-side schema

`/v1/search` and `/v1/data_sources/{id}/query` look similar but have **different** filter and sort shapes. Don't cross-pollinate them.

| | `POST /v1/search` | `POST /v1/data_sources/{id}/query` |
|---|---|---|
| Sort key name | `sort` (singular) | `sorts` (plural) |
| Sort value shape | object `{ direction, timestamp }` | array `[ { property, direction }, … ]` |
| Sort inner key | `timestamp: "last_edited_time"` | `property: "<your-column-name>"` |
| Filter shape | `{ property: "object", value: "page"\|"data_source" }` | property-predicate, e.g. `{ property: "Status", select: { equals: "Done" } }` |

Search by query text (filter optional):

```
os.http.request {
  method: "POST",
  url: "https://api.notion.com/v1/search",
  headers: { ... },
  body: { "query": "page title" }
}
```

List all pages the integration can see, newest first, no query:

```
os.http.request {
  method: "POST",
  url: "https://api.notion.com/v1/search",
  headers: { ... },
  body: {
    "filter":    { "property": "object", "value": "page" },
    "sort":      { "direction": "descending", "timestamp": "last_edited_time" },
    "page_size": 50
  }
}
```

To page through results, send the `next_cursor` from the previous response back as `start_cursor`. To list databases instead of pages, set `filter.value` to `"data_source"`.

### Get page metadata (GET)

```
GET https://api.notion.com/v1/pages/{page_id}
```

### Get page content / blocks (GET)

```
GET https://api.notion.com/v1/blocks/{page_id}/children
```

### Create a page in a database (POST)

```
os.http.request {
  method: "POST",
  url: "https://api.notion.com/v1/pages",
  headers: { ... },
  body: {
    "parent": { "database_id": "xxx" },
    "properties": {
      "Name":   { "title":  [ { "text": { "content": "New Item" } } ] },
      "Status": { "select": { "name": "Todo" } }
    }
  }
}
```

### Query a database / data source (POST)

```
os.http.request {
  method: "POST",
  url: "https://api.notion.com/v1/data_sources/{data_source_id}/query",
  headers: { ... },
  body: {
    "filter": { "property": "Status", "select": { "equals": "Active" } },
    "sorts":  [ { "property": "Date", "direction": "descending" } ]
  }
}
```

### Update page properties (PATCH — via curl)

```
os.shell.run {
  cmd: "curl",
  args: [
    "-fsSL", "-X", "PATCH",
    "-H", "Authorization: Bearer <key>",
    "-H", "Notion-Version: 2025-09-03",
    "-H", "Content-Type: application/json",
    "-d", "{\"properties\":{\"Status\":{\"select\":{\"name\":\"Done\"}}}}",
    "https://api.notion.com/v1/pages/{page_id}"
  ]
}
```

### Append blocks to a page (PATCH — via curl)

Same shape as above but URL is `https://api.notion.com/v1/blocks/{page_id}/children` and the body is:

```
{ "children": [ { "object": "block", "type": "paragraph",
                   "paragraph": { "rich_text": [ { "text": { "content": "Hello" } } ] } } ] }
```

## Property types (cheat sheet)

- Title: `{ "title": [ { "text": { "content": "..." } } ] }`
- Rich text: `{ "rich_text": [ { "text": { "content": "..." } } ] }`
- Select: `{ "select": { "name": "Option" } }`
- Multi-select: `{ "multi_select": [ { "name": "A" }, { "name": "B" } ] }`
- Date: `{ "date": { "start": "2026-01-15", "end": "2026-01-16" } }`
- Checkbox: `{ "checkbox": true }`
- Number: `{ "number": 42 }`
- URL: `{ "url": "https://..." }`
- Email: `{ "email": "user@example.com" }`
- Relation: `{ "relation": [ { "id": "page_id" } ] }`

## API version 2025-09-03 — what changed

- "Databases" are now called **data sources** in the API surface.
- Each database carries both a `database_id` (used as `parent` when creating pages) and a `data_source_id` (used as the path component when querying: `POST /v1/data_sources/{id}/query`).
- Search results for databases come back as `"object": "data_source"` with their `data_source_id`.

## Notes

- Page and database IDs are UUIDs; both with and without dashes are accepted.
- Rate limit: ~3 requests/second average. Back off on `429`.
- The API cannot set database view filters — that is a UI-only feature.
- If the agent gets `object_not_found` (404), the most common cause is forgetting to share the page with the integration.
- Never echo the `Authorization` header value back to the user verbatim. Treat the key as a secret.
- `/v1/search` and `/v1/data_sources/{id}/query` use **different** filter shapes. `/v1/search` only filters by object type (`{property:"object", value:"page"|"data_source"}`); the property-predicate filter (`{property:"Status", select:{equals:"Active"}}`) belongs exclusively to `/v1/data_sources/{id}/query`.
- To fetch multiple pages or blocks, emit one solo `os.http.request` step at a time and wait for each result. The runtime forbids batching approval-gated tools (see `## Calling convention`).
- `GET /v1/pages/{id}` returns the full property map by default. Do **not** append `?include=properties` or any similar query param — Notion does not define one and will silently ignore it.

## Troubleshooting — common 400 patterns

When Notion returns `400 validation_error`, the message text is precise — read it before retrying. The three most common patterns seen in agent traces:

### `body.sort should be an object or undefined, instead was [{…}]`

You sent `sort` as an array. `/v1/search` accepts only the singular-object form:

DO:
```
"sort": { "direction": "descending", "timestamp": "last_edited_time" }
```

DON'T (taken from a real failing trace):
```
"sort": [ { "property": "last_edited_time", "direction": "descending" } ]   // wrong: array, plural-style
"sort": [ { "property": "last_edited_time", "direction": "desc" } ]         // wrong: array AND short direction
```

The array-of-`{property, direction}` form belongs to `/v1/data_sources/{id}/query` under the key `sorts` (plural). See the side-by-side table in `### Search (POST)`.

### `body.filter.property should be "object", instead was "<something>"`

You sent a property-predicate filter to `/v1/search`. That endpoint only accepts an object-type filter:

DO:
```
"filter": { "property": "object", "value": "page" }
"filter": { "property": "object", "value": "data_source" }
```

DON'T:
```
"filter": { "property": "Status", "select": { "equals": "Done" } }     // wrong endpoint
"filter": { "property": "name",   "text":   { "contains": "" } }       // invented, not a Notion shape
"filter": { "property": "parent", "database_id": null, "rich_text": { "exists": true } }  // frankenstein
```

If you actually need to filter by page properties, switch to `POST /v1/data_sources/{id}/query` and use the property-predicate shape there.

