Claude Skill

notion

Operate Notion from a terminal or agent: retrieve pages, query databases, search pages and databases, and update page properties — with a bundled notion-cli script that is read-only by default and gates every create or update behind a --dry-run/--yes confirmation. Use when an age

LLM Mart · 0 points · 10 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download magnus919-agent-skills-notion-addad86.zip · 16 KB
Part of magnus919/agent-skills — 145 skills

Install

skills CLI npx skills add https://github.com/magnus919/agent-skills/tree/main/notion
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
Git git clone https://github.com/magnus919/agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.

README

Notion — Read and Edit Notion from the Terminal

Operate Notion without leaving your terminal or your agent's tool loop: retrieve pages, query databases, search across the workspace, and make confirmed property updates.

Why Install This Skill

Teams run their operational memory in Notion — runbooks, on-call docs, product trackers, decision logs — and agents have had no bounded way to read it. This skill gives your agent a real read path (page retrieval, database queries, search) and a safe write path: creating a page or updating a property is a guarded mutation that requires a preview and an explicit confirmation, so the agent can answer questions from Notion without ever silently editing shared content.

It ships notion-cli, a small Python script that speaks the Notion API with no third-party dependencies. Reads are capped (--limit), output is clean JSON for the agent or readable text for you, and --help works with no token and no network. The script sends the standard Notion-Version header and summarizes pages as title + ID + URL instead of dumping raw block trees.

What You Get

Directory Purpose
SKILL.md Agent-facing operating contract, mutation gates, and verification boundaries
references/ Dated source index and an API operations reference (endpoints, pagination, property types, filters, errors)
scripts/notion-cli Bounded, stdlib-only CLI: pages get/create/update, databases query, search; --json, --limit, mutations gated by --dry-run/--yes
tests/ 13 deterministic tests against a stub Notion API, covering the mutation gate and read-only contract
evals/evals.json Six output-quality evaluation cases for agent runs

Quick Start

# Help works with no token and no network
notion/scripts/notion-cli --help

# Find a page by text (bounded)
NOTION_TOKEN=secret_... notion/scripts/notion-cli --json search query --query "on-call runbook"

# Retrieve one page
NOTION_TOKEN=secret_... notion/scripts/notion-cli --json pages get --page-id <page-id>

# Query a database, capped at 10 rows
NOTION_TOKEN=secret_... notion/scripts/notion-cli --json --limit 10 databases query --database-id <db-id>

# Update a page property: preview first, then confirm
printf '{"Status": {"select": {"name": "Done"}}}' > props.json
NOTION_TOKEN=secret_... notion/scripts/notion-cli pages update --page-id <page-id> --properties props.json --dry-run
NOTION_TOKEN=secret_... notion/scripts/notion-cli pages update --page-id <page-id> --properties props.json --yes

Triggers

Load this skill for notion operations: "what does the runbook say", reading pages, querying a Notion database (rows, filters), searching pages and databases, creating a page row, or updating a page property with confirmation. Do not load it for building Notion integrations or apps, workspace administration, or other knowledge bases like Confluence.

Requirements

  • Python 3.9+ for notion-cli (stdlib only; --help needs nothing else).
  • A Notion integration token (NOTION_TOKEN, secret_...) with the workspace pages/databases you need shared with the integration. Search and database access require the corresponding capabilities in the integration settings.
  • Network access to api.notion.com for live reads and writes. Optionally set NOTION_VERSION to pin the API version (default 2022-06-28).

Skill manifest

Notion Operations

Use this skill to read and, with explicit confirmation, write Notion content through the Notion API: pages, database queries, search, and page property updates. This is a tool skill for the Notion platform. Building Notion integrations, writing complex block compositions, or building an app on the Notion API is application development; this skill owns the everyday agent workflow: finding the right page, answering from a database, and making a confirmed edit.

Operating contract

  1. Read-only discovery before any mutation. Retrieve pages, query databases, and search freely. The bundled notion-cli script makes reads without writing anything.
  2. Confirm the target, scope, and rollback path before acting. Creating a page or updating properties changes a shared workspace that teammates read. Both require an explicit human directive plus --dry-run preview and --yes confirmation through notion-cli. Property updates overwrite existing values — state the current value and the replacement before confirming.
  3. Respect bounded reads. Notion paginates with page_size and has_more; never page past what the task needs. notion-cli --limit caps every search and query.
  4. Keep evidence bounded. Quote short page titles, property values, and IDs; never paste full pages, tokens, or raw API payloads into chat.
  5. Know the API version. The Notion-Version header pins the API contract; reads that work today can change with a version bump. notion-cli sends 2022-06-28 by default and honors NOTION_VERSION.

The notion-cli script

scripts/notion-cli is an agent-first, stdlib-only CLI over the Notion API. It covers the full issue scope: pages, databases (query), search, and updates.

notion/scripts/notion-cli --help                           # no token or network needed
notion/scripts/notion-cli --json pages get --page-id <page>
notion/scripts/notion-cli --json --limit 10 databases query --database-id <db>
notion/scripts/notion-cli --json search query --query "on-call runbook"
notion/scripts/notion-cli pages update --page-id <page> --properties props.json --dry-run
notion/scripts/notion-cli pages update --page-id <page> --properties props.json --yes
notion/scripts/notion-cli pages create --parent-database <db> --title "New row" --yes

Exit codes: 0 success, 1 API error or failed check, 2 usage error. Creates and updates are guarded: without --dry-run or --yes the script refuses with exit 1 and never calls the API. Reads are bounded by --limit (default 20, max 100).

Operating loop

  1. Locate the content: search query to find pages and databases by text, or a known ID directly.
  2. Read with bounds: pages get for a single page, databases query for rows in a database (optionally with a JSON --filter), always capped by --limit.
  3. Triage the answer: map the question to evidence (page title + properties, database rows, search results with has_more state).
  4. Act with confirmation: only a human directive to change, previewed with --dry-run and confirmed with --yes.
  5. Verify: re-read the page (pages get) and confirm the property values landed.

Pages, databases, search

  • Pages (GET /pages/{id}): a page is an ID, a title (extracted from the title or Name property), a URL, and timestamps. Property values live under properties; the CLI summarizes them rather than dumping the full block tree.
  • Databases (POST /databases/{id}/query): query rows as pages with a page_size cap and an optional structured --filter JSON file (e.g. {"property": "Status", "select": {"equals": "Done"}}). has_more tells you whether the cap hid further rows.
  • Search (POST /search): finds pages and databases by text across the integration's accessible workspace; results are bounded by --limit.
  • Updates (PATCH /pages/{id}): property updates overwrite values (select, status, checkbox, rich text, etc.). Preview the exact properties payload with --dry-run and confirm with --yes; verify with a follow-up pages get.

Integration access model

  • Notion integrations authenticate with a bot-style token (secret_...) and can only see the pages and databases explicitly shared with the integration. A page that exists in the workspace but is not shared returns 404/object_not_found — that is an access-model result, not a missing page.
  • The Notion-Version header selects the API contract. The CLI defaults to 2022-06-28; set NOTION_VERSION when a workspace or application pins a different version.
  • Tokens are workspace-scoped credentials. Store them in the environment (NOTION_TOKEN), never in code, chat, or commits. Revoke a leaked integration token in the Notion integration settings.

Reference routing

Load when Reference
Sources, version notes, refresh procedure references/00-source-index.md
API endpoints, pagination, property types, filters, and error handling references/01-api-operations.md

Included artifacts

  • scripts/notion-cli: bounded, stdlib-only CLI (pages get/create/update, databases query, search; --json; --limit; mutations gated by --dry-run/--yes).
  • tests/test_notion_cli.py: 13 deterministic tests against a stub Notion API, including the mutation gate and the read-only contract.
  • references/: dated source index + API operations reference.
  • evals/evals.json: six output-quality evaluation cases for agent runs.

Verification boundary

Claim Minimum evidence
A page exists and its title notion-cli pages get --page-id ... --json returns the title and ID
A database query answered the question notion-cli databases query --json returns bounded rows with has_more state
Search found the content notion-cli search query --json returns the matching page/database with ID and title
An update landed notion-cli pages update --yes exits 0 and a follow-up pages get shows the new property values
A mutation is safe to run notion-cli ... --dry-run prints the exact payload that would be sent

Hard boundaries

  • Never create or update a page without a human directive, --dry-run preview, and --yes confirmation — Notion edits are visible to everyone with access to the page.
  • Never claim a page is missing when it may simply not be shared with the integration; verify the access model first.
  • Never page reads past --limit; never dump full pages, tokens, or raw payloads into chat.
  • This skill operates the Notion API. It does not build Notion integrations (application development) or cover other knowledge-base products.

When not to use

  • Building Notion integrations or apps (OAuth flows, custom blocks, public API products, block-tree composition beyond property updates) — that is application development; see backend-engineering for service design.
  • Other knowledge bases and document tools (Confluence, Google Docs, wikis) — each has its own tooling; this skill covers Notion only.
  • Workspace administration (user management, workspace settings, integration approval) — that is the Notion admin console.
Files (agent-skills)
  • evals
    • evals.json 5.8 KB
      {
        "schema_version": 1,
        "skill_name": "notion",
        "evals": [
          {
            "id": "page-retrieval",
            "prompt": "A user asks: 'Show me what the on-call runbook page says. I have the page ID page-9f8e7d6c5b4a3210.'",
            "expected_output": "Run notion-cli pages get --page-id page-9f8e7d6c5b4a3210 and report the page title, URL, last-edited time, and a summary of the meaningful property values. The response quotes short values with the page ID for verification and does not dump the raw block tree or full property payload. If the page is not accessible, it checks whether the page is shared with the integration before concluding it is missing.",
            "assertions": [
              "The page is retrieved via notion-cli pages get with the exact page ID",
              "The response summarizes the title, URL, and property values without dumping raw payloads",
              "Quotes are short and include the page ID for verification",
              "An inaccessible page is checked against the integration sharing model before being called missing"
            ]
          },
          {
            "id": "database-query",
            "prompt": "A user asks: 'How many open bugs are tracked in our issues database (db-1234), and what are the five oldest? Show me their titles and statuses.'",
            "expected_output": "Run notion-cli databases query --database-id db-1234 with a bounded --limit (at least 20) and optionally a JSON filter on the Status property equals 'Open' to narrow the answer. Report the row count visible under the cap, then the five oldest rows by created time with title and status. The response notes whether has_more indicates further rows beyond the cap and offers to page with a higher limit. Reads only — nothing is updated.",
            "assertions": [
              "The database is queried via notion-cli databases query with the database ID",
              "The query uses a bounded --limit and reports has_more state",
              "A Status filter is used when narrowing to open bugs",
              "The response is read-only and offers to page if rows exceed the cap"
            ]
          },
          {
            "id": "workspace-search",
            "prompt": "A user asks: 'Search Notion for anything about the postmortem for last week's checkout outage. I do not remember where we wrote it.'",
            "expected_output": "Run notion-cli search query with terms derived from the request (e.g. 'postmortem checkout outage') and a bounded --limit. Report each result as page or database with title, ID, and URL, noting how many results were returned versus the cap. If the term set is too broad, suggest a more specific query or narrowing keywords. The response stays read-only.",
            "assertions": [
              "Search is run via notion-cli search query with terms derived from the user's topic",
              "Each result identifies object type (page/database), title, ID, and URL",
              "The bounded cap and has_more state are reported",
              "The operation is read-only"
            ]
          },
          {
            "id": "guarded-property-update",
            "prompt": "A user asks: 'Mark the runbook page page-9f8e7d6c5b4a3210 as Reviewed and set the reviewer to me. The current status is Draft.'",
            "expected_output": "The agent builds the properties payload ({'Status': {'select': {'name': 'Reviewed'}}, 'Reviewer': {'rich_text': [{'text': {'content': '<user>'}}]}}), states the current value (Draft) and the replacement, then previews via notion-cli pages update --dry-run and asks for explicit confirmation. Only after the user confirms does it run pages update --yes, then verifies with pages get that the properties changed. If the user only asked to draft the change, nothing is written.",
            "assertions": [
              "The exact properties payload is previewed with --dry-run before any update",
              "The current value and replacement are stated before confirmation",
              "The update runs only after explicit user confirmation, via --yes",
              "The result is verified with a follow-up pages get"
            ]
          },
          {
            "id": "page-creation-confirmed",
            "prompt": "A user asks: 'Add a row to the incident log database db-5678 titled \"Checkout latency spike\" so we can track it.'",
            "expected_output": "The agent resolves the parent (database db-5678), builds the page payload with the title property, previews it with notion-cli pages create --parent-database db-5678 --title 'Checkout latency spike' --dry-run, and asks for confirmation. After explicit confirmation it runs the create with --yes and reports the new page ID and URL. It does not create anything if the user only asked for a draft.",
            "assertions": [
              "The create payload is previewed with --dry-run before any request",
              "The parent database and title are explicit in the preview",
              "Creation happens only after explicit user confirmation, via --yes",
              "The response reports the new page ID and URL as delivery evidence"
            ]
          },
          {
            "id": "access-model-triage",
            "prompt": "A user asks: 'The integration can't find page-1111. Did we delete it?' The search API returns no result for the page title they expect.",
            "expected_output": "The response distinguishes the failure modes: a 404/object_not_found from the Notion API usually means the page exists in the workspace but is not shared with the integration, not that it was deleted. It advises checking the page's sharing settings (add the integration to the page or its parent), then re-running notion-cli pages get or search. It does not claim the page is deleted without evidence, and it never creates or updates anything during triage.",
            "assertions": [
              "object_not_found is explained as an access-model result rather than proof of deletion",
              "The fix is to share the page with the integration and re-check",
              "Triage is read-only and no mutation is attempted",
              "Deletion is only concluded with concrete evidence"
            ]
          }
        ]
      }
      
  • references
    • 00-source-index.md 1.5 KB
      # Notion — Source Index
      
      > **Last Updated:** 2026-08-03
      
      This skill is a distilled operating layer over Notion's public developer documentation. Facts and endpoint names in this skill are grounded in the sources below; refresh this index when Notion ships API changes.
      
      | Topic | Source | URL |
      |---|---|---|
      | API reference (endpoints, versioning) | Notion API reference | https://developers.notion.com/reference |
      | Versioning and the `Notion-Version` header | API versioning | https://developers.notion.com/reference/versioning |
      | Authentication and integration tokens | Authorization | https://developers.notion.com/reference/authorization |
      | Pages (retrieve, create, update) | Page endpoints | https://developers.notion.com/reference/patch-page |
      | Database queries and filters | Query a database | https://developers.notion.com/reference/post-database-query |
      | Search | Search endpoint | https://developers.notion.com/reference/post-search |
      | Property types and values | Property value objects | https://developers.notion.com/reference/property-value-object |
      
      ## Refresh procedure
      
      - Re-check the API reference when a call returns `validation_error` for a documented body shape or when `Notion-Version` deprecations are announced.
      - The `Notion-Version` header is a security-adjacent contract pin: before changing the default in `notion-cli`, verify the new version's property object shapes in the versioning page.
      - Update `research_checked` in `SKILL.md` frontmatter and this file's `Last Updated` when you verify the sources again.
      
    • 01-api-operations.md 3.7 KB
      # Notion API Operations
      
      > **Last Updated:** 2026-08-03
      
      Operational detail for the Notion API surface the skill owns: endpoints, the version header, pagination, property values, filters, and error handling. The bundled `notion-cli` implements this reference; use this document when a call behaves unexpectedly.
      
      ## Request conventions
      
      - Base URL: `https://api.notion.com/v1`. Every request carries `Authorization: Bearer <integration_token>` and the `Notion-Version` header (default `2022-06-28`; `notion-cli` honors `NOTION_VERSION`).
      - Bodies are JSON. Reads with bodies (search, database query) are `POST`; single-object reads are `GET`; updates are `PATCH`; creates are `POST`.
      
      ## Endpoint surface
      
      | Operation | Endpoint | Method | Notes |
      |---|---|---|---|
      | Retrieve a page | `/pages/{id}` | GET | Summarized as id, title, url, timestamps |
      | Create a page | `/pages` | POST | Guarded mutation; `parent` is `page_id` or `database_id` |
      | Update page properties | `/pages/{id}` | PATCH | Guarded mutation; overwrites the given property values |
      | Query a database | `/databases/{id}/query` | POST | `page_size` cap + optional `filter` object |
      | Search | `/search` | POST | Finds pages and databases by text; `page_size` cap |
      
      ## Pagination and bounded reads
      
      - Search and database queries take `page_size` (max 100) and return `has_more` plus a `next_cursor` when more rows exist.
      - **Bounded-read rule:** request only what the task needs; `notion-cli --limit` caps `page_size` at the request level. If a task needs more, raise the limit or page with the cursor, and stop when the question is answered.
      - Always report `has_more` when summarizing a query so the reader knows the cap hid further rows.
      
      ## Property values
      
      - A page's `properties` is a map of property names to value objects. Title extraction: `notion-cli` looks for a property typed `title` (commonly named `title` or `Name`).
      - Common value objects for updates: `{"select": {"name": "..."}}`, `{"status": {"name": "..."}}`, `{"checkbox": true|false}`, `{"rich_text": [{"text": {"content": "..."}}]}`, `{"number": 42}`, `{"date": {"start": "2026-08-03"}}`.
      - An update `PATCH` sends only the properties you include; properties you omit are left unchanged. Omitted properties are safe; *wrong* values for included properties are the risk, so preview the exact payload with `--dry-run`.
      
      ## Filters
      
      Database query filters are structured JSON, e.g.:
      
      ```json
      {"property": "Status", "select": {"equals": "Open"}}
      {"or": [
        {"property": "Priority", "select": {"equals": "High"}},
        {"property": "Priority", "select": {"equals": "Critical"}}
      ]}
      ```
      
      A `--filter` file must be a single JSON object; `notion-cli` validates it parses before sending.
      
      ## Error handling
      
      - HTTP 400 `validation_error`: the body shape or filter is wrong — the message names the offending field. Fix the payload, never retry blindly.
      - HTTP 404 `object_not_found`: almost always the page/database is **not shared with the integration**, not deleted. Check sharing settings before concluding data loss.
      - HTTP 401 `unauthorized`: token invalid or revoked — rotate the integration token.
      - HTTP 429 `rate_limited`: slow down; Notion rate limits per integration.
      - `notion-cli` exit 1 with a `Notion API HTTP <code>: <message>` line; the `--json` variant emits `{"ok": false, "error": "..."}`.
      
      ## Integration access model
      
      - An integration sees exactly the pages and databases **shared with it**. Sharing a parent page shares descendants unless a child overrides.
      - Search only covers content the integration can access — a workspace-wide search from the app may find more than the API search will.
      - Tokens are workspace-scoped credentials; store in `NOTION_TOKEN`, never in code, chat, or commits. Revoke a leaked token in the integration settings.
      
  • scripts
    • notion-cli 13.1 KB · in bundle
  • tests
    • test_notion_cli.py 9.3 KB
      #!/usr/bin/env python3
      """Deterministic tests for notion/scripts/notion-cli.
      
      Runs the script as a subprocess so the tests exercise the real CLI surface
      (--help, --json, --limit, mutation gate, exit codes, JSON payloads). A local
      stdlib HTTP server stubs the Notion API (pages, databases/query, search), so
      no external network or Notion workspace is needed. Also asserts the read-only
      contract: reads never call write methods, and the mutation gate refuses to
      create/update without --dry-run or --yes.
      """
      import json
      import os
      import subprocess
      import sys
      import threading
      import unittest
      from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
      from pathlib import Path
      
      ROOT = Path(__file__).resolve().parent.parent
      SCRIPT = ROOT / "scripts" / "notion-cli"
      
      SAMPLE_PAGE = {
          "object": "page",
          "id": "page-1234",
          "url": "https://www.notion.so/page-1234",
          "created_time": "2026-01-01T00:00:00.000Z",
          "last_edited_time": "2026-01-02T00:00:00.000Z",
          "properties": {"title": {"type": "title", "title": [{"plain_text": "Meeting notes"}]}},
      }
      
      
      class StubNotionServer:
          """Minimal stub of the Notion API surface used by notion-cli."""
      
          def __init__(self):
              self.requests = []  # (method, path, body) recorded by the stub
              handler = self._make_handler()
              self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
              self.port = self.server.server_address[1]
              self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
      
          def _make_handler(self):
              stub = self
      
              class Handler(BaseHTTPRequestHandler):
                  def _respond(self, payload, status=200):
                      self.send_response(status)
                      self.send_header("Content-Type", "application/json")
                      self.end_headers()
                      self.wfile.write(json.dumps(payload).encode("utf-8"))
      
                  def _read_json(self):
                      length = int(self.headers.get("Content-Length", "0"))
                      raw = self.rfile.read(length)
                      return json.loads(raw.decode("utf-8")) if raw else {}
      
                  def do_GET(self):  # noqa: N802
                      stub.requests.append(("GET", self.path, None))
                      if self.path.startswith("/pages/"):
                          self._respond(SAMPLE_PAGE)
                      else:
                          self._respond({"message": "not_found"}, 404)
      
                  def do_POST(self):  # noqa: N802
                      body = self._read_json()
                      stub.requests.append(("POST", self.path, body))
                      if self.path == "/search":
                          results = [SAMPLE_PAGE]
                          page_size = body.get("page_size", 20)
                          self._respond({"results": results[:page_size], "has_more": False})
                      elif "/query" in self.path:
                          page_size = body.get("page_size", 20)
                          self._respond({"results": [SAMPLE_PAGE][:page_size], "has_more": False})
                      elif self.path == "/pages":
                          created = dict(SAMPLE_PAGE, id="page-new")
                          self._respond(created, 200)
                      else:
                          self._respond({"message": "not_found"}, 404)
      
                  def do_PATCH(self):  # noqa: N802
                      body = self._read_json()
                      stub.requests.append(("PATCH", self.path, body))
                      self._respond(SAMPLE_PAGE, 200)
      
                  def log_message(self, *args):  # silence stderr
                      pass
      
              return Handler
      
          def __enter__(self):
              self.thread.start()
              return self
      
          def __exit__(self, *exc):
              self.server.shutdown()
              self.server.server_close()
      
      
      def run_script(env, *args):
          return subprocess.run(
              [sys.executable, str(SCRIPT), *args],
              capture_output=True,
              text=True,
              timeout=30,
              env=env,
          )
      
      
      def base_env(stub):
          env = dict(os.environ)
          env["NOTION_TOKEN"] = "secret_test"
          env["NOTION_API_BASE"] = f"http://127.0.0.1:{stub.port}/"
          return env
      
      
      def load_json(proc):
          return json.loads(proc.stdout)
      
      
      class NotionCliTests(unittest.TestCase):
          def test_help_lists_json_and_bounded_reads(self):
              proc = run_script(dict(os.environ), "--help")
              self.assertEqual(proc.returncode, 0)
              self.assertIn("--json", proc.stdout)
              self.assertIn("--limit", proc.stdout)
      
          def test_help_works_without_token(self):
              env = dict(os.environ)
              env.pop("NOTION_TOKEN", None)
              proc = run_script(env, "search", "query", "--help")
              self.assertEqual(proc.returncode, 0)
      
          def test_pages_get(self):
              with StubNotionServer() as stub:
                  proc = run_script(base_env(stub), "--json", "pages", "get", "--page-id", "page-1234")
              self.assertEqual(proc.returncode, 0, proc.stderr)
              data = load_json(proc)
              self.assertEqual(data["page"]["title"], "Meeting notes")
              self.assertEqual(data["page"]["id"], "page-1234")
      
          def test_database_query_bounded(self):
              with StubNotionServer() as stub:
                  proc = run_script(base_env(stub), "--json", "--limit", "5", "databases", "query",
                                    "--database-id", "db-1")
              self.assertEqual(proc.returncode, 0, proc.stderr)
              data = load_json(proc)
              self.assertEqual(data["database_id"], "db-1")
              self.assertEqual(data["pages"][0]["id"], "page-1234")
      
          def test_database_query_sends_page_size(self):
              with StubNotionServer() as stub:
                  run_script(base_env(stub), "--json", "--limit", "3", "databases", "query",
                             "--database-id", "db-1")
              posts = [body for method, path, body in stub.requests
                       if method == "POST" and path == "/databases/db-1/query"]
              self.assertEqual(len(posts), 1)
              self.assertEqual(posts[0].get("page_size"), 3)
      
          def test_search(self):
              with StubNotionServer() as stub:
                  proc = run_script(base_env(stub), "--json", "search", "query", "--query", "meeting")
              self.assertEqual(proc.returncode, 0, proc.stderr)
              data = load_json(proc)
              self.assertEqual(data["results"][0]["object"], "page")
      
          def test_pages_create_requires_confirmation(self):
              with StubNotionServer() as stub:
                  proc = run_script(base_env(stub), "pages", "create", "--parent-page", "page-1",
                                    "--title", "New page")
              self.assertEqual(proc.returncode, 1)
              self.assertIn("refusing to create", proc.stderr)
              self.assertEqual(stub.requests, [], "no API call may be made without confirmation")
      
          def test_pages_create_dry_run_does_not_post(self):
              with StubNotionServer() as stub:
                  proc = run_script(base_env(stub), "--json", "pages", "create", "--parent-page", "page-1",
                                    "--title", "New page", "--dry-run")
              self.assertEqual(proc.returncode, 0, proc.stderr)
              data = load_json(proc)
              self.assertTrue(data["dry_run"])
              self.assertEqual(stub.requests, [], "dry-run must not reach the API")
      
          def test_pages_create_with_yes_posts(self):
              with StubNotionServer() as stub:
                  proc = run_script(base_env(stub), "--json", "pages", "create", "--parent-page", "page-1",
                                    "--title", "New page", "--yes")
              self.assertEqual(proc.returncode, 0, proc.stderr)
              self.assertEqual(load_json(proc)["page"]["id"], "page-new")
              self.assertTrue(any(method == "POST" and path == "/pages" for method, path, _ in stub.requests))
      
          def test_pages_update_requires_confirmation(self):
              props = ROOT / "tests" / "props.json"
              props.write_text(json.dumps({"Status": {"select": {"name": "Done"}}}))
              try:
                  with StubNotionServer() as stub:
                      proc = run_script(base_env(stub), "pages", "update", "--page-id", "page-1234",
                                        "--properties", str(props))
              finally:
                  props.unlink(missing_ok=True)
              self.assertEqual(proc.returncode, 1)
              self.assertIn("refusing to update", proc.stderr)
              self.assertEqual(stub.requests, [])
      
          def test_pages_update_with_yes_patches(self):
              props = ROOT / "tests" / "props.json"
              props.write_text(json.dumps({"Status": {"select": {"name": "Done"}}}))
              try:
                  with StubNotionServer() as stub:
                      proc = run_script(base_env(stub), "--json", "pages", "update", "--page-id", "page-1234",
                                        "--properties", str(props), "--yes")
              finally:
                  props.unlink(missing_ok=True)
              self.assertEqual(proc.returncode, 0, proc.stderr)
              self.assertTrue(any(method == "PATCH" for method, _path, _body in stub.requests))
      
          def test_missing_token_errors_cleanly(self):
              env = dict(os.environ)
              env.pop("NOTION_TOKEN", None)
              proc = run_script(env, "--json", "search", "query", "--query", "x")
              self.assertEqual(proc.returncode, 1)
              self.assertIn("NOTION_TOKEN", proc.stdout)
      
          def test_read_only_contract_no_write_opens(self):
              source = SCRIPT.read_text()
              writes = [line for line in source.splitlines()
                        if line.strip().startswith("open(") and ("'w'" in line or '"w"' in line)]
              self.assertEqual(writes, [], "script must never open files in write mode")
      
      
      if __name__ == "__main__":
          unittest.main()
      
  • README.md 3.3 KB
    # Notion — Read and Edit Notion from the Terminal
    
    Operate Notion without leaving your terminal or your agent's tool loop: retrieve pages, query databases, search across the workspace, and make confirmed property updates.
    
    ## Why Install This Skill
    
    Teams run their operational memory in Notion — runbooks, on-call docs, product trackers, decision logs — and agents have had no bounded way to read it. This skill gives your agent a real read path (page retrieval, database queries, search) and a safe write path: creating a page or updating a property is a guarded mutation that requires a preview and an explicit confirmation, so the agent can answer questions from Notion without ever silently editing shared content.
    
    It ships `notion-cli`, a small Python script that speaks the Notion API with no third-party dependencies. Reads are capped (`--limit`), output is clean JSON for the agent or readable text for you, and `--help` works with no token and no network. The script sends the standard `Notion-Version` header and summarizes pages as title + ID + URL instead of dumping raw block trees.
    
    ## What You Get
    
    | Directory | Purpose |
    |---|---|
    | `SKILL.md` | Agent-facing operating contract, mutation gates, and verification boundaries |
    | `references/` | Dated source index and an API operations reference (endpoints, pagination, property types, filters, errors) |
    | `scripts/notion-cli` | Bounded, stdlib-only CLI: pages get/create/update, databases query, search; `--json`, `--limit`, mutations gated by `--dry-run`/`--yes` |
    | `tests/` | 13 deterministic tests against a stub Notion API, covering the mutation gate and read-only contract |
    | `evals/evals.json` | Six output-quality evaluation cases for agent runs |
    
    ## Quick Start
    
    ```bash
    # Help works with no token and no network
    notion/scripts/notion-cli --help
    
    # Find a page by text (bounded)
    NOTION_TOKEN=secret_... notion/scripts/notion-cli --json search query --query "on-call runbook"
    
    # Retrieve one page
    NOTION_TOKEN=secret_... notion/scripts/notion-cli --json pages get --page-id <page-id>
    
    # Query a database, capped at 10 rows
    NOTION_TOKEN=secret_... notion/scripts/notion-cli --json --limit 10 databases query --database-id <db-id>
    
    # Update a page property: preview first, then confirm
    printf '{"Status": {"select": {"name": "Done"}}}' > props.json
    NOTION_TOKEN=secret_... notion/scripts/notion-cli pages update --page-id <page-id> --properties props.json --dry-run
    NOTION_TOKEN=secret_... notion/scripts/notion-cli pages update --page-id <page-id> --properties props.json --yes
    ```
    
    ## Triggers
    
    Load this skill for `notion` operations: "what does the runbook say", reading pages, querying a Notion database (rows, filters), searching pages and databases, creating a page row, or updating a page property with confirmation. Do not load it for building Notion integrations or apps, workspace administration, or other knowledge bases like Confluence.
    
    ## Requirements
    
    - Python 3.9+ for `notion-cli` (stdlib only; `--help` needs nothing else).
    - A Notion integration token (`NOTION_TOKEN`, `secret_...`) with the workspace pages/databases you need **shared with the integration**. Search and database access require the corresponding capabilities in the integration settings.
    - Network access to `api.notion.com` for live reads and writes. Optionally set `NOTION_VERSION` to pin the API version (default `2022-06-28`).
    
  • SKILL.md 8.1 KB
    ---
    name: notion
    description: >-
      Operate Notion from a terminal or agent: retrieve pages, query databases,
      search pages and databases, and update page properties — with a bundled
      notion-cli script that is read-only by default and gates every create or
      update behind a --dry-run/--yes confirmation. Use when an agent needs to
      read Notion content, answer questions from a team wiki or database, or make
      a confirmed edit. Do not use for building Notion integrations or
      block-level page composition beyond property updates (that is Notion API
      application development), or for other knowledge bases (that is their own
      tooling).
    license: MIT
    compatibility: >-
      The bundled notion-cli script runs on Python 3.9+ with only the standard
      library. --help and page/database/search reads need no network; live reads
      require a Notion integration token (secret_...) with the right workspace
      capabilities and network access to api.notion.com.
    metadata:
      source: https://developers.notion.com/reference
      source_index: references/00-source-index.md
      research_checked: "2026-08-03"
    ---
    
    # Notion Operations
    
    Use this skill to read and, with explicit confirmation, write Notion content through the Notion API: pages, database queries, search, and page property updates. This is a **tool skill** for the Notion platform. Building Notion integrations, writing complex block compositions, or building an app on the Notion API is application development; this skill owns the everyday agent workflow: finding the right page, answering from a database, and making a confirmed edit.
    
    ## Operating contract
    
    1. **Read-only discovery before any mutation.** Retrieve pages, query databases, and search freely. The bundled `notion-cli` script makes reads without writing anything.
    2. **Confirm the target, scope, and rollback path before acting.** Creating a page or updating properties changes a shared workspace that teammates read. Both require an explicit human directive plus `--dry-run` preview and `--yes` confirmation through `notion-cli`. Property updates overwrite existing values — state the current value and the replacement before confirming.
    3. **Respect bounded reads.** Notion paginates with `page_size` and `has_more`; never page past what the task needs. `notion-cli --limit` caps every search and query.
    4. **Keep evidence bounded.** Quote short page titles, property values, and IDs; never paste full pages, tokens, or raw API payloads into chat.
    5. **Know the API version.** The `Notion-Version` header pins the API contract; reads that work today can change with a version bump. `notion-cli` sends `2022-06-28` by default and honors `NOTION_VERSION`.
    
    ## The notion-cli script
    
    `scripts/notion-cli` is an agent-first, stdlib-only CLI over the Notion API. It covers the full issue scope: pages, databases (query), search, and updates.
    
    ```bash
    notion/scripts/notion-cli --help                           # no token or network needed
    notion/scripts/notion-cli --json pages get --page-id <page>
    notion/scripts/notion-cli --json --limit 10 databases query --database-id <db>
    notion/scripts/notion-cli --json search query --query "on-call runbook"
    notion/scripts/notion-cli pages update --page-id <page> --properties props.json --dry-run
    notion/scripts/notion-cli pages update --page-id <page> --properties props.json --yes
    notion/scripts/notion-cli pages create --parent-database <db> --title "New row" --yes
    ```
    
    Exit codes: 0 success, 1 API error or failed check, 2 usage error. Creates and updates are guarded: without `--dry-run` or `--yes` the script refuses with exit 1 and never calls the API. Reads are bounded by `--limit` (default 20, max 100).
    
    ## Operating loop
    
    1. **Locate the content**: `search query` to find pages and databases by text, or a known ID directly.
    2. **Read with bounds**: `pages get` for a single page, `databases query` for rows in a database (optionally with a JSON `--filter`), always capped by `--limit`.
    3. **Triage the answer**: map the question to evidence (page title + properties, database rows, search results with `has_more` state).
    4. **Act with confirmation**: only a human directive to change, previewed with `--dry-run` and confirmed with `--yes`.
    5. **Verify**: re-read the page (`pages get`) and confirm the property values landed.
    
    ## Pages, databases, search
    
    - **Pages** (`GET /pages/{id}`): a page is an ID, a title (extracted from the `title` or `Name` property), a URL, and timestamps. Property values live under `properties`; the CLI summarizes them rather than dumping the full block tree.
    - **Databases** (`POST /databases/{id}/query`): query rows as pages with a `page_size` cap and an optional structured `--filter` JSON file (e.g. `{"property": "Status", "select": {"equals": "Done"}}`). `has_more` tells you whether the cap hid further rows.
    - **Search** (`POST /search`): finds pages and databases by text across the integration's accessible workspace; results are bounded by `--limit`.
    - **Updates** (`PATCH /pages/{id}`): property updates overwrite values (select, status, checkbox, rich text, etc.). Preview the exact properties payload with `--dry-run` and confirm with `--yes`; verify with a follow-up `pages get`.
    
    ## Integration access model
    
    - Notion integrations authenticate with a bot-style token (`secret_...`) and can only see the pages and databases explicitly **shared with the integration**. A page that exists in the workspace but is not shared returns 404/`object_not_found` — that is an access-model result, not a missing page.
    - The `Notion-Version` header selects the API contract. The CLI defaults to `2022-06-28`; set `NOTION_VERSION` when a workspace or application pins a different version.
    - Tokens are workspace-scoped credentials. Store them in the environment (`NOTION_TOKEN`), never in code, chat, or commits. Revoke a leaked integration token in the Notion integration settings.
    
    ## Reference routing
    
    | Load when | Reference |
    |---|---|
    | Sources, version notes, refresh procedure | `references/00-source-index.md` |
    | API endpoints, pagination, property types, filters, and error handling | `references/01-api-operations.md` |
    
    ## Included artifacts
    
    - `scripts/notion-cli`: bounded, stdlib-only CLI (pages get/create/update, databases query, search; `--json`; `--limit`; mutations gated by `--dry-run`/`--yes`).
    - `tests/test_notion_cli.py`: 13 deterministic tests against a stub Notion API, including the mutation gate and the read-only contract.
    - `references/`: dated source index + API operations reference.
    - `evals/evals.json`: six output-quality evaluation cases for agent runs.
    
    ## Verification boundary
    
    | Claim | Minimum evidence |
    |---|---|
    | A page exists and its title | `notion-cli pages get --page-id ... --json` returns the title and ID |
    | A database query answered the question | `notion-cli databases query --json` returns bounded rows with `has_more` state |
    | Search found the content | `notion-cli search query --json` returns the matching page/database with ID and title |
    | An update landed | `notion-cli pages update --yes` exits 0 and a follow-up `pages get` shows the new property values |
    | A mutation is safe to run | `notion-cli ... --dry-run` prints the exact payload that would be sent |
    
    ## Hard boundaries
    
    - Never create or update a page without a human directive, `--dry-run` preview, and `--yes` confirmation — Notion edits are visible to everyone with access to the page.
    - Never claim a page is missing when it may simply not be shared with the integration; verify the access model first.
    - Never page reads past `--limit`; never dump full pages, tokens, or raw payloads into chat.
    - This skill operates the Notion API. It does not build Notion integrations (application development) or cover other knowledge-base products.
    
    ## When not to use
    
    - **Building Notion integrations or apps** (OAuth flows, custom blocks, public API products, block-tree composition beyond property updates) — that is application development; see [backend-engineering](../backend-engineering/SKILL.md) for service design.
    - **Other knowledge bases and document tools** (Confluence, Google Docs, wikis) — each has its own tooling; this skill covers Notion only.
    - **Workspace administration** (user management, workspace settings, integration approval) — that is the Notion admin console.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related