Tana Web Clipper

Tana Web Clipper

One-click clipping of the current web page into the Tana Inbox, as a #clip node with the whole page rendered as outline content plus a faithful PDF copy on disk.

Twin of the Notebooks Web Clipper. Both run at the same time and do not touch each other.

Notebooks clipper Tana clipper
Service clip_service.py tana_clip_service.py
Port 8787 8788
Hotkey βŒ˜β‡§Y βŒ˜β‡§U
launchd me.edmd.clipper me.edmd.tana-clipper
Source ~/Sync/ED/scripts/clipper/ ~/Sync/ED/scripts/tana-clipper/
venv ~/venvs/clipper ~/venvs/tana-clipper
Destination Notebooks “Clipper” book (HTML note) Tana Inbox (#clip node)
PDF ~/Sync/ED/notebooks-pdfs/ same dir (see Gotchas)
Log clip.log tana-clip.log

Overview

Click the toolbar button (or βŒ˜β‡§U). The extension captures the fully-rendered page from inside the page’s own context β€” so logged-in and paywalled content is included β€” declutters a clone of the DOM, and POSTs the HTML to http://localhost:8788/clip.

The service then:

  1. Checks Tana is reachable before doing any expensive work (fail fast, fail loud).
  2. Renders a PDF with a headless Chromium binary (offline; not your browser).
  3. Names the clip using the local LM Studio model (~10–30 s), falling back to the page title.
  4. Converts the page HTML β†’ markdown β†’ Tana Paste.
  5. Imports a #clip node into the Inbox with fields, then the body beneath it.

Typical end-to-end: ~13 s for a long Wikipedia article (132 nodes).

What Tana can and cannot do

This is the part that surprises people, so it’s written down explicitly. Verified against Tana’s docs on 2026-07-17:

  • Tana cannot render a web page. There is no HTML node type and no webview. Nodes are outline text. A pixel copy of a page is simply not representable in Tana.
  • The PDF cannot be attached to the node programmatically. Tana does have a file node type that previews PDFs inline β€” but it exists only in the cloud Input API, which caps payloads at 5,000 characters. A base64-encoded PDF blows that instantly. The local MCP has no file-upload tool at all. Drag-and-drop into Tana by hand works; there is no automated path. This is why the PDF is linked, not embedded.
  • What Tana renders, and therefore what we send: headings (!!), paragraphs, **bold** __italic__ ^^highlight^^, [name](url) links, markdown tables, code blocks, and inline images via ![](url).

So a clip is the whole page’s content, laid out and readable with its pictures in place β€” not the site’s CSS/layout. The PDF is the pixel-faithful copy.

Architecture

Browser (any Chromium)                Mac
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Tana Clipper ext β”‚        β”‚  tana_clip_service.py :8788  β”‚
β”‚  βŒ˜β‡§U / toolbar   │─POST──▢│   launchd me.edmd.tana-clipperβ”‚
β”‚  captures DOM    β”‚  HTML  β”‚                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚  1. Tana health precheck     β”‚
                            β”‚  2. headless Chrome β†’ PDF    β”‚
                            β”‚  3. LM Studio :1234 β†’ title  β”‚
                            β”‚  4. HTML β†’ md β†’ Tana Paste   β”‚
                            β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                     β”‚          β”‚
                       JSON-RPC over β”‚          β”‚ writes
                       HTTP + PAT    β”‚          β–Ό
                                     β”‚   ~/Sync/ED/notebooks-pdfs/*.pdf
                                     β–Ό   + pdf-manifest.json
                        Tana desktop app :8262/mcp
                        import_tana_paste β†’ Inbox

Auth: the service reads the Tana PAT out of ~/Sync/ED/config/claude_desktop_config.json (mcpServers.tana-local) at call time β€” one source of truth, so a token rotation needs no edit here.

Inbox id is deterministic: {workspaceId}_CAPTURE_INBOX = rav2D0ijeq_CAPTURE_INBOX (🐝 Main).

Install the extension

  1. Chromium browser β†’ chrome://extensions β†’ enable Developer mode
  2. Load unpacked β†’ ~/Sync/ED/scripts/tana-clipper/extension
  3. chrome://extensions/shortcuts β†’ confirm βŒ˜β‡§U

βŒ˜β‡§T is not available β€” Chrome reserves it for “reopen closed tab” and the browser wins.

A bookmarklet equivalent (any browser, no extension) is at ~/Sync/ED/scripts/tana-clipper/bookmarklet.js.

Badge: βœ“ green = clipped Β· βœ— red = capture failed Β· ! red = service not running on :8788.

The #clip supertag

Created 2026-07-17 in 🐝 Main. Tag id gYJaHULPWCaI.

Field Id Type
URL CgejRBrrSNQl url
Clipped 93CnQc3afdXj date
PDF oHxYuU9N178Q url (localhost:8788/open?f=…)
Site BT-fLzJApnWB plain

The PDF field is a /open link rather than a file:// link: clicking it hits the local service, which runs open and the PDF appears in Preview.

Gotchas

  1. clip-reap.py would delete every Tana PDF β€” this is wired around, do not undo it. The reaper (launchd me.edmd.clip-reap, every 6 h) deletes any PDF in ~/Sync/ED/notebooks-pdfs/ that no Notebooks note references. Tana clips share that directory and have no Notebooks note, so they look exactly like orphans. The Tana clipper therefore records every PDF it writes in ~/Sync/ED/scripts/tana-clipper/pdf-manifest.json, and clip-reap.py treats those names as referenced (TANA_MANIFEST). Verified by negative control 2026-07-17: hide the manifest and the reaper immediately lists the Tana PDF as an orphan. Guarded by the tana_clip_pdfs_protected invariant.

  2. A table or code block sandwiched between plain nodes breaks the import. Measured against the live app: [plain, table] OK, [table, plain] OK, [plain, table, plain] β†’ Internal server error (renderer may not be connected). Multi-line blocks are emitted with indented continuation lines, which Tana Paste reads as child structure. So multi-line blocks are always imported alone (body_batches()).

  3. Chunk size is not the constraint. 198 filler nodes / 20,007 chars imported fine. Don’t “fix” import failures by shrinking chunks β€” look for a multi-line block instead.

  4. Images must be unwrapped from their links or they render as text. markdownify turns <a><img></a> into [![alt](img)](href), which Tana reads as a link. Wikipedia wraps every thumbnail this way β€” 8/8 images on the test article. unwrap_linked_images() runs before markdownify and is what makes pictures actually appear.

  5. Protocol-relative image srcs (//host/path) render nothing β€” absolutized explicitly.

5a. Tana only linkifies [text](url). A bare URL string in a url-type field stays plain text and is not clickable; markdown’s autolink form <https://…> is not linkified either (renders as literal escaped text). So the URL:: field is written as [url](url) β€” clickable and still shows the full URL β€” and linkify_bare_urls() converts autolinks/bare URLs in body prose the same way.

5b. Parentheses in a URL break the markdown link. [text](…/Taxonomy_(biology)) ends at the first ), truncating the href and leaking the rest as literal text. Wikipedia is full of these β€” 64 broken links on the Shiitake article alone. encode_parens() percent-encodes (/) to %28/%29 in every href/src. This also makes [^)]* a safe matcher for link targets everywhere else in the pipeline.

5c. title attributes become visible junk. markdownify renders title="…" as [text](url "title"); Tana folds the title into the href, yielding malformed anchors. Wikipedia sets a title on nearly every link (614 on one page) β€” clean_link_attrs() strips them.

5d. Don’t linkify bare URLs with lookbehinds alone. archive.org targets embed a whole second URL (…/web/20170225225742/http://host/path); a naive bare-URL pass linkifies the inner one and produces nested ](..[..](..)) garbage (4 links corrupted this way). linkify_bare_urls() matches out existing link/image spans and only processes the gaps.

  1. The import result is text, not JSON. There is no nodeId key. The created node id is parsed out of a Created nodes:\n- <id> ("name") block. Needed so body nodes nest under the clip instead of dumping loose into the Inbox.

  2. Tana desktop must be running β€” the MCP server lives inside the app. The service prechecks and notifies rather than silently dropping the clip.

  3. A name-match must NEVER be allowed to delete content β€” the sc-ad-container trap. Found 2026-07-17: thespruce.com clipped as nothing but its “Related Articles” rail. Dotdash (thespruce, Investopedia, Verywell, Byrdie…) names the article wrapper sc-ad-container β€” it’s the container that hosts ads inside the article. The declutter regex matched -ad-, removed that div, and took 67 headings/paragraphs and 24,224 chars with it. Measured on the live DOM: 25 h2 / 44 p β†’ 2 h2 / 0 p. The sticky sweep and the tag/dialog step were both innocent β€” each was isolated and tested. Fix: holdsContent() spares any element containing article/main/[role=main], more than 2 of p/h1/h2/h3, or >400 chars of innerText. Marking runs on the live document (innerText needs layout β€” it is meaningless on a detached clone), then marked nodes are removed from the clone. Verified: 25 h2 / 44 p / 42 img preserved, 57 chrome elements still removed. The Notebooks clipper shares this bug (its declutter is where this code came from).

  4. The service cannot detect a bad capture on its own. Once the declutter strips the article, the captured text and the resulting markdown agree β€” both small, both “fine”. A markdown-vs-captured-text ratio check scored the broken spruce clip as OK (missed by 13 chars). Only the extension knows what the page really held, so it sends the live document.body.innerText.length as &pt=; the service warns when captured text is under CAPTURE_RATIO (50%) of it. Bookmarklet clips omit pt and skip the check.

  5. Raw captures are kept in ~/Sync/ED/scripts/tana-clipper/captures/ (newest 10). Non-negotiable for debugging: thespruce.com blocks curl and headless Chrome, so a bad clip cannot be reproduced after the fact β€” only the exact bytes the browser sent will do.

Troubleshooting

# is it up?
curl -s http://localhost:8788/health          # -> "ok tana=up"

# recent clips
tail -20 ~/Sync/ED/scripts/tana-clipper/tana-clip.log

# restart
launchctl kickstart -k gui/$(id -u)/me.edmd.tana-clipper

# would the reaper eat anything? (never destructive)
python3 ~/scripts/clip-reap.py --dry-run

# invariants
python3 ~/scripts/check-invariants.py --only tana_clipper_up
python3 ~/scripts/check-invariants.py --only tana_clip_pdfs_protected

Log lines are OK (all nodes landed) or PARTIAL (some batch failed β€” count and kinds are logged, and a notification fires). A clip is never reported OK when the body didn’t land.