Installs into .claude/skills of the current project.
Are you the author of Notion?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/gabrielmoreira-notion)
---
name: notion
description: Read and write Notion pages, databases (data sources), and blocks via the Notion REST API.
version: 1.3.1
requires_tools:
- os.shell.run
- os.http.request
dangerous: false
platforms:
- darwin
- linux
---
# 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.