Files
our-claude-skills/custom-skills/32-notion-writer/code/CLAUDE.md
2026-06-27 10:12:58 +09:00

14 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

  1. Go to Notion 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

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
> [!NOTE] / [!TIP] / [!IMPORTANT] / [!WARNING] / [!CAUTION] Callout (with icon + colored background)
<details><summary>X</summary> ... </details> 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:

```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]` | `<callout icon="..." color="...">` |
| `::: columns` / `::: column` / `:::` | `<columns><column>...</column></columns>` |
| `<details><summary>...</summary>` | `<details>` (Notion toggle) |
| `@[Title](id-or-url)` | `<mention-page 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.

```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

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

Callouts (GitHub alerts)

> [!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 <details>)

<details>
<summary>Click to expand: full debug log</summary>

```bash
$ python notion_writer.py --test
✅ Connected
```

Lists, code blocks, and even nested `<details>` work inside.
</details>

Multi-line form required: <details> and </details> must be on their own lines.

Columns (Pandoc fenced div)

::: 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

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

# 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_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

# 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 <details> 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.