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) |
~/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:
- Checks Tana is reachable before doing any expensive work (fail fast, fail loud).
- Renders a PDF with a headless Chromium binary (offline; not your browser).
- Names the clip using the local LM Studio model (~10β30 s), falling back to the page title.
- Converts the page HTML β markdown β Tana Paste.
- Imports a
#clipnode 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
filenode 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.
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
- Chromium browser β
chrome://extensionsβ enable Developer mode - Load unpacked β
~/Sync/ED/scripts/tana-clipper/extension 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 |
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
-
clip-reap.pywould delete every Tana PDF β this is wired around, do not undo it. The reaper (launchdme.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, andclip-reap.pytreats 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 thetana_clip_pdfs_protectedinvariant. -
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()). -
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.
-
Images must be unwrapped from their links or they render as text. markdownify turns
<a><img></a>into[](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. -
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.
-
The import result is text, not JSON. There is no
nodeIdkey. The created node id is parsed out of aCreated nodes:\n- <id> ("name")block. Needed so body nodes nest under the clip instead of dumping loose into the Inbox. -
Tana desktop must be running β the MCP server lives inside the app. The service prechecks and notifies rather than silently dropping the clip.
-
A name-match must NEVER be allowed to delete content β the
sc-ad-containertrap. Found 2026-07-17: thespruce.com clipped as nothing but its “Related Articles” rail. Dotdash (thespruce, Investopedia, Verywell, Byrdieβ¦) names the article wrappersc-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 containingarticle/main/[role=main], more than 2 ofp/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). -
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.lengthas&pt=; the service warns when captured text is underCAPTURE_RATIO(50%) of it. Bookmarklet clips omitptand skip the check. -
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.