agentleFS
amirhosseinNouriamirhosseinNouri/skills/notion-api-macos-keychain/SKILL.md

notion-api-macos-keychain

Skill1 starsChanged 2 months agoMITView on GitHub

This skill provides comprehensive instructions for interacting with the Notion API via REST calls. This skill should be used whenever the user asks to interact with Notion, including reading, creating, updating, or deleting pages, databases, blocks, comments, or any other Notion content. The skill covers authentication, all available endpoints, pagination, error handling, and best practices. Stores the Notion API token in the macOS Keychain under service `notion-api-token` and authenticates with a Bearer header against `https://api.notion.com`.

  • Reads credentials
  • Sends data out

The file

Show its rules and text

We split this skill into the rules it gives an agent

17 rules. About 0 are common, 9 are distinctive, and 8 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈9

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 8

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    IMPORTANT: Never display, log, or send the token anywhere except in the Authorization header.

    line 37
  2. Distinctive:

    Check the existing token first:

    line 53
  3. Distinctive:

    If it is missing or invalid, ask the user for a Notion integration token, then store and validate clipboard contents without putting the …

    line 60
  4. Distinctive:

    Do not print the token or enable shell tracing while the helper is active.

    line 108
  5. Very specific:

    Empty Values: Use null instead of empty strings

    line 130
  6. Distinctive:

    IMPORTANT: Before executing any operation that modifies or deletes data, ask the user for confirmation.

    line 157
  7. Distinctive:

    Search across all accessible pages and databases:

    line 170
  8. Very specific:

    Use the page ID as block_id to get page content.

    line 264
  9. Distinctive:

    Default: appends to end

    line 294
  10. Very specific:

    Use a page ID as block_id for page-level comments.

    line 440
  11. Very specific:

    Check has_more in the response

    line 478
  12. Very specific:

    If true, extract next_cursor and include it as start_cursor in the next request

    line 479
  13. Distinctive:

    Store IDs: When creating pages/databases, store the returned IDs for future updates

    line 522
  14. Very specific:

    Check has_more: Always handle pagination for list endpoints

    line 526
  15. Very specific:

    Use Keychain: Never hardcode API keys; read the token via scripts/keychain.sh

    line 528
  16. Distinctive:

    Handle Errors Gracefully: Check response status codes and error messages

    line 529
  17. Distinctive:

    Schema Size: Keep database schemas under 50KB for optimal performance

    line 530

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Notion API Skill

This skill enables interaction with Notion workspaces through the Notion REST API. Use the notion_curl helper for direct REST calls, or write ad-hoc scripts as appropriate for the task.

ConcernNotion
AuthenticationAuthorization: Bearer <integration token>
Base URLhttps://api.notion.com
API version2025-09-03 (required Notion-Version header)
Token storagemacOS Keychain service notion-api-token

The skill is self-contained: scripts/keychain.sh holds the Keychain read/write helpers, so nothing else has to be installed.

Authentication

API Key Handling

The token is stored in the macOS Keychain, never in the environment or the repository.

  1. Check the stored token — run scripts/check_token.sh first
  2. Store a token — pbpaste | scripts/store_token.sh (validates against GET /v1/users/me before writing, so a bad token cannot overwrite a working one)
  3. Missing/Invalid — if no valid token is in Keychain, ask the user for a Notion integration token (from Notion → Settings → Connections → Develop or manage integrations)

IMPORTANT: Never display, log, or send the token anywhere except in the Authorization header. Confirm its existence, ask if missing, use it in requests—but never echo or expose it.

Configuration

Defaults:

NOTION_URL=https://api.notion.com
NOTION_VERSION=2025-09-03
KEYCHAIN_ACCOUNT=<current macOS username>

Environment variables may override these values. NOTION_ENV_FILE may point to a local .env file containing only non-secret settings. Never put the token in .env or a repository.

Authentication setup

Check the existing token first:

notion_skill_dir="<directory containing this SKILL.md>"
"$notion_skill_dir/scripts/check_token.sh"

If it is missing or invalid, ask the user for a Notion integration token, then store and validate clipboard contents without putting the literal token in shell history:

pbpaste | "$notion_skill_dir/scripts/store_token.sh"

Removing the token is destructive and unrecoverable. Confirm the exact service with the user first, then:

security delete-generic-password -s "notion-api-token" -a "$(id -un)"

Authenticated request helper

Define this helper once per shell session. It injects the Authorization and Notion-Version headers from Keychain, so request examples below never need to repeat them:

notion_curl() {
  local env_file="${NOTION_ENV_FILE:-}"
  if [[ -n "$env_file" ]]; then
    # shellcheck disable=SC1090
    set -a; . "$env_file"; set +a
  fi

  local notion_version="${NOTION_VERSION:-2025-09-03}"
  local notion_skill_dir="<directory containing this SKILL.md>"
  # shellcheck source=scripts/keychain.sh
  source "$notion_skill_dir/scripts/keychain.sh"
  local account="${KEYCHAIN_ACCOUNT:-$(id -un)}"
  local token
  token=$(keychain_get "notion-api-token" "$account") || {
    echo "No Notion token in Keychain. Run scripts/store_token.sh first." >&2
    return 1
  }

  curl -sS \
    -H "Authorization: Bearer $token" \
    -H "Notion-Version: $notion_version" \
    "$@"
}

Validate the session:

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/users/me" | jq '{name, bot: .bot.workspace_name}'

Do not print the token or enable shell tracing while the helper is active.

Request Headers

The notion_curl helper adds all required headers automatically. A raw request looks like:

-H "Authorization: Bearer $token" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json"

For raw calls (e.g. in scripts that read the token via keychain_get), include all three.

Base URL and Conventions

  • Base URL: https://api.notion.com
  • API Version: 2025-09-03 (required header)
  • Data Format: JSON for all request/response bodies
  • IDs: UUIDv4 format (dashes optional in requests)
  • Timestamps: ISO 8601 format (2020-08-12T02:12:33.231Z)
  • Property Names: snake_case
  • Empty Values: Use null instead of empty strings

Rate Limits

  • Average: 3 requests per second per integration
  • Bursts: Brief bursts above this limit are allowed
  • Rate Limited Response: HTTP 429 with Retry-After header
  • Strategy: Implement exponential backoff when receiving 429 responses

Request Size Limits

TypeLimit
Maximum block elements per payload1000
Maximum payload size500KB
Rich text content2000 characters
URLs2000 characters
Equations1000 characters
Email addresses200 characters
Phone numbers200 characters
Multi-select options100 items
Relations100 related pages
People mentions100 users
Block arrays per request100 elements

Confirmation for Destructive Operations

IMPORTANT: Before executing any operation that modifies or deletes data, ask the user for confirmation. This includes:

  • Updating pages or blocks
  • Deleting/archiving pages or blocks
  • Modifying database schemas
  • Creating pages (if multiple or in batch)
  • Any bulk operations

For a logical group of related operations, a single confirmation is sufficient.

Core API Endpoints

Search

Search across all accessible pages and databases:

notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/search" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "search term",
    "filter": {"property": "object", "value": "page"},
    "sort": {"direction": "descending", "timestamp": "last_edited_time"},
    "page_size": 100
  }' | jq

Filter values: "page" or "data_source" (or omit for both)

Pages

Retrieve a Page

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/pages/{page_id}" | jq

Note: This returns page properties, not content. For content, use "Retrieve block children" with the page ID.

Create a Page

notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/pages" \
  -H "Content-Type: application/json" \
  -d '{
    "parent": {"page_id": "parent-page-id"},
    "properties": {
      "title": {
        "title": [{"text": {"content": "Page Title"}}]
      }
    },
    "children": [
      {
        "object": "block",
        "type": "paragraph",
        "paragraph": {
          "rich_text": [{"type": "text", "text": {"content": "Paragraph content"}}]
        }
      }
    ]
  }' | jq

Parent options:

  • {"page_id": "..."} - Create under a page
  • {"database_id": "..."} - Create in a database (legacy)
  • {"data_source_id": "..."} - Create in a data source (API v2025-09-03+)

Update a Page

notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/pages/{page_id}" \
  -H "Content-Type: application/json" \
  -d '{
    "properties": {
      "title": {"title": [{"text": {"content": "Updated Title"}}]}
    },
    "icon": {"type": "emoji", "emoji": "📝"},
    "archived": false
  }' | jq

Additional update options: cover, is_locked, in_trash

Archive (Delete) a Page

notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/pages/{page_id}" \
  -H "Content-Type: application/json" \
  -d '{"archived": true}' | jq

Retrieve a Page Property Item

For properties with more than 25 references:

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/pages/{page_id}/properties/{property_id}" | jq

Blocks (Page Content)

Retrieve Block Children

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}/children?page_size=100" | jq

Use the page ID as block_id to get page content. Check has_children on each block for nested content.

Append Block Children

notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}/children" \
  -H "Content-Type: application/json" \
  -d '{
    "children": [
      {
        "object": "block",
        "type": "heading_2",
        "heading_2": {
          "rich_text": [{"type": "text", "text": {"content": "New Section"}}]
        }
      },
      {
        "object": "block",
        "type": "paragraph",
        "paragraph": {
          "rich_text": [{"type": "text", "text": {"content": "Content here"}}]
        }
      }
    ]
  }' | jq

Maximum 100 blocks per request, up to 2 levels of nesting.

Position options in request body:

  • Default: appends to end
  • "position": {"type": "start"} - Insert at beginning
  • "position": {"type": "after_block", "after_block": {"id": "block-id"}} - Insert after specific block

Retrieve a Block

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}" | jq

Update a Block

notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}" \
  -H "Content-Type: application/json" \
  -d '{
    "paragraph": {
      "rich_text": [{"type": "text", "text": {"content": "Updated content"}}]
    }
  }' | jq

The update replaces the entire value for the specified field.

Delete a Block

notion_curl -X DELETE "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}" | jq

Moves block to trash (can be restored).

Databases

Retrieve a Database

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/databases/{database_id}" | jq

Returns database structure including data sources and properties.

Query a Database

notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/databases/{database_id}/query" \
  -H "Content-Type: application/json" \
  -d '{
    "filter": {
      "property": "Status",
      "select": {"equals": "Done"}
    },
    "sorts": [
      {"property": "Created", "direction": "descending"}
    ],
    "page_size": 100
  }' | jq

See references/filters-and-sorts.md for comprehensive filter and sort documentation.

Create a Database

notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/databases" \
  -H "Content-Type: application/json" \
  -d '{
    "parent": {"page_id": "parent-page-id"},
    "title": [{"type": "text", "text": {"content": "My Database"}}],
    "is_inline": true,
    "initial_data_source": {
      "properties": {
        "Name": {"title": {}},
        "Status": {
          "select": {
            "options": [
              {"name": "To Do", "color": "red"},
              {"name": "In Progress", "color": "yellow"},
              {"name": "Done", "color": "green"}
            ]
          }
        },
        "Due Date": {"date": {}}
      }
    }
  }' | jq

Update a Database

notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/databases/{database_id}" \
  -H "Content-Type: application/json" \
  -d '{
    "title": [{"text": {"content": "Updated Title"}}],
    "description": [{"text": {"content": "Database description"}}]
  }' | jq

Data Sources (API v2025-09-03+)

Data sources are individual tables within a database. As of API version 2025-09-03, databases can contain multiple data sources.

Create a Data Source

notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/data_sources" \
  -H "Content-Type: application/json" \
  -d '{
    "parent": {"type": "database_id", "database_id": "database-id"},
    "title": [{"type": "text", "text": {"content": "New Data Source"}}],
    "properties": {
      "Name": {"title": {}},
      "Description": {"rich_text": {}}
    }
  }' | jq

Users

List All Users

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/users?page_size=100" | jq

Retrieve a User

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/users/{user_id}" | jq

Retrieve Bot User (Self)

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/users/me" | jq

Comments

Retrieve Comments

notion_curl "${NOTION_URL:-https://api.notion.com}/v1/comments?block_id={block_id}&page_size=100" | jq

Use a page ID as block_id for page-level comments.

Create a Comment

On a page:

notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/comments" \
  -H "Content-Type: application/json" \
  -d '{
    "parent": {"page_id": "page-id"},
    "rich_text": [{"type": "text", "text": {"content": "Comment content"}}]
  }' | jq

Reply to a discussion:

notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/comments" \
  -H "Content-Type: application/json" \
  -d '{
    "discussion_id": "discussion-id",
    "rich_text": [{"type": "text", "text": {"content": "Reply content"}}]
  }' | jq

Note: The API cannot start new inline discussion threads or edit/delete existing comments.

Pagination

Paginated endpoints return:

  • has_more: Boolean indicating more results exist
  • next_cursor: Cursor for the next page
  • results: Array of items

To iterate through all results:

  1. Make the initial request (omit start_cursor)
  2. Check has_more in the response
  3. If true, extract next_cursor and include it as start_cursor in the next request
  4. Repeat until has_more is false

Example request with cursor:

{
  "page_size": 100,
  "start_cursor": "v1%7C..."
}

Error Handling

HTTP StatusCodeDescription
400invalid_jsonRequest body is not valid JSON
400invalid_request_urlURL is malformed
400invalid_requestRequest is not supported
400validation_errorRequest body doesn't match expected schema
400missing_versionMissing Notion-Version header
401unauthorizedInvalid bearer token
403restricted_resourceToken lacks permission
404object_not_foundResource doesn't exist or not shared with integration
409conflict_errorData collision during transaction
429rate_limitedRate limit exceeded (check Retry-After header)
500internal_server_errorUnexpected server error
503service_unavailableNotion unavailable or 60s timeout exceeded
503database_connection_unavailableDatabase unresponsive
504gateway_timeoutRequest timeout

Troubleshooting

SymptomLikely causeAction
No Keychain entryToken was never stored for this accountRun store_token.sh
401 unauthorizedMissing, expired, revoked, or mistyped tokenStore a fresh integration token
403 restricted_resourceToken lacks access to the resourceShare the page/database with the integration
404 object_not_foundWrong ID or resource not shared with integrationVerify IDs and sharing
400 missing_versionNotion-Version header missingUse the notion_curl helper

Best Practices

  1. Store IDs: When creating pages/databases, store the returned IDs for future updates
  2. Use Property IDs: Reference properties by ID rather than name for stability
  3. Batch Operations: Aggregate multiple small operations into fewer requests
  4. Respect Rate Limits: Implement exponential backoff for 429 responses
  5. Check has_more: Always handle pagination for list endpoints
  6. Validate Before Updates: Retrieve current state before making updates
  7. Use Keychain: Never hardcode API keys; read the token via scripts/keychain.sh
  8. Handle Errors Gracefully: Check response status codes and error messages
  9. Schema Size: Keep database schemas under 50KB for optimal performance
  10. Properties Limit: Properties with >25 page references require separate retrieval

References

For detailed documentation on specific topics, see:

  • references/block-types.md - All supported block types and their structures
  • references/property-types.md - Database property types and value formats
  • references/filters-and-sorts.md - Database query filter and sort syntax
  • references/rich-text.md - Rich text object structure and annotations

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.