Phase 1 — docs alignment: - Fix path drift in 32 CLAUDE.md (02-notion-writer → 32-notion-writer) - Align env var to NOTION_API_KEY in 31 docs (NOTION_TOKEN still accepted) - Document Phase 3 roadmap in 31 (metadata-aware migration, Notion-as-RAG) Phase 2 — multi-source database support: - New 32/scripts/_notion_compat.py: client factory, data_source resolution with cache, property coercion, find_existing_page (for upsert), explain_api_error for friendlier failure messages - 32 notion_writer.py: drop the 2022-06-28 pin, route schema introspection through data_sources.retrieve, build data_source_id parent shape, accept arbitrary properties via --properties JSON-or-file flag, add --upsert-by for idempotent re-runs - 32 markdown parser: anchor-link fix — Notion's URL validator rejects fragment URLs and relative paths; fragments now render as bold to preserve TOC nav intent, relatives drop the link, absolute URLs preserved - 31 async_organizer.py + schema_migrator.py: same data_sources migration with cached resolution; pages.create now uses data_source_id parent - 16/16 parser tests pass (was 13; +3 link-handling regression guards) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.2 KiB
Notion Writer - Claude Code Skill
Purpose: Push markdown content to Notion pages or databases Platform: Claude Code (CLI) Input: Markdown files, Notion URLs Output: Content written to Notion
Capabilities
| Feature | Input | Output |
|---|---|---|
| Page Content Append | Markdown + Page URL | Appended blocks |
| Page Content Replace | Markdown + Page URL | Replaced content |
| Database Row Create | Markdown + DB URL + Title | New database row |
| Connection Test | API token | Connection status |
| Page/DB Info | URL | Metadata |
Setup
1. Create Notion Integration
- Go to Notion Integrations
- Click "New integration"
- Name it (e.g., "Claude Writer")
- Select workspace
- Copy the "Internal Integration Token"
2. Share Pages/Databases with Integration
Important: You must share each page or database with your integration:
- Open the Notion page/database
- Click "..." menu → "Connections"
- Add your integration
3. Configure Environment
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
# Create .env from example
cp .env.example .env
# Edit and add your token
nano .env
.env file:
NOTION_API_KEY=secret_your_integration_token
4. Activate Environment
source venv/bin/activate
Usage
Test Connection
python notion_writer.py --test
Get Page/Database Info
# Page info
python notion_writer.py --page "https://notion.so/My-Page-abc123" --info
# Database info
python notion_writer.py --database "https://notion.so/abc123" --info
Write to Page
# Append content to page
python notion_writer.py --page PAGE_URL --file content.md
# Replace page content
python notion_writer.py --page PAGE_URL --file content.md --replace
# From stdin
cat report.md | python notion_writer.py --page PAGE_URL --stdin
Create Database Row
# Create row with title and content
python notion_writer.py --database DB_URL --title "New Entry" --file content.md
# Title only
python notion_writer.py --database DB_URL --title "Empty Entry"
# Set arbitrary properties (JSON literal)
python notion_writer.py \
--database DB_URL \
--title "Research Notes" \
--properties '{"Status": "In progress", "Topic": ["AI", "MCP"], "Account Code": "OurDigital"}' \
--file notes.md
# Set properties from a JSON file
python notion_writer.py \
--database DB_URL \
--title "Research Notes" \
--properties ./props.json \
--file notes.md
--database accepts a database URL, a database_id, or a data_source_id. It is resolved to a data_source_id automatically.
Upsert (avoid duplicates)
# If a row with Name="My Title" already exists, update it; otherwise create.
python notion_writer.py \
--database DB_URL \
--title "My Title" \
--upsert-by "Name" \
--properties '{"Status": "Done"}' \
--file content.md
When upserting, --file (or --stdin) replaces the page's body blocks; --properties updates the named columns. --upsert-by supports title, rich_text, select, status, number, url, email, and phone_number properties.
Two-tool split: when to use what
| Goal | Tool | Why |
|---|---|---|
| Push markdown content into a page or new DB row | notion_writer.py |
Converts markdown → Notion blocks (tables, headings, code, lists, rich-text, anchors) |
| Create a DB row with both content and metadata in one shot | notion_writer.py with --properties |
Single API call, full schema validation |
| Update properties on many existing pages (bulk column edit) | MCP notion-update-page |
Native schema awareness, no markdown work needed |
| Search / move pages with metadata transition | 31-notion-organizer (Phase 3 work) | Cross-database operations |
Markdown Support
Supported Elements
| Markdown | Notion Block |
|---|---|
# Heading |
Heading 1 |
## Heading |
Heading 2 |
### Heading |
Heading 3 |
- item |
Bulleted list |
1. item |
Numbered list |
- [ ] task |
To-do (unchecked) |
- [x] task |
To-do (checked) |
> quote |
Quote |
```code``` |
Code block |
--- |
Divider |
| Paragraphs | Paragraph |
Code Block Languages
Specify language after opening backticks:
```python
print("Hello")
### Inline rich-text
| Markdown | Result |
|----------|--------|
| `**bold**` or `__bold__` | bold |
| `*italic*` or `_italic_` | italic |
| `` `code` `` | inline code |
| `~~strike~~` | strikethrough |
| `[text](https://...)` | link (absolute URLs only) |
| `[text](#anchor)` | bold (Notion rejects fragment URLs) |
| `[text](relative/path.md)` | plain text (Notion rejects relative URLs) |
Notion's URL validator requires absolute URLs for link annotations. The parser converts TOC-style anchor links to bold to preserve navigation intent and silently strips relative paths.
---
## Examples
### Push SEO Audit Report
```bash
python notion_writer.py \
--page "https://notion.so/SEO-Reports-abc123" \
--file ~/reports/seo_audit_2025.md
Create Meeting Notes Entry
python notion_writer.py \
--database "https://notion.so/Meeting-Notes-abc123" \
--title "Weekly Standup - Dec 26" \
--file meeting_notes.md
Pipe from Another Tool
# Pipe YouTube video info to Notion
python jamie_video_info.py VIDEO_ID --json | \
python notion_writer.py --page PAGE_URL --stdin
Test against the "Working with AI" database
The user's primary export target is the AI research database (data_source_id f8f19ede-32bd-43ac-9f60-0651f6f40afe). End-to-end check:
# 1) Sanity: connection works
python notion_writer.py --test
# 2) Inspect the schema (confirms data_source resolution)
python notion_writer.py \
--database f8f19ede-32bd-43ac-9f60-0651f6f40afe \
--info
# 3) Upsert a test row with full metadata
python notion_writer.py \
--database f8f19ede-32bd-43ac-9f60-0651f6f40afe \
--title "[smoke test] notion_writer v3 migration" \
--upsert-by "Name" \
--properties '{
"Status": "Done",
"AI used": ["Claude Code"],
"Topic": ["AI"],
"Category": ["Research"],
"Account Code": "OurDigital",
"Type": "Memo",
"AI summary": "Verifying the data_sources API migration end-to-end."
}' \
--stdin <<< "## Smoke test
Migration verified — properties + content land in one shot."
Re-running step 3 should update the existing row instead of creating a duplicate (upsert).
API Limits
| Limit | Value |
|---|---|
| Blocks per request | 100 |
| Text content per block | 2,000 chars |
| Requests per second | ~3 |
The script automatically batches large content.
Troubleshooting
"Notion object not found"
- Ensure the page or database is shared with your integration (Notion → "..." → Connections → add integration)
- Check URL/ID is correct
- For databases, the integration must have access to each data source under it
"Unauthorized"
- Verify
NOTION_API_KEYin.env - Token should start with
secret_(internal integration token)
"Database has multiple data sources"
- Pass a specific
data_source_idinstead of the database URL/id - List data sources via the Notion API or open the data source individually in Notion to grab its ID
"Property 'X' has unsupported type"
- The script handles common property types but skips computed/read-only ones (formula, rollup, created_by, last_edited_by, last_edited_time, unique_id)
- Drop the unsupported key from
--propertiesJSON
"Rate limited"
- Wait and retry
- Script handles batching but rapid calls may hit limits
File Structure
32-notion-writer/
├── code/
│ ├── CLAUDE.md # This skill document
│ ├── scripts/
│ │ ├── notion_writer.py # Main script
│ │ ├── _notion_compat.py # API compat helper (data_sources resolution)
│ │ ├── test_parser.py # Markdown parser tests
│ │ ├── venv/ # Python environment
│ │ ├── .env # API token (not committed)
│ │ └── .env.example # Template
│ ├── output/ # For generated content
│ └── references/
└── desktop/ # Claude Desktop version (future)
Quick Reference
# Navigate
cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
source venv/bin/activate
# Test
python notion_writer.py --test
# Write to page
python notion_writer.py -p PAGE_URL -f content.md
# Replace content
python notion_writer.py -p PAGE_URL -f content.md -r
# Create DB row
python notion_writer.py -d DB_URL -t "Title" -f content.md
# Create DB row with full metadata
python notion_writer.py -d DB_URL -t "Title" --properties '{"Status": "Done"}' -f content.md
# Upsert (avoid duplicates)
python notion_writer.py -d DB_URL -t "Title" --upsert-by "Name" -f content.md
Version 1.1.0 | Claude Code | 2026-04-27
Changelog:
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added
--propertiesJSON flag,--upsert-byfor idempotency, anchor-link parser fix, friendlier API error messages. - 1.0.0 — Initial release with markdown→Notion block conversion.