455 lines
14 KiB
Markdown
455 lines
14 KiB
Markdown
# 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) |
|
|
| `<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:
|
|
```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 (``) are auto-uploaded to Notion and embedded as `file_upload` image blocks. Remote images (``) 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 
|
|
|
|
# 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 `<details>`)
|
|
|
|
````markdown
|
|
<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)
|
|
|
|
````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 (`` → 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.
|