Notion Link Triage
Auto-titles and classifies untitled links in the user's Notion link-inbox database, then surfaces high-signal entries as Linear-issue suggestions.
When to use
- User says "triage my notion links", "process my notion inbox", "inbox-zero notion"
- User pastes a Notion database URL (
?v= in URL = database view) and says "look at these"
- A new link was added to the inbox and the user wants it auto-actioned
- User wants to dedupe a Notion link database
What this does
For every row in the Notion DB:
- Auto-title — if the title is blank or equals the URL:
- X / Twitter URLs:
publish.twitter.com/oembed (no auth needed)
- Other URLs: scrapes
og:title then <title> tag
- Auto-tag — multi-select tags:
ai, coding, tool, aiva, va, marketing, tax, nonprofit, design, personal-finance, event, shopping, startup, meme, x-post, ig-post, github, video, meta-ad
- Dedupe detection — strips
utm_*, fbclid, s=12, etc. and groups by canonical URL
- Linear suggestions — appends a JSONL row to
~/tools/notion-link-triage/suggestions.jsonl for any entry that hits a high-signal tag (tool, aiva, coding, ai, marketing, tax, nonprofit)
Source database
Common invocations
# Dry-run preview on first 10 untitled rows (no writes)
python3 ~/tools/notion-link-triage/triage.py --dry-run --limit 10
# Backfill: title + tag every untitled row (default behavior)
python3 ~/tools/notion-link-triage/triage.py
# Process only links added since a date (good for daily incremental)
python3 ~/tools/notion-link-triage/triage.py --since 2026-04-25
# Force-reprocess a single row by page_id
python3 ~/tools/notion-link-triage/triage.py --row <page_id> --force
# Show duplicate groups, no writes
python3 ~/tools/notion-link-triage/triage.py --show-dupes
Workflow: full inbox-zero pass
- Show current state:
python3 ~/tools/notion-link-triage/triage.py --show-dupes
- Dry-run sanity check on 10 rows → eyeball the output
- Run the full backfill — takes ~2-3 min for ~280 rows (0.4s sleep between calls)
- Review
~/tools/notion-link-triage/suggestions.jsonl — each line is a candidate for Linear
- Group suggestions by tag → propose Linear issues to the user → batch-create via
mcp__claude_ai_Linear__save_issue
Workflow: action a single new link
When user pastes a Notion link URL or adds a new entry:
- Find the page_id (it's the last URL segment after the title slug, with dashes)
python3 ~/tools/notion-link-triage/triage.py --row <page_id>
- Read
suggestions.jsonl tail to see the classification
- If it hit a high-signal tag, propose the Linear issue title + project + priority
Tag → Linear project mapping
| Tag |
Suggested Linear team / project |
coding, tool, ai, github |
Personal → Coding Work |
aiva, va, marketing |
AIVA → Marketing |
tax, nonprofit, legal |
AIVA → Admin Work (or Personal → Admin Work) |
design |
Personal → Coding Work (UI inspiration) |
event, shopping, meme, personal-finance |
Personal → Personal Recurring (or skip) |
Rule: never auto-create Linear issues. Always show the user the proposed title + tags + project, then create only the approved ones.
Daily cron (optional)
If user wants this automated, add a launchd plist on the Mac OR a systemd timer on the VPS that runs the --since "$(date -v-2d +%Y-%m-%d)" pass nightly. Per the user's LLM-vs-Shell rule this is pure shell — use systemd timer on VPS, not openclaw cron. Pattern:
[Unit]
Description=Notion link triage incremental
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 $HOME/notion-link-triage/triage.py --since YYYY-MM-DD
[Install]
Plus a Composio Gmail alert on failure.
Composio actions used
NOTION_QUERY_DATABASE — paginated fetch of all rows
NOTION_FETCH_ROW — verify a single row after update
NOTION_UPDATE_PAGE — write Name (title) + Tags (multi_select) properties
No new OAuth setup — uses the existing Composio Notion connection.
Limitations / known gotchas
- IG posts: Instagram returns a login wall to non-authed clients, so
og:title scrape often fails. Title falls back to URL.
- X cookies stale (
~/.cookies/x.com): we sidestep this with oEmbed — no auth needed for public tweets.
- Rate limit: 0.4s sleep between rows. ~280 rows → ~2 min. Bump up if oEmbed starts 429-ing (it hasn't yet).
- Duplicate detection is by canonical URL only — doesn't compare titles/content. A tweet quoted in two different X URLs won't merge.
- Tag rules are keyword-based, not semantic. Iterate
TAG_RULES in triage.py to improve.
Quick sanity check before reporting done
# Did the title and tags actually persist?
node ~/tools/<your-mcp>/client.cjs execute NOTION_FETCH_ROW '{"page_id":"<id>"}' \
| grep -E "plain_text|multi_select"
Source files
~/tools/notion-link-triage/triage.py — the script
~/tools/notion-link-triage/suggestions.jsonl — append-only Linear-issue candidates
- This skill:
~/.claude/skills/notion-link-triage/SKILL.md
Ground-truth gate (MANDATORY)
Before this skill asserts a stakes-bearing fact or takes any outward/irreversible action, apply the global standard — verify against a primary source fetched now, never a cached/remembered value. Full standard: ~/.claude/skills/shared/ground-truth-standard.md.
Verify live before you act or assert (this skill):
- Read the live Notion state before modifying; confirm the target page/block; explicit approval for destructive edits.
Then: dry-run where possible, show the user exactly what will be sent/filed/asserted, get explicit chat approval for any outward action (per CLAUDE.md), capture the confirmation, and write any verified fact back into its source doc. State uncertainty as uncertainty; never assert plausible-but-unverified as fact.
1---2name: notion-link-triage3description: Auto-fetch titles, classify, and dedupe links saved to the user's Notion link-inbox database. Use when the user asks to triage their Notion links, inbox-zero a Notion link database, process saved tweets/posts/articles, or auto-action a Notion page they pasted. Triggers on "triage notion links", "inbox-zero notion", "process notion links", "dedupe notion", or pasting a Notion DB URL with v= query param.4---56# Notion Link Triage78Auto-titles and classifies untitled links in the user's Notion link-inbox database, then surfaces high-signal entries as Linear-issue suggestions.910## When to use1112- User says "triage my notion links", "process my notion inbox", "inbox-zero notion"13- User pastes a Notion database URL (`?v=` in URL = database view) and says "look at these"14- A new link was added to the inbox and the user wants it auto-actioned15- User wants to dedupe a Notion link database1617## What this does1819For every row in the Notion DB:20211. **Auto-title** — if the title is blank or equals the URL:22 - X / Twitter URLs: `publish.twitter.com/oembed` (no auth needed)23 - Other URLs: scrapes `og:title` then `<title>` tag242. **Auto-tag** — multi-select tags: `ai`, `coding`, `tool`, `aiva`, `va`, `marketing`, `tax`, `nonprofit`, `design`, `personal-finance`, `event`, `shopping`, `startup`, `meme`, `x-post`, `ig-post`, `github`, `video`, `meta-ad`253. **Dedupe detection** — strips `utm_*`, `fbclid`, `s=12`, etc. and groups by canonical URL264. **Linear suggestions** — appends a JSONL row to `~/tools/notion-link-triage/suggestions.jsonl` for any entry that hits a high-signal tag (`tool`, `aiva`, `coding`, `ai`, `marketing`, `tax`, `nonprofit`)2728## Source database2930- DB ID: `1c207f660378818c81a1cfbd2323746b`31- URL: <https://www.notion.so/1c207f660378818c81a1cfbd2323746b?v=1c207f66037881e990ae000c5c56cbd8>32- Schema: `Name` (title), `URL`, `Tags` (multi_select), `Date`, `Created`33- Auth: Composio Notion (already connected, see `node ~/tools/<your-mcp>/client.cjs status`)3435## Common invocations3637```bash38# Dry-run preview on first 10 untitled rows (no writes)39python3 ~/tools/notion-link-triage/triage.py --dry-run --limit 104041# Backfill: title + tag every untitled row (default behavior)42python3 ~/tools/notion-link-triage/triage.py4344# Process only links added since a date (good for daily incremental)45python3 ~/tools/notion-link-triage/triage.py --since 2026-04-254647# Force-reprocess a single row by page_id48python3 ~/tools/notion-link-triage/triage.py --row <page_id> --force4950# Show duplicate groups, no writes51python3 ~/tools/notion-link-triage/triage.py --show-dupes52```5354## Workflow: full inbox-zero pass55561. Show current state: `python3 ~/tools/notion-link-triage/triage.py --show-dupes`572. Dry-run sanity check on 10 rows → eyeball the output583. Run the full backfill — takes ~2-3 min for ~280 rows (0.4s sleep between calls)594. Review `~/tools/notion-link-triage/suggestions.jsonl` — each line is a candidate for Linear605. Group suggestions by tag → propose Linear issues to the user → batch-create via `mcp__claude_ai_Linear__save_issue`6162## Workflow: action a single new link6364When user pastes a Notion link URL or adds a new entry:65661. Find the page_id (it's the last URL segment after the title slug, with dashes)672. `python3 ~/tools/notion-link-triage/triage.py --row <page_id>`683. Read `suggestions.jsonl` tail to see the classification694. If it hit a high-signal tag, propose the Linear issue title + project + priority7071## Tag → Linear project mapping7273| Tag | Suggested Linear team / project |74|---|---|75| `coding`, `tool`, `ai`, `github` | Personal → Coding Work |76| `aiva`, `va`, `marketing` | AIVA → Marketing |77| `tax`, `nonprofit`, `legal` | AIVA → Admin Work (or Personal → Admin Work) |78| `design` | Personal → Coding Work (UI inspiration) |79| `event`, `shopping`, `meme`, `personal-finance` | Personal → Personal Recurring (or skip) |8081Rule: **never auto-create Linear issues**. Always show the user the proposed title + tags + project, then create only the approved ones.8283## Daily cron (optional)8485If user wants this automated, add a launchd plist on the Mac OR a systemd timer on the VPS that runs the `--since "$(date -v-2d +%Y-%m-%d)"` pass nightly. Per the user's `LLM-vs-Shell` rule this is pure shell — use systemd timer on VPS, **not** `openclaw cron`. Pattern:8687```88[Unit]89Description=Notion link triage incremental90[Service]91Type=oneshot92ExecStart=/usr/bin/python3 $HOME/notion-link-triage/triage.py --since YYYY-MM-DD93[Install]94```9596Plus a Composio Gmail alert on failure.9798## Composio actions used99100- `NOTION_QUERY_DATABASE` — paginated fetch of all rows101- `NOTION_FETCH_ROW` — verify a single row after update102- `NOTION_UPDATE_PAGE` — write `Name` (title) + `Tags` (multi_select) properties103104No new OAuth setup — uses the existing Composio Notion connection.105106## Limitations / known gotchas107108- **IG posts**: Instagram returns a login wall to non-authed clients, so `og:title` scrape often fails. Title falls back to URL.109- **X cookies stale** (`~/.cookies/x.com`): we sidestep this with oEmbed — no auth needed for public tweets.110- **Rate limit**: 0.4s sleep between rows. ~280 rows → ~2 min. Bump up if oEmbed starts 429-ing (it hasn't yet).111- **Duplicate detection is by canonical URL only** — doesn't compare titles/content. A tweet quoted in two different X URLs won't merge.112- **Tag rules are keyword-based**, not semantic. Iterate `TAG_RULES` in `triage.py` to improve.113114## Quick sanity check before reporting done115116```bash117# Did the title and tags actually persist?118node ~/tools/<your-mcp>/client.cjs execute NOTION_FETCH_ROW '{"page_id":"<id>"}' \119 | grep -E "plain_text|multi_select"120```121122## Source files123124- `~/tools/notion-link-triage/triage.py` — the script125- `~/tools/notion-link-triage/suggestions.jsonl` — append-only Linear-issue candidates126- This skill: `~/.claude/skills/notion-link-triage/SKILL.md`127128129## Ground-truth gate (MANDATORY)130131Before this skill asserts a stakes-bearing fact or takes any outward/irreversible action, apply the global standard — verify against a **primary source fetched now**, never a cached/remembered value. Full standard: `~/.claude/skills/shared/ground-truth-standard.md`.132133**Verify live before you act or assert (this skill):**134- Read the live Notion state before modifying; confirm the target page/block; explicit approval for destructive edits.135136Then: dry-run where possible, show the user exactly what will be sent/filed/asserted, get explicit chat approval for any outward action (per CLAUDE.md), capture the confirmation, and write any verified fact back into its source doc. State uncertainty as uncertainty; never assert plausible-but-unverified as fact.