# 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 1. Go to [Notion Integrations](https://www.notion.so/my-integrations) 2. Click "New integration" 3. Name it (e.g., "Claude Writer") 4. Select workspace 5. Copy the "Internal Integration Token" ### 2. Share Pages/Databases with Integration **Important**: You must share each page or database with your integration: 1. Open the Notion page/database 2. Click "..." menu → "Connections" 3. Add your integration ### 3. Configure Environment ```bash 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 ```bash source venv/bin/activate ``` --- ## Usage ### Test Connection ```bash python notion_writer.py --test ``` ### Get Page/Database Info ```bash # 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 ```bash # 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 ```bash # 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) ```bash # 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 | | `> [!NOTE]` / `[!TIP]` / `[!IMPORTANT]` / `[!WARNING]` / `[!CAUTION]` | Callout (with icon + colored background) | | `
X ...
` | Toggle (multi-line form, recursive children) | | `::: columns / ::: column / :::` | Column list (Pandoc fenced div, ≥2 columns required) | | `` ```code``` `` | Code block | | `---` | Divider | | Tables (`\| col \|`) | Table | | Paragraphs | Paragraph | ### Code Block Languages Specify language after opening backticks: ```markdown ```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) | | `@[Title](page-id-or-notion-url)` | page mention (resolves via Notion URL or raw 32-hex ID) | 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. ### File uploads Standalone local-image lines (`![alt](./image.png)`) are auto-uploaded to Notion and embedded as `file_upload` image blocks. Remote images (`![](https://...)`) are left as external links unchanged. **Requirements:** - `ntn` CLI installed: `curl -fsSL https://ntn.dev | bash` - Logged in: `ntn login` Uploads are workspace-scoped — the file lands in whichever workspace the `ntn` CLI is authenticated to. Ensure this matches the workspace the Notion API token targets, or the image block will not attach. ### Engines Two write engines, selected with `--engine {blocks,markdown}`. Default is `blocks`. | Engine | Flag | When to use | |--------|------|-------------| | **blocks** (default) | `--engine blocks` | General use; images embed at their exact position; full table + container support | | **markdown** | `--engine markdown` | Richer Notion-native formatting via enhanced-markdown endpoints | **blocks engine** converts markdown to Notion block objects locally (via `markdown_to_notion_blocks`). Local images are uploaded via `ntn` and embedded at their exact position in the document. **markdown engine** posts the document through Notion's native enhanced-markdown endpoints (`Notion-Version: 2026-03-11`, set automatically). The skill's authoring dialect is auto-translated before posting: | Skill dialect | Translated to | |---------------|---------------| | `> [!NOTE]` / `[!TIP]` / `[!IMPORTANT]` / `[!WARNING]` / `[!CAUTION]` | `` | | `::: columns` / `::: column` / `:::` | `...` | | `
...` | `
` (Notion toggle) | | `@[Title](id-or-url)` | `` | **Local image limitation with `--engine markdown`**: local images cannot be placed inline via the enhanced-markdown API. They are appended at the end of the page as a second write pass. Use `--engine blocks` when image placement matters. **`--notion-version`**: Override the API version for the markdown engine (default: `2026-03-11`). **`--allow-deleting-content`**: Required when `--replace` needs to delete child pages or databases under the target page; the Notion API refuses such deletions unless this flag is present. Applies only to the markdown engine's `--replace` path (`--engine markdown --replace`); has no effect with `--engine blocks`. ```bash # Markdown engine, create a row from a doc with callouts/columns python notion_writer.py -d DB_URL -t "Notes" --engine markdown -f notes.md # Blocks engine with a local image (auto-uploaded via ntn) python notion_writer.py -p PAGE_URL -f post.md # post.md contains ![chart](./chart.png) # Markdown engine, replace page allowing child deletion python notion_writer.py -p PAGE_URL -f doc.md --replace --engine markdown --allow-deleting-content ``` --- ## 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 ```bash python notion_writer.py \ --database "https://notion.so/Meeting-Notes-abc123" \ --title "Weekly Standup - Dec 26" \ --file meeting_notes.md ``` ### Callouts (GitHub alerts) ````markdown > [!NOTE] > Just FYI: this method is idempotent. > [!WARNING] > Don't run this in production without a backup. ```` Renders as Notion callout blocks with corresponding emoji icon and colored background. ### Toggles (HTML5 `
`) ````markdown
Click to expand: full debug log ```bash $ python notion_writer.py --test ✅ Connected ``` Lists, code blocks, and even nested `
` work inside.
```` Multi-line form required: `
` and `
` must be on their own lines. ### Columns (Pandoc fenced div) ````markdown ::: columns ::: column **Column 1** - item a - item b ::: ::: column **Column 2** ```python print("hello") ``` ::: ::: ```` ≥2 columns required by Notion. Single-column blocks degrade to plain paragraphs. ### Page mentions ```markdown See @[Architecture Decision Record](https://notion.so/ADR-abcdef0123456789abcdef0123456789) for context. ``` Both Notion URLs and raw 32-hex IDs work. Invalid targets fall back to plain text `@Title`. ### Pipe from Another Tool ```bash # 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: ```bash # 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_KEY` in `.env` - Token should start with `secret_` (internal integration token) ### "Database has multiple data sources" - Pass a specific `data_source_id` instead 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 `--properties` JSON ### "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 ```bash # 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.3.0 | Claude Code | 2026-06-27* Changelog: - 1.3.0 — Local file/image uploads via the `ntn` CLI (`![](local)` → file_upload image blocks). New `--engine markdown` path writing through Notion's native enhanced-markdown endpoints with a dialect translator. Added `--notion-version` and `--allow-deleting-content`. - 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `
` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks. - 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages. - 1.0.0 — Initial release with markdown→Notion block conversion.