UI はローカライズ済みです。スキルの紹介と説明文書は選択した言語へ AI が自動翻訳し、コード・コマンド・パス・専門用語は原文のままです。原作者の文書が優先されます。
スキルを探す
データ・研究 · SKILL.md

notion

notion

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

Independent GitHub snapshot · Automated classification · Original documentation governs

スキルの説明

GitHub

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

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

MethodEndpoints
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)

FieldTypeAllowed shape
querystringFree-text match against page/database titles. Pass "" or omit to list everything.
filterobjectExactly 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.
sortobject (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_sizeinteger1..100. Default 100.
start_cursorstringOpaque 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/searchPOST /v1/data_sources/{id}/query
Sort key namesort (singular)sorts (plural)
Sort value shapeobject { direction, timestamp }array [ { property, direction }, … ]
Sort inner keytimestamp: "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.

保存したスキル