---
title: KMS (the Vault) — notes, editor, search and sharing (part 4)
---

# KMS (the Vault) — notes, editor, search and sharing (part 4)

## What it is

This is part 4 of the [KMS](kms.md) page. It covers everything that is not text: how images and attachments get into a note, how they are stored and shown, and how pasting a plain web address can turn into a titled link without you doing anything.

## Where to find it

The **KMS** panel — images and attachments are added from inside a note, and the link behaviour happens wherever you paste a URL while editing.

## How it behaves

### Images & media (Phase 5)

Phase 5 turns KMS from a notes-only surface into a notes-plus-images vault. Every image you paste / drop / pick into the editor is dedup-stored under `<vaultRoot>/assets/<sha256>.<ext>`, rendered through the custom `nothari-asset://` protocol so traversal-safe path resolution sits between the renderer and the filesystem, optionally analyzed by an LLM that emits a per-image title / description / OCR / tags / category, browsed through a dedicated **Gallery** right-pane panel, opened full-screen in a **Lightbox**, and surfaced in the Unified Search modal under the `kind:image` chip.

### Inserting an image (Phase 5 Sub-PRs 5b – 5d)

Three input methods, all routing through the same `kms:asset-import` handler:

- **Paste** — `Ctrl+V` while focused in the editor. The clipboard-image handler in the editor's paste plugin captures the `Blob` and, because the import is an async round-trip, drops a short-lived **"Adding image…" loading placeholder at the caret SYNCHRONOUSLY** (so a `Ctrl+Z` right after the paste undoes it — it used to insert nothing until the import finished, which is why an early undo did nothing and the image popped in afterward); when the bytes resolve, the real image swaps into that placeholder **without adding a second undo step**, so one `Ctrl+Z` always removes the paste. The node stores the **portable vault-relative ref** `assets/<sha>.<ext>` (NOT the absolute `nothari-asset://` URL), so the vault stays self-contained + matches KMT-imported notes; the NodeView resolves it to the per-platform display URL via the active vault (`resolveDisplayAssetUrl` → `getVaultAssetUrl`). The loading placeholder never serializes to disk ([contract](/.claude/memory/contracts/kms-image-handling-contract.md) `touch-has-first-class-doors`).
- **Drag & drop** — drop one or more image files anywhere in the editor surface. Multi-drop inserts in source order. Non-image MIME types are silently rejected.
- **Picker** — the editor toolbar's **⋯ More** menu carries an **Image** row that opens an OS file picker scoped to image extensions. Same insertion path. (Pre-2026-05-22 this lived as an inline button in the toolbar's media group; the 2026-05-22 collapse moved it into the overflow.)

In all three cases the bytes are hashed (SHA-256), and if the resulting `<sha256>.<ext>` already exists under `<vaultRoot>/assets/`, the importer **reuses the existing file** rather than writing a duplicate — pasting the same screenshot into ten different notes leaves you with one file on disk and ten `assets/<sha>.<ext>` references. The importer also bumps a refcount row in the `nothari_assets` table so a future cleanup pass can find truly-orphaned bytes.

The note→image **references** that the Gallery's delete-gate reads live in `nothari_asset_refs`. These are reconciled **synchronously when the note is saved** (`writeNoteBody` / `createNote` call `reconcileNoteAssetRefs`), not only later by the file-watcher/indexer — so a just-pasted image is immediately protected from the Gallery's delete action rather than deletable in the ~debounce window before a reindex. The indexer reconcile stays the backstop for EXTERNAL edits ([contract](/.claude/memory/contracts/kms-image-handling-contract.md) `asset-refs-reconcile-synchronously`).

### Editing an image in place (resize · replace · copy · caption · details)

Each editor image is a `VaultImage` NodeView ([/src/renderer/src/features/kms/editor/extensions/VaultImageNodeView.tsx](/src/renderer/src/features/kms/editor/extensions/vault-image/VaultImageNodeView.tsx)) with a full set of in-place controls — inline caption/alt-text rows (revealed on selection, not hover) plus a right-click menu (opened by a **long-press** on touch, since a phone has no right-click — same menu, same items):

- **Resize** — hover the image and drag any corner handle. WIDTH is persisted as an INVISIBLE, list-safe marker inside the image's markdown link title — never block `<img>`/`<figure>` HTML (that would collapse a numbered list on the next save; see [kms-markdown-serialization-contract.md](/.claude/memory/contracts/kms-markdown-serialization-contract.md)). Height stays automatic so the aspect ratio is locked. The inline image is static — pan/zoom lives in the Lightbox, never in the document — so a drag resizes and a **double-click** (or **Enter** on the focused image) opens the Lightbox without the two fighting. A plain single click does NOTHING, so a stray click while reading or editing a note can't pop the viewer; the right-click menu's **Open in lightbox** is the third door.
- **Replace…** — right-click → Replace opens a file picker; the new image is imported and EVERY note that referenced the old one is rewritten in place (one backend pass via `kms:replace-image`).
- **Copy image** — right-click → Copy image puts the actual picture on the clipboard (paste into Gmail / Docs / etc.), falling back to copying the link when the browser blocks image-clipboard writes. "Copy URL" still copies the `nothari-asset://` link as text.
- **Caption + alt text** — inline rows that **reveal only when the image is SELECTED (clicked) — never on hover — and hide at rest**, so a plain hover no longer pops "Add caption"/"Add alt text" clutter up under every image the mouse passes over (and the row reclaims the space). A written caption stays visible for reading; alt text (the screen-reader description, not on-screen content) stays selection-gated even when set. (These rows are the ONLY place to set caption/alt — "Edit details" edits different, per-image metadata — so selecting the image is how you reach them.) The caption round-trips through the inline link title (`![alt](src "caption")`), never block `<figure>` markdown. Caption + alt-text are per-PLACEMENT (node attributes), so the same image can carry different captions in different notes. ([contract](/.claude/memory/contracts/kms-image-handling-contract.md) `caption-rows-reveal-on-selection`)
- **Edit details…** — a dialog ([/src/renderer/src/features/kms/ImageDetailsDialog.tsx](/src/renderer/src/features/kms/ImageDetailsDialog.tsx)) to hand-edit the AI-derived title / description / tags (the per-IMAGE metadata in `nothari_image_analysis`). Saving writes the row with `model: 'manual'` and PRESERVES the machine-extracted OCR text.
- **Remove from note** — right-click → Remove deletes the image from the note body. The menu `preventDefault`s its mousedown so clicking it does NOT blur the editor — otherwise TipTap's chained `.focus()` (deferred to `requestAnimationFrame`) would leave the editor momentarily unfocused and a follow-up `Ctrl+Z` would land on `<body>` instead of undoing the delete. With focus kept on the editor, `Ctrl+Z` restores the image ([contract](/.claude/memory/contracts/kms-image-handling-contract.md) `touch-has-first-class-doors`).

### Images on the web / phone client

The browser can't resolve the `nothari-asset://` protocol, so the web/mobile client renders KMS images through a same-origin, token-gated HTTP route `GET /vault-asset/<vaultId>/<relativePath>` ([/src/main/services/web/web-access-http-routes.ts](/src/main/services/web/web-access-http-routes.ts)) that reuses the SAME `serveVaultAsset` security core as the desktop protocol handler — identical path-safety, symlink refusal, extension allow-list, and SVG hardening. The displayed src is chosen per-platform by `getVaultAssetUrl` ([/src/renderer/src/lib/platform.ts](/src/renderer/src/lib/platform.ts)); note bodies always store the canonical `nothari-asset://` URL.

Image IMPORT on the web can't ride the WebSocket IPC bridge (a `Uint8Array` doesn't survive the JSON transport), so the mobile editor's **Insert image** action (in the ⋯ action sheet) POSTs the raw file to `POST /vault-asset/<vaultId>?ext=<ext>` → `importImageBytes`, then inserts the returned asset. The upload route is authenticated upstream (the same `resolveAuthedToken` gate as every web data route) and streams with a 50 MB cap. A failed upload (unsupported type, network / server error) surfaces an error toast instead of silently closing the action sheet — parity with desktop paste/drop ([contract](/.claude/memory/contracts/kms-image-handling-contract.md) `upload-never-fails-silently`).

On a phone every image surface is reachable by **touch** (desktop's right-click + hover + `Ctrl+Shift+G` don't exist there): a plain **tap** does nothing (a stray tap while reading can't pop the viewer); **press-and-hold** opens its edit menu (Replace / Edit details / Copy / Remove / **Open full-screen** — the touch twin of the desktop right-click menu, via the shared `useLongPress` hook, cancelled on scroll), whose **Open full-screen** item is the touch door to the Lightbox; the editor's **⋯ action sheet → Image gallery** row opens the vault Gallery; and caption + alt-text are tappable rows under the image. The image container carries `.mobile-press-target` so the iOS long-press callout can't pre-empt the hold. Locked by [kms-image-handling-contract.md](/.claude/memory/contracts/kms-image-handling-contract.md) `touch-has-first-class-doors`.

> Behavior + invariants for everything in this section are locked by the [KMS image-handling contract](/.claude/memory/contracts/kms-image-handling-contract.md).

### Asset storage layout

```
<vaultRoot>/
├── *.md                          # notes
├── .trash/                       # soft-deleted notes
└── assets/                       # all image bytes, content-addressed
    ├── 3a7c1e...f29.png
    ├── 9b4d0a...c16.jpg
    └── e5f8b2...a01.webp
```

The `assets/` directory is automatically created on the first import, ignored by the note watcher (the watcher's `.trash/` rule extends to `assets/` for the same ping-pong reason — the asset writer drops a file there, the watcher would see it as a new "note", reindex, double-write). Direct hand-editing of `assets/` is technically supported but unusual — most users will never look at it. If you delete an asset file from disk while a note still references it, the renderer renders a broken-image placeholder rather than crashing; the next analyzer cycle drops the analysis row for that sha.

### `nothari-asset://` protocol (Phase 5 Sub-PR 5a)

The renderer references vault images via a custom Electron scheme:

`nothari-asset://<vaultId>/<relativePathFromVaultRoot>`

The protocol handler at [/src/main/protocol-handlers/kms-asset.ts](/src/main/protocol-handlers/kms-asset.ts) is the single security gate before any vault image touches the editor. It resolves the URL through `resolveInsideVault()` to prevent path traversal (literal `..`, URL-encoded `..%2f`, absolute Windows / POSIX paths, NTFS alternate-data-stream `:` tricks all reject as `400 Bad Request`), refuses to follow symlinks via `lstat()` (a symlinked `.png` inside the vault could point at `~/.ssh/id_rsa` — `403 Forbidden`), and serves only the allow-listed image MIME types: `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `avif` (every other extension — `.exe`, `.html`, `.js`, `.lnk`, `.pdf`, etc. — returns `415 Unsupported Media Type`, and `path.extname` is checked on the FINAL extension only so a double-extension polyglot like `foo.png.exe` rejects on `.exe`).

Every successful response carries `X-Content-Type-Options: nosniff` so a polyglot image with HTML bytes appended can't be MIME-sniffed into a scriptable document, plus `Cache-Control: private, max-age=600` so a panel re-render doesn't refetch every embedded image. SVG responses get extra hardening (mirrors the [attachment.ts](/src/main/protocol-handlers/attachment.ts) RT-F080 pattern): `Content-Disposition: attachment` (top-level navigation downloads instead of rendering) plus `Content-Security-Policy: default-src 'none'; script-src 'none'; sandbox` so even a bypass would render in a unique sandbox origin with no script execution. The scheme is registered as privileged with `bypassCSP: false` set EXPLICITLY in [/src/main/protocol-handlers/index.ts](/src/main/protocol-handlers/index.ts) so vault assets obey the same CSP as every other image source — the renderer's CSP `img-src` includes the literal `nothari-asset:` token (no wildcards).

### Gallery panel (Phase 5 Sub-PR 5e)

The right-pane panel stack carries a **Gallery** entry, collapsed by default. Open it and you see a thumbnail grid of every asset under `<vaultRoot>/assets/` for the active vault, sorted by most-recently-added first. Each tile shows the thumbnail plus a small badge marking whether the image has been analyzed (filled marker) vs not (hollow marker). Click any tile to open the full-resolution **Lightbox**.

Behavior:

- Thumbnails use the same `nothari-asset://` URL the editor uses — Electron caches them after first paint, so re-opening the panel after browsing is instant.
- The toolbar carries a filename search, a sort dropdown (Newest / Oldest / Largest / Most-referenced), All/Used/Orphaned chips, AND **AI tag + category filter dropdowns** (2026-06-11). The tag/category dropdowns only render when at least one analyzed image carries that data; "Clear filters" resets them. The list feed (`KMS_LIST_IMAGES`) left-joins each on-disk asset with its `tags`/`category` from `nothari_image_analysis` (one vault-scoped query via `listImageAnalysisLite`), so the dropdowns filter in-grid without opening the Unified Search modal.
- An "Analyze all" button at the top of the panel kicks off the bulk-analyze flow (see "AI image analysis" below). The button is gated on `nothariEnabled === true`, on the user having configured an API-key account, and on at least one image being un-analyzed.
- Per-row hover reveals a delete button. Click → confirms via danger-variant `useConfirmDialog`. On accept, fires `kms:asset-delete`. If the asset is referenced by any note, the handler rejects with a list of referencing notes (rendered as a toast) — the user must clear the references first. If unreferenced, the file is moved to `<vaultRoot>/.trash/assets/<timestamp>__<sha256>.<ext>` (same trash semantics as note delete; recoverable by hand).
- **Opening the Gallery on mobile.** Desktop opens this overlay via **Ctrl+Shift+G** or the command palette's "Open image gallery" — a phone has neither, so the editor's **⋯ action sheet** carries a vault-gated **Image gallery** row (the touch entry point to the same overlay). Tapping a tile still opens the Lightbox.

### Lightbox (Phase 5 Sub-PR 5e)

Opens on a **double-click** (or **Enter**) of an in-note image — a plain single click no longer opens it — on **click** of a Gallery-panel tile, OR on **click** of an image hit in the Unified Search modal. Shape:

- Full-resolution image, fit-to-viewport with letterboxing for non-matching aspect ratios.
- Header: image title (from AI analysis if available, else the basename); close button.
- Side rail: **AI metadata** (when present) — description, OCR text, tags chip strip, category.
- Footer: tech details — sha256 (copy button), dimensions, file size, mtime, file path.
- Keyboard: **Esc** closes; **Left / Right Arrow** navigate between images when opened from the Gallery; **Z** toggles native-pixel-size view (no fit-to-viewport).

The lightbox is the same surface for both gallery browsing AND unified-search image hits — Sub-PR 5h didn't introduce a second viewer.

### AI image analysis (Phase 5 Sub-PR 5f)

When triggered (per-image via Lightbox or bulk via Gallery's "Analyze all"), KMS sends each image to the configured AI model and stores the response in the `nothari_image_analysis` table (migration v198 + v199; see below). Per-image record:

- **title** — short noun-phrase summary (~3–7 words).
- **description** — 1–3 sentence prose summary of the visible content.
- **ocr_text** — every legible character the model extracted, line-broken.
- **tags** — a JSON array of single-word tags (e.g. `["screenshot", "diagram", "ui"]`).
- **category** — high-level bucket (e.g. `"screenshot"`, `"photo"`, `"diagram"`).
- **model** + **analyzed_at** + **input_tokens** + **output_tokens** + **cost_usd** — provenance / cost-tracking fields.

The table uses `(vault_id, sha256)` as a logical unique key — re-analyzing the same image bytes UPSERTs in place rather than writing a duplicate row. A backing `nothari_images_fts` FTS5 external-content virtual table mirrors `title + description + ocr_text + tags_json + category` for the Unified Search images query; an ai/ad/au trigger triad keeps the FTS index in lockstep with the analysis table.

**Schema note (migration v198 → v199 fix-up):** Sub-PR 5f's original v198 migration shipped with an FTS5 column-name mismatch — the FTS5 column was `tags` while the content table column was `tags_json`. Because the FTS table is declared `content='nothari_image_analysis'`, FTS5 reads the content column **by the FTS5 column name** during snippet(), 'rebuild', and integrity-check operations — the mismatch crashed snippet() with `no such column: T.tags`. The bug was invisible in 5f because no read path called snippet() until 5h introduced the unified-image search. Migration **v199** (Sub-PR 5h) drops + recreates the FTS5 virtual table and the ai/ad/au triggers with `tags_json` as the column name, then runs the `'rebuild'` command to repopulate the index from the existing content rows. v199 is content-preserving — no analysis rows are lost, only the FTS index is rewritten.

The analyzer at [/src/main/services/kms/image-analyzer.ts](/src/main/services/kms/image-analyzer.ts) sends one image per Anthropic API call (no batching — keeps individual costs visible in the cost dashboard). Spend hits `api_cost_log` via `trackApiCost(accountId, 'kms-image-analyzer', model, in, out)`. AI features fail-with-clear-error when the active account is OAuth/login: per the project rule, "AI features call the Messages API which does not accept OAuth tokens — the analyzer falls back to the first available API-key account, and fails closed with a toast if none exists." Bulk-analyze iterates with rate-limit awareness: it polls `listAnalyzedShas(vaultId)` between calls so an interrupted run can resume without re-billing analyzed shas.

### Agent & CLI access to image metadata (2026-06-11)

The AI image metadata is reachable headlessly so spawned agents (and the omniscio-control skill) can read it — on **two** surfaces, deliberately asymmetric:

- **CLI control server** (runs in main, so it CAN disk-walk): `GET /kms/images` (asset list + `tags`/`category`), `GET /kms/images/:sha` (full analysis row or `analysis:null`), `GET /kms/images/search?q=` (FTS5 over the AI metadata). Read-only, same `nothariEnabled` 403-gate as the other KMS routes. Surface contract: [kms-cli-routes-contract.md](../../.claude/memory/contracts/kms-cli-routes-contract.md).
- **Bundled MCP server** (a bare process that can't load the disk-walking asset store): `kms_list_images` / `kms_search_images` / `kms_image_details` — **metadata-only** (no file size/path), cross-vault, gated on `nothariEnabled` + `nothariAgentToolsEnabled`, 100 KB payload cap. In [agent-tools-read.ts](/src/main/services/kms/agent-tools/agent-tools-read.ts).

Triggering a **new** (billable) analysis from the CLI is **never a direct call** — `POST /kms/images/analyze` (`{ vaultId?, sha256? }`, sha set = one image, omitted = all un-analyzed) only ENQUEUES an approval-gated `kms.analyze_images` row (the `kms-image-analysis` approval family, `danger:'cost'`, default ON), and the dispatcher runs [image-analysis-runner.ts](/src/main/services/kms/image-analysis-runner.ts) `runImageAnalysis` only after the user approves. The route returns **202 (queued)** with a cost-estimate preview, never **200 (applied-inline)** — so no CLI caller can spend without an approval. Mirrors `POST /sms/send`.

### Image IPC surface (Phase 5)

All routes gate on `settings.nothariEnabled === true`.

#### Import + reference

- `kms:asset-import` (5b) — input `{ vaultId, sourcePath?, blobBase64?, filename }`. Either provide an absolute filesystem source path OR the base-64 bytes inline (paste / drag-drop). Returns `{ sha256, relativePath }`. SHA-256 dedup happens here.
- `kms:asset-list` (5e) — input `{ vaultId }`. Returns `{ assets: { sha256, relativePath, sizeBytes, mtime, hasAnalysis }[] }`. The Gallery's data source.
- `kms:asset-details` (5g) — input `{ vaultId, sha256 }`. Returns the same per-row fields plus the AI analysis record (if any). The Lightbox's data source.

#### Mutate

- `kms:asset-replace` (5g) — input `{ vaultId, oldSha256, newSourcePath | newBlobBase64, filename }`. Replaces an asset in place: writes the new bytes, rewrites every note's `assets/<oldSha>.<ext>` reference to `assets/<newSha>.<ext>` (single transaction, parameterized rewrite — the relative ref is the canonical on-disk form `extractAssetRefs` matches), drops the old analysis row, moves the old asset to `.trash/assets/`.
- `kms:asset-delete` (5e/5g) — input `{ vaultId, sha256 }`. Refuses if the asset is referenced by any note; otherwise moves to `.trash/assets/`.
- `kms:asset-orphans` (5g) — input `{ vaultId }`. Returns `{ orphans: { sha256, relativePath, sizeBytes }[] }` — assets in `<vaultRoot>/assets/` that no note references. Surfaces a small "Clean up orphans" affordance on the Gallery footer.

#### Analyze

- `kms:image-analyze` (5f) — input `{ vaultId, sha256, model? }`. Returns the persisted `nothari_image_analysis` row. Used by the Lightbox's per-image Analyze button.
- `kms:image-analyze-bulk` (5f) — input `{ vaultId, model?, sinceCursor? }`. Walks `assets/`, skips already-analyzed shas (via `listAnalyzedShas(vaultId)`), emits a push event per completion. Used by the Gallery's "Analyze all" button.

#### Search

- `kms:unified-search` (3h + 5h) — see "Unified Search modal" above. Phase 5 added the `images` section + `kind:image` chip.

### Image file map

- Protocol handler: [/src/main/protocol-handlers/kms-asset.ts](/src/main/protocol-handlers/kms-asset.ts).
- Asset store: [/src/main/services/kms/asset-store.ts](/src/main/services/kms/asset-store.ts) (sha256 + dedup + refcount + trash move).
- Analyzer service: [/src/main/services/kms/image-analyzer.ts](/src/main/services/kms/image-analyzer.ts).
- DB queries: [/src/main/db/queries-kms-images.ts](/src/main/db/queries-kms-images.ts) (analysis CRUD + `searchImagesUnified`).
- IPC handlers: [/src/main/ipc/kms/images.ts](/src/main/ipc/kms/images.ts) (import / list / details / replace / delete / orphans / analyze) + [/src/main/ipc/kms/search.ts](/src/main/ipc/kms/search.ts) (unified-search Phase 5h kind branching).
- Renderer Gallery: [/src/renderer/src/features/kms/views/ImageGallery.tsx](/src/renderer/src/features/kms/views/ImageGallery.tsx) (panel + grid + analyze-all button).
- Renderer Lightbox: [/src/renderer/src/features/kms/ImageLightbox.tsx](/src/renderer/src/features/kms/ImageLightbox.tsx).
- TipTap VaultImage node: [/src/renderer/src/features/kms/editor/extensions/VaultImage.ts](/src/renderer/src/features/kms/editor/extensions/vault-image/VaultImage.ts) (+ [VaultImageNodeView.tsx](/src/renderer/src/features/kms/editor/extensions/vault-image/VaultImageNodeView.tsx)).
- Paste / drop handler: [/src/renderer/src/features/kms/editor/extensions/ImagePasteDropHandler.ts](/src/renderer/src/features/kms/editor/extensions/vault-image/ImagePasteDropHandler.ts).

### URL unfurl (clean-URL paste)

Pasting a **clean** URL (the pasted text, after trim, IS the URL — no surrounding text) into a note unfurls it, ported from the standalone Nothari app:

- **YouTube** (`youtube.com/watch?v=…`, `youtu.be/…`, `/shorts/…`) and **Vimeo** (`vimeo.com/<id>[/<hash>]`) → the link text is replaced with the video's title, so the note shows `[Real Title](url)`. The title comes from each site's public **oEmbed** endpoint (no API key); when oEmbed fails or returns nothing, it falls back to reading the video **page's `og:title`** (the endpoint that still answers when Vimeo's abuse WAF 403-blocks `/api/oembed.json` from an IP — see [tasks-v2.md](tasks-v2.md) "Paste a video link"). Resolved titles are cached for the process + in-flight-deduped, so a duplicate/rapid re-paste never re-hits the provider.
- **X / Twitter** (`…/status/<id>`) → a rendered **screenshot of the tweet** is fetched from the authenticated tweet-shots service, stored in the vault's content-addressed asset store, and inserted as an inline `VaultImage` (sized ~25% of editor width, column-aware) at a schema-valid slot next to the pasted link — **nested INSIDE the current bullet when you paste into a list** (rather than being lifted out and dropped outside the bullets), or the next block in a plain paragraph; the plain link stays as a fallback.
- Triggers only on a whole-string URL paste, and never inside a code block. While the fetch is in flight a render-only ProseMirror **Decoration** marks the link (never serialized — a slow fetch can't persist a placeholder).
- **Any failure** (offline, deleted/private tweet, missing/rate-limited key, no open vault) degrades to the plain link with a soft "kept the plain link" toast — never a crash.

**Why main-process:** the renderer may not make outbound network calls, so the fetch runs in main behind two IPC channels — `kms:fetch-video-title` → `{ title }` and `kms:fetch-tweet-screenshot` → `{ relativePath, sha256 }` (the renderer builds the `nothari-asset://` src). Titles are HTML-entity-decoded + whitespace-collapsed (NOT markdown-escaped — the serializer owns that). The oEmbed + tweet-shots hosts are fixed (only a numeric tweet id / percent-encoded url param is user-influenced). The **og:title fallback is the one request whose host comes from the user's URL**, so it is re-validated against a video-host allow-list BEFORE the fetch AND on the final (post-redirect) URL, and the body read is size-bounded — keeping the "no SSRF" surface intact.

**Tweet-shots key:** default-only (`getTweetShotsApiKey`, no per-user setting) — baked into the committed internal defaults (zero setup; private-repo Known Debt). The shipped key is free-tier, so heavy use can hit its credit/rate limit and fall back to plain links.

### URL-unfurl file map

- Detection: [/src/renderer/src/features/kms/editor/extensions/url-patterns.ts](/src/renderer/src/features/kms/editor/extensions/url/url-patterns.ts).
- Editor extension + renderer fetchers: [/src/renderer/src/features/kms/editor/extensions/UrlPasteHandler.ts](/src/renderer/src/features/kms/editor/extensions/url/UrlPasteHandler.ts).
- Main service: [/src/main/services/kms/url-unfurl.ts](/src/main/services/kms/url-unfurl.ts); IPC handlers: [/src/main/ipc/kms/url-unfurl.ts](/src/main/ipc/kms/url-unfurl.ts).
- Invariants (egress-in-main, content-type/size guards, graceful fallback, default-only key): [kms-url-unfurl-contract.md](../../.claude/memory/contracts/kms-url-unfurl-contract.md).

## Related

The vault itself is the [KMS](kms.md) page; the editor's own syntax is [part 2](kms-part-2.md). Turning a clip from the web into a note is on [KMS web clipper](kms-web-clipper.md).
