Bookmarks for prompts, on claude.ai. Save prompts you use often and re-insert them into the composer from the Picker (Ctrl/Cmd+Shift+P), and bookmark the ones you'll want to come back to — then jump straight from the Bookmarks list back to the exact message in the exact conversation.
Requires Node.js.
npm install
npm run dev # watches and rebuilds into dist/Load dist/ as an unpacked extension at chrome://extensions (enable Developer mode first).
npm run build # production build
npm test # unit tests (vitest)
npm run typecheck # tsc --noEmitVISION.md §6.1/§10 scope this project as Chrome-only for v1 (Firefox is Phase 4). npm run build:firefox exists purely as a load-time compatibility fix, not a Firefox support commitment: Firefox's MV3 background rejects a service_worker entry, and @crxjs/vite-plugin's generated service-worker loader uses a static ES import, which only runs in a module-typed context Firefox doesn't have for background scripts. scripts/build-firefox.mjs runs the normal Chrome build, then separately bundles src/background/index.ts with esbuild into a plain classic script and patches dist-firefox/manifest.json's background key to { "scripts": ["background.js"] }. Content scripts and the popup/options pages needed no changes — crx already uses dynamic import() there, which works in both browsers.
npm run build:firefox # outputs dist-firefox/If src/background/index.ts ever grows real complexity (or gains imports), re-verify this still bundles cleanly — this script is a narrow patch, not a maintained cross-browser pipeline.
A saved prompt is something you want to use again. A bookmark is something you want to go back to. Same entity, two intents — see VISION.md §8.4.
Saving is bookmarking. Every save gesture bookmarks, so anything you keep shows up in the Library immediately:
| Gesture | Where |
|---|---|
| Bookmark ribbon beside a message | claude.ai transcript (Tier C) — also captures the conversation and message position |
Cmd/Ctrl+Shift+S with a draft in the composer |
claude.ai (Tier A) |
Cmd/Ctrl+Shift+S with an empty composer |
claude.ai — bookmarks your last sent prompt (Tier B) |
| The Bookmark button above the composer | claude.ai (Tier A) |
Cmd/Ctrl+B on the highlighted row |
Picker |
| Bookmark current prompt | Popup |
| + New prompt | Options page |
The only way to get an unbookmarked prompt is to deliberately unbookmark one.
The Library lists bookmarked prompts only. Unbookmark something and it leaves the Library immediately, from whichever surface you cleared it — the ribbon in a chat, the ribbon in the Library list, the Picker's Cmd/Ctrl+B, the popup, or the detail pane.
Unbookmarking is not deleting. The prompt stays in storage, stays in your JSON export, and stays retrievable from the Picker — press Cmd/Ctrl+Shift+P, find it, Cmd/Ctrl+B, and it's back in the Library. When saved-but-unbookmarked prompts exist, the Library says how many and where they are, so an empty list never reads as lost data. See VISION.md §11 v1.2 and v1.3.
Getting back. Open the extension's options page and switch to the Bookmarks tab. Each card shows the title, which conversation it came from, when you bookmarked it, its position in the chat, and enough body text to recognise it. Clicking a card opens that conversation and scrolls to the message, highlighting it.
When it can't find the message, it says so rather than dumping you at the top of a transcript:
| What happened | What you see |
|---|---|
| Matched uniquely | The message is scrolled to and highlighted. No toast — the highlight is the feedback. |
| The prompt appears twice in that chat | "Jumped to the closest match" |
| Only a partial match (message edited since) | "Found a similar message" |
| Conversation opened, message not found | "Couldn't find that message — it may have been edited or deleted." |
A bookmark saved before v0.2.0, or one saved outside a conversation, has no conversation URL. Those render disabled and labelled "no chat link saved" instead of offering a click that fails.
claude.ai has no per-message URL fragment, so returning to a message means searching the transcript for it. The saved prompt body is the anchor; conversationId and messageOrdinal only disambiguate duplicates and provide a fallback. Matching is whitespace-tolerant, because a message round-tripped through a rich-text editor differs from the saved body in spacing far more often than in content.
If the tab is already on the target conversation, the content script is messaged directly — an SPA won't reload for a same-URL update. Otherwise the target is parked in chrome.storage.session (expiring after two minutes so a stale one can't hijack a later page load) and picked up once after the navigation.
src/content/adapters/ClaudeAdapter.ts— the only DOM-aware module (§6.2): composer read/write, verified insertion, capture-phase send hook (Tier B), conversation context,locateAndHighlight, self-checksrc/content/adapters/conversationUrl.ts— claude.ai's/chat/<id>URL scheme. Pure, but adapter-owned: host knowledge belongs behind the adapter boundary whether or not it touches the DOMsrc/content/adapters/tierA.ts— composer-anchored "Save prompt" affordance (§5.1), appears only while the composer has textsrc/content/adapters/tierC.ts— transcript bookmark ribbons and the message candidate list, contained independently per §5.3src/content/ui/Picker.ts— Shadow DOM retrieval overlay: fuzzy search, MRU default, keyboard nav, bookmark toggle, fallback-mode clipboard copysrc/content/ui/StatusNotice.ts— the §6.4/§8.3 loud-failure surface: per-capability, plain-language, dismissible per sessionsrc/content/shortcuts.ts— theCmd/Ctrl+Shift+STier A/B overload (composer has text -> save draft; empty -> save last sent)src/content/index.ts— content script entry point; mounts the Picker, notice and adapter, routes messages, consumes pending navigationsrc/background/index.ts— MV3 service worker; per §6.6 its only jobs arechrome.commandsregistration and forwarding to the active claude.ai tab, no statesrc/ui/base.css— design tokens and primitives shared by the popup and options page, so the two surfaces read as one product (§2.1). Plain CSS, no component library — §6.7 charges dependencies against a small budgetsrc/popup/— quick-glance surface: Recent / Bookmarks tabs, search, save current composer text, link into the full Librarysrc/options/— the Library (§8.3, bookmarked prompts only) plus the Bookmarks tab (§8.4)src/shared/—PromptStorage(chrome.storage.sync + local overflow,onLibraryChangebroadcast),pickerQuery(fuzzy search/MRU),librarySort,bookmarks(bookmark filter/sort/search),messageLocator(which message a bookmark points at, and how much to trust it),bookmarkNavigation+pendingNavigation+navigateToBookmark,sendBuffer, message types
npm test runs the vitest suite (~140 tests) over the pure core: storage and both migration paths, duplicate detection, quota overflow in both directions, bookmark filtering/sorting, fuzzy search, library sort, the message locator, navigation planning and the navigation executor.
Per §9.3 the DOM-touching adapter modules are deliberately not unit-tested — pretending the DOM boundary is testable produces false confidence. They get the self-check harness plus a manual smoke test against live claude.ai. test/fakeChrome.ts and test/fakeTabs.ts model only the API surface the code actually uses.
Schema version 2. chrome.storage.sync holds an index record (library:index) and one body record per prompt (prompt:{id}), with oversized bodies spilling transparently to storage.local.
Bookmark flags live in the index so toggling one costs a single index write and the Bookmarks view can filter without reading every body. Navigation provenance (conversationId, messageOrdinal) lives in the body, since it's only needed when actually navigating.
Migrations run on read in both directions (§7.3). A v1 library upgrades cleanly: bookmark flags default to false, and navigation fields default to null rather than guessing a transcript position that can't be reconstructed. conversationTitle and conversationUrl captured under v1 are preserved, which is what makes older prompts navigable at all.
JSON import is intentionally not implemented — VISION.md §4.2/§7.4 place it in Phase 2, and §4.2 says PRs adding excluded features get declined without a prior document amendment. Export only, for now. Folders, tags, collections, templating, sharing, cloud sync, and telemetry all remain excluded.
Bookmarks were excluded (as "favorites") and were promoted into v1 by an explicit amendment — see VISION.md §11 v1.2 for the reasoning, and docs/bookmarks-implementation-note.md for the design that followed.
Scoped to https://claude.ai/* plus storage. No tabs or scripting permission — the popup, options page and background all reach the content script via chrome.tabs.sendMessage using a tab id, and chrome.tabs.query({ url: ... }) is covered by the existing host_permissions. Bookmark navigation added no permission.
Still zero network calls, zero telemetry, zero accounts. Bookmarks, usage counts and conversation provenance are local-only insights (§2.6) — they never leave the machine.
Per §6.6, the content-script Picker, the popup, and the options page all call onLibraryChange() (in shared/storage.ts, wrapping chrome.storage.onChanged filtered to the library's own keys) so a save/edit/delete/bookmark on any one surface reflects on the others without a reload. Tier C's ribbons re-sync through the same broadcast. The options page additionally guards against clobbering in-progress edits: an external change only redraws the detail panel if the open editor has no unsaved changes.
Registered via the manifest's commands key, so users can rebind them at chrome://extensions/shortcuts (§8.1's "all shortcuts are rebindable").
| Shortcut | Action |
|---|---|
Ctrl/Cmd+Shift+P |
Toggle the Picker |
Ctrl/Cmd+Shift+S |
Save draft (composer has text) or save last sent (composer empty) |
Ctrl/Cmd+B |
Toggle bookmark on the highlighted prompt (Picker open) |
↑ ↓ / Enter / Esc |
Navigate / insert / dismiss (Picker open) |
In the Library and Bookmarks pages:
| Shortcut | Action |
|---|---|
/ or Ctrl/Cmd+F |
Focus the search box |
↑ ↓ |
Move through the list (works from the search box) |
Enter / Space |
Open the focused row |
Esc |
Clear the search |
Chrome grants commands shortcuts first-come-first-served — if another extension already claims a chord, ours shows as unassigned until rebound manually.
Ctrl/Cmd+B is handled inside the Picker rather than registered as a command, so it costs no global chord. It's deliberately not Cmd/Ctrl+D: suppressing the browser's own bookmark shortcut from a content script is the kind of host-page fight §2.1 avoids.
Reloading the extension orphans open tabs. A content script keeps running after its extension is reloaded or updated, but every chrome.* call in it fails. All storage access goes through storageArea() in src/shared/extensionContext.ts, which raises a named ExtensionContextInvalidatedError instead of a bare TypeError; the content script catches it once, detaches its observers and listeners, and shows a notice asking you to reload the tab. Unrelated errors still propagate — §9.6 forbids silent catch-and-continue.
chrome.storage.session is closed to content scripts by default. Its access level defaults to TRUSTED_CONTEXTS, so the API is simply absent in a content script. Both Tier B's send buffer (§5.2) and the bookmark-navigation handoff (§8.4) live there, so the service worker calls setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" }) on install, on startup, and on every worker wake-up — the setting is per-session and does not survive a browser restart.
The composer, send-button, user-message and dark-mode selectors are still flagged best-effort in code and unconfirmed against live claude.ai. Bookmark navigation depends on the same transcript selector Tier C uses, and is contained the same way (§5.3): if it breaks, the Bookmarks list still works and still opens the right conversation — only the jump-to-message step degrades, and it says so.