Skip to content
rajatrv-fullstack edited this page Sep 8, 2026 · 1 revision

FAQ

Direct answers to the questions people ask first about raindrop-brave-sync, each with the mechanism behind the answer and a pointer to the page that goes deeper.

Design

Why an LLM instead of rules?

Because the hard half of the problem is judgement, not transport. Anything can copy a list of URLs from one place to another; the interesting half is deciding what each save was actually about, and doing it well enough that the result is worth looking at. Keyword rules fail in an instructive way. The tempting rule is "this URL carries an ad click-id, so it is junk", and it is wrong: utm_source, a tracking parameter, a share.google shortlink all describe how a bookmark arrived, and none of them says whether it matters. Somebody who tapped an advertisement for a plumber and then deliberately saved it was looking for a plumber, which is exactly the kind of thing a bookmark library exists to remember. Provenance is real and is recorded as a tag; it must never drive placement. Dead links and unreadable slug titles are research problems (follow the redirect, check the Wayback Machine, work out what the page was), not grounds for binning.

That is also why there is no fallback. There is no keyword-rules mode, no local-model mode, no classify-by-domain mode, and Raindrop's own /raindrop/{id}/suggest endpoint is Pro-only and deliberately unused. If you are not willing to run a model over your bookmark titles and URLs, the classification half has nothing to offer you. The apply half still works on a desired.json you write yourself. See Taxonomy and Classification.

Is there a "Misc" folder?

No, and there never will be. Noise, Junk, Misc, Other, Various and Uncategorised are forbidden collection names, along with any catch-all wearing a nicer one. When nothing in the taxonomy fits, the correct outcome is a new collection, named in the run summary. A bookmark library is a record of deliberate acts, and a classifier that sweeps what it does not recognise into a bin is destroying the signal it was built to find. Something genuinely unfamiliar is information about a gap in the taxonomy. A pull request that adds a catch-all category will be declined; see CONTRIBUTING.md.

Why is the taxonomy pinned?

Because an unpinned taxonomy makes the bookmarks bar unstable even when every individual write is correct. The first classification pass reads your whole library and derives a folder tree and tag vocabulary that fit it. That tree is saved to ~/.raindrop-sync/taxonomy.json, and from then on every routine pass classifies new and changed items into it: the existing paths (from collection-map.json) and the controlled tag vocabulary are passed to the model in the prompt, and it chooses exactly one path and one to four tags from them. Nothing outside those vocabularies is accepted, with one exception: creating a new collection when nothing fits. Extending the tree is allowed and expected; restructuring it is not.

Without the rule, a model asked to derive the tree afresh regroups the same bookmarks under plausibly different names every run (Woodworking today, Workshop tomorrow, Carpentry the day after), and the bar churns permanently. Re-deriving is a separate, explicit, user-invoked operation that bumps taxonomy_version in the ledger, which by definition marks every item for reclassification. The same instinct governs change detection: the delta is computed from a content hash over the raw Raindrop values, never from a timestamp, because Raindrop bumps lastUpdate on every write including the tool's own, and a timestamp watermark would re-select the entire library forever.

Why does it never delete?

Because, from the outside, a bookmark that was trashed in Raindrop and a bookmark that was simply missed by a paging race look identical, and acting on the wrong one costs you data this tool cannot restore. Both appliers are additive-only. The file writer (src/apply_brave.py) is permitted to add or remove only nodes carrying its own marker, and as shipped it only ever adds. The extension (extension/sw.js) creates what is missing and nothing else. Deletion propagation is off by default and is not on the roadmap in any form that runs without confirmation. Doing it safely would need absence from N consecutive full reads, a re-query of the item id, and a check of Raindrop's Trash collection (-99), and none of that is built. Anything that deletes will be declined as a contribution.

One nuance, stated plainly. ~/.raindrop-sync/reset_folders.json is an explicit request, meant to be consumed once, that asks the extension to tear down and rebuild a folder the tool owns end to end: a direct child of the bookmarks bar that the native host names explicitly. It exists for layout changes to the managed folder. The service worker's own comment warns that anything a person dropped inside such a folder would go with it, so the host only ever names folders that are entirely the tool's own, and it never touches a folder you created.

What happens to my hand-curated folders?

They are untouchable by construction. Every node the file writer creates carries meta_info: {"raindrop_sync": "v1"}, and the writer may only add or remove marked nodes. Unmarked nodes, which is everything you curated, are never moved, renamed or removed. Marked nodes are found by a full-tree walk on the marker, never by position: positional lookup silently duplicates the first time you drag a folder, and Chromium then re-randomises one of the duplicate GUIDs, severing the identity anchor. This is what makes it safe to file into your real folders instead of a quarantine folder. That the marker survives Brave's own re-serialisation was verified on Brave 152, 7 of 7 nodes; re-verify it after a major Brave upgrade, because the ownership model rests on it.

Two more things protect your folders. The mirror lives under a managed folder on the bar, one level deep (a folder_path looks like Raindrop/Woodworking), because hover menus make every extra level a tax on the reader. And promotion into a folder you built is a separate, reject-by-default decision: the gate rejects deep links that will rot, commentary about a field rather than a primary source, one-off vendor landing pages, job postings, news articles, and any proposed new folder with fewer than three qualifying items. A rejected item stays in Raindrop and loses nothing; a wrongly promoted one quietly degrades something you spent years building. See Safety and Ownership Model.

Why no cloud component?

There is nothing to host. State is files under ~/.raindrop-sync/ (mode 0700): a SQLite ledger, the pinned taxonomy, the collection map, the staged promotions, timestamped backups, logs, and the extension's signing key. The only thing that passes from the classification half to the apply half is a JSON file on disk. Nothing phones home; the only outbound traffic is the Raindrop API, plus whatever your own Claude Code session sends to the model. No server, no hosted service, no account to create. The consequences are practical: your Raindrop token is stored on your machine and leaves it only as the bearer header on calls to the Raindrop API, which is the only outbound traffic; there is nothing to bill or shut down, and nobody else's taxonomy is imposed on your library. The honest trade-off is that the classification half needs you to start it, because the LLM session it runs in is interactive.

The browser side

Why unpacked, and not the Chrome Web Store?

Because the alternative people reach for first fails permanently. A locally signed .crx, registered as an external extension, does install: Brave records it, then disables it with disable_reasons: [256], which is DISABLE_NOT_VERIFIED. Chromium's InstallVerifier enforces that against a signed allowlist in the profile's Preferences, a locally built CRX is never on it, every attempt to re-enable is reverted, and the toggle greys out for good. The poison attaches to the extension id, not the installation: remove the registration, load the very same folder unpacked, and it stays disabled. The only escape is a brand-new id from a new key.pem, which is how the first identity built for this project was lost. --load-extension does not help either; it applies only to a browser instance you launch yourself, not your everyday profile.

Unpacked extensions are exempt from the install verifier, which makes Developer mode and Load unpacked the supported route, not a loophole. The project deliberately ships no packed CRX and no store listing: the extension is one file with no build step, it exists only to talk to a native host on your own machine, and its id is derived from a key generated on your machine at bootstrap. The manifest pins that key so the id is a function of the keypair rather than the folder's absolute path; without it, moving the folder would silently break the native host's allowed_origins. The costs are a one-time manual load, and a version bump plus a click on reload after any edit to sw.js, because unpacked extensions do not hot-reload. RESEARCH.md finding 3 has the Preferences evidence.

Why does the extension poll instead of push?

Because chrome.alarms clamps to a one-minute floor, and a minute is already below the threshold anyone notices. The service worker sets PERIOD_MINUTES = 1. On each tick it spawns native_host.py for about 30 ms, asks what is pending for this client, creates whatever is missing through chrome.bookmarks, and reports the results back so the host can record them. Genuinely instant delivery would need a long-lived chrome.runtime.connectNative port so the host could push when desired.json changes, and MV3 fights that: it kills idle service workers, so the port would have to be re-established over and over. The alarm is re-armed on every worker start rather than only in onInstalled; creating an alarm with an existing name replaces it, so this is idempotent, and it guarantees a changed period takes effect even when onInstalled never fires. Every entry point (worker load, onInstalled, onStartup, the alarm) is funnelled through one promise chain, because in MV3 those events can fire in the same millisecond and, before the lock existed, that produced a real duplicate bookmark in testing. See RESEARCH.md finding 8.

Why is there a launchd fallback at all?

Because the extension only works while Brave is open with the extension enabled, and neither is guaranteed. The launchd agent com.raindrop-sync.apply fires every 900 seconds and runs apply_brave.py, which edits Brave's Bookmarks file directly, so its changes appear at the next Brave start whatever state the browser was in. The two coexist safely: each skips anything already present by URL, and neither deletes. If Brave is running, the file writer may lose a race with Brave's own re-serialisation (Brave rewrites the file from memory about 2.5 seconds after any bookmark change, and again at quit), and the only casualty is the tool's own pending addition, which the next tick re-applies. It converges on the first quiet tick and always sticks once Brave restarts and reads the file.

It works the other way round too: you can skip the extension entirely and stop after the launchd step of SETUP.md. The functional cost is zero; you lose only instant application, and the promotion gate typically passes a handful of items a month. Operations covers running the agent; Architecture explains why the two appliers cannot share an ownership marker.

Why does it write the bookmarks file while Brave is running?

Because waiting would not make it safer, and quitting Brave would make it worse. Brave reads Bookmarks exactly once, at profile load, into an in-memory model that is authoritative from then on; there is no file watcher, and every later write to the file is Brave serialising its whole tree over the top. A write made while Brave runs is therefore invisible to the model and will be overwritten. But both writers are atomic (os.replace on this side, Chromium's ImportantFileWriter rename on Brave's), so the file is never torn: one version wins whole. Your bookmarks live in Brave's memory and were never the loser's content, so losing the race cannot delete anything of yours. The only thing lost is the tool's pending addition, and the next 15-minute tick puts it back. The applier was deliberately not given a Brave-is-running gate. Never quit or kill Brave to make a write land: quitting triggers the shutdown serialise, which is the very thing that discards the write. See RESEARCH.md finding 4.

The genuinely dangerous case is a malformed file, not a concurrent one, and it is guarded separately (next question).

What if the writer corrupts my bookmarks file?

That is the catastrophic failure mode of the whole design, and it is silent. On a parse failure, a version other than 1, or a missing root, Chromium records a metric, loads three empty permanent nodes and behaves like a fresh profile: no dialog, no error, no restore path. Worse, Bookmarks.bak is not a safety net; it is overwritten with whatever was on disk at the first save of the next session, so one restart destroys the last good copy.

Every guard in apply_brave.py exists because of that. Preflight refuses to run (exit 2) if the file is missing, the version is unexpected, a root is absent, Brave Sync metadata is present, account or encrypted bookmark stores exist, or the recomputed checksum does not match the stored one, which means something else is editing. Before writing, it takes its own timestamped backups of both Bookmarks and Bookmarks.bak under ~/.raindrop-sync/backups/, keeps ten, and re-opens the copy to prove it parses. It writes to a temp file on the same volume, fsyncs, renames atomically, fsyncs the directory, then re-reads from disk and verifies parse, checksum, id uniqueness and GUID uniqueness, restoring the backup and exiting 1 if any check fails. If you ever see an empty bar after a write, quit Brave before it saves and restore the newest backup that parses; Troubleshooting has the exact steps. The checksum itself is the piece nobody documents: titles hash as UTF-16LE and everything else as UTF-8, and the writer refuses to touch a file whose stored checksum does not recompute. See RESEARCH.md finding 6.

Why do I have to put the native-messaging manifest in Chrome's directory when I don't use Chrome?

Because that is where Brave looks. On macOS, Brave does not override Chromium's stock product path for native-messaging host lookup, so a manifest under ~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/ is silently ignored and the extension reports "Specified native messaging host not found". The manifest must live under ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/. Google Chrome does not need to be installed; the directory is a path convention. scripts/bootstrap.sh installs the manifest into both directories, Chrome's because it works and Brave's in case a future Brave honours its own path. This was a single-variable experiment: the launch_context.cc line Can't find manifest for native messaging host appears with the manifest in Brave's directory and disappears when it is copied to Chrome's. One caveat from SETUP.md: it was verified on a machine where Chrome's directory already existed, so bootstrap creating that directory from scratch is untested. See RESEARCH.md finding 2.

Why is my extension id different from every id in the docs?

Because it is yours. The id is the first 16 bytes of the SHA-256 of your DER-encoded public key, rendered as hex with each nibble mapped from 0-f to a-p, so it is 32 letters shaped like abcdefghijklmnopabcdefghijklmnop, derived from the key.pem that bootstrap.sh generated for you. Every id in this wiki and in the repo is a placeholder of the right shape. Three things depend on your real one: the key field in manifest.json, the allowed_origins entry in both host manifests, and the id shown on the card after Load unpacked. If the card shows a different id, the manifest lost its key and the id was derived from the install path instead. Back up key.pem (mode 0600) and never commit it: losing it means a new id and an update to every copy of the host manifest.

Does it work on Chrome or Edge?

It should, and it is untested; that is exactly the kind of report the roadmap board wants. Other Chromium browsers use the same Bookmarks format and the same chrome.bookmarks API. The code is already client-aware: the service worker reports which browser it runs in (Brave is detected with "brave" in navigator, since its user agent deliberately claims to be Chrome; Edge by its Edg/ token; anything else as chrome), and the native host keeps a separate applied-set per client, keyed (client, raindrop_id), so the same staged item can land in more than one browser. Without that key the first browser to sync would record the item and every other browser would be told nothing is pending. The file writer defaults to Brave's profile directory and can be pointed elsewhere with RAINDROP_SYNC_PROFILE. Windows and Linux are a different matter: launchd, the macOS keychain and macOS profile paths are baked in, and nothing is portable as written. Run it on a throwaway profile and report what you saw, positive or negative, on the roadmap board.

Why is Safari impossible?

Not "not yet". Absent. Seven independent routes were tested on macOS 26 and every one is closed: browser.bookmarks in a Safari Web Extension is compile-gated out of WebKit and the symbol is absent from every shipped dyld subcache; AppleScript has no bookmark class and the terminology will not compile (errors -2741 and -2740); Shortcuts has no bookmark action; there is no URL scheme; safaridriver exposes no bookmark endpoint; the declarative device-management path needs MDM enrolment, and profiles can no longer install configuration profiles on macOS 26; and a direct plist write is guarded by CloudBookmarkDatabaseLockArbiter against an iCloud-backed store. That last one is the point to take seriously even if you find a way around the lock: Safari's bookmark store fans out to every Apple device on the account, so a write made behind Safari's back does not stay local.

The Reading List is the near-miss people chase: add reading list item is scriptable, but the list is flat, effectively write-only, and cannot be cleared in bulk. Do not build on it. The one escape hatch is manual: export a NETSCAPE-Bookmark-file-1 HTML file and import it through Safari's File menu, after exporting your existing bookmarks first. Re-import dedupe behaviour is unknown and was not tested, so treat that as a rare, deliberate act. Each finding went through an adversarial refutation pass and survived; see RESEARCH.md finding 10 and Research Status. Safari support is on the declined list in CONTRIBUTING.md.

Will it interfere with Brave Sync?

It refuses to run while Brave Sync is on. If bookmark sync is enabled, injected nodes trip CorruptionReason::UNTRACKED_BOOKMARK, Brave discards its sync metadata, re-merges, and uploads the injected folders to every device on the chain. The file writer hard-aborts (exit 2) when it sees top-level sync_metadata in the file, logging ABORT: Brave Sync is enabled. External nodes would corrupt sync metadata and upload to every device. Disable bookmark sync or remove this job., and keeps aborting until you turn sync off; nothing is damaged, and nothing applies. The extension writes through the ordinary bookmarks API and carries no such check of its own, so the file writer's abort is the only enforcement; SETUP.md lists bookmark sync being off as a prerequisite to satisfy before you install anything.

The classification side

Why is my Raindrop token needed, and where does it live?

Phase A reads your whole library and writes back to it, creating collections and applying tags, because Raindrop is the source of truth. That needs a token. Raindrop's "test token" from the Integrations page authenticates as you, with no expiry and no scoping, so treat it like a password. The free plan is sufficient.

seed-token.sh stores it in the login keychain under the service name raindrop-api, either by prompting for it with hidden input or, with --from-env, by taking it from $RAINDROP_TOKEN. get-token.sh resolves it in a fixed order: $RAINDROP_TOKEN in the environment (a one-off override), then ~/.raindrop-sync/.env (mode 0600), then the login keychain. Nothing else is consulted; there is no password-manager step. The keychain is the store the helper writes to, and it is preferred over .env for unattended jobs: a login-keychain item with no timeout is readable by a launchd agent without a Touch ID prompt, and an unattended job must never depend on a fingerprint. .env is a deliberate cleartext fallback: any process running as you can read it, the directory is 0700 and the file 0600, and that is the whole of its protection; delete it if that trade is unacceptable, and remember that while it exists it is consulted before the keychain. Two rules follow. Never pass the token as a command-line argument yourself, because argv is readable by every process running as you; the one place the helpers do so is the short-lived security add-generic-password -w call inside seed-token.sh. And never print it: a token that has reached shell history, a scrollback buffer or an agent transcript must be rotated, not "should". get-token.sh prints the token to stdout for a pipe and nothing else, and seed-token.sh confirms only that the keychain item was stored; to check that a token resolves without seeing it, pipe it into wc -c for a length or shasum -a 256 | cut -c1-8 for a one-way fingerprint. The rotation procedure is in SETUP.md step 2: delete the token in Raindrop's Integrations settings, create a new one, and seed it again.

Why can't the classification pass run headless?

Because the classifier is an LLM in an interactive Claude Code session, reading and writing Raindrop through a connector that is not available to a headless run. As built, /raindrop-sync is something you start. The design allows for a daily scheduled fetch agent, and the groundwork is there (the token resolves from the keychain without a fingerprint precisely so that an unattended job could use it), but the scheduled agent is not installed and the README says so. Phase B has no such limit: the extension and the launchd agent run unattended, and everything Phase A produces is a file, so a pass you start in the morning is applied without you.

What is the canary?

classified_count, the number of items the pass classified, logged on every run. On a steady-state run it should be a handful, because only the delta is classified: an item is reclassified only if it is absent from the ledger (new), its content_hash changed (edited), or taxonomy_version moved. If the number is in the dozens or hundreds on a library that barely changed, delta detection is broken, and the run is supposed to say so loudly instead of reclassifying the whole library. The threshold used is about 50.

That one number is the tell for every failure mode in the delta logic: a timestamp watermark (Raindrop bumps lastUpdate on the tool's own writes, so a watermark re-selects everything forever), a hash computed over curated values instead of raw ones (a real bug that shipped and re-flagged 168 items, about 28% of a library, as edited on the next run), a silently widened hash format, or a paging race. The fix is never to recompute hashes from the ledger's cleaned-up title and link columns; those columns and content_hash are meant to disagree, and "repairing" them is how the bug comes back. See Taxonomy and Classification, Troubleshooting and RESEARCH.md finding 7.

The run classified forty items and promoted none. Is that a bug?

No. Raindrop is a library and the bookmarks bar is a curated shortlist, and they deserve different standards. Every classified item gets a collection and tags in Raindrop. Promotion to the bar is adversarial and reject-by-default: deep links that will rot, commentary rather than primary sources, one-off saves of early-stage products, job postings, news articles, and any proposed new folder holding fewer than three qualifying items are all rejected. On the first real run, eight promotions were proposed and seven were rejected. desired.json therefore holds a much narrower set than "everything classified", and a run that promotes nothing is normal. Before assuming a bug, count the entries in ~/.raindrop-sync/desired.json.

How much LLM work does a routine run cost?

The first pass classifies the whole library and takes a while. Every pass after it classifies only the delta, computed with a content hash over the raw link and title. (The hash format keeps excerpt|note|type slots that are always empty, because the connector the classifier reads through does not return those fields; widening the hash to fill them would invalidate every stored hash at once and is a taxonomy_version-class change.) The read side is currently a full read of the library every run, because Raindrop's changedBookmarksDate gate has not been verified to advance on a tag-only edit; reading is cheap, and classification is what costs. If the canary is small, the cost is small.

Do I need a Raindrop Pro plan?

No. The free plan is sufficient. The two Pro-only features, /raindrop/{id}/suggest classification and semantic search, are not used. Semantic search hard-errors on a free account; the exact plan gating of /suggest (expected 402 or 403) is listed as untested in RESEARCH.md, and it is unused either way.

Using half of it

Can I use the apply half without the LLM half?

Yes. Phase B is plain stdlib Python and an unpacked extension, and it runs on a desired.json you wrote yourself. The file is a JSON array of objects with four fields, as read by src/apply_brave.py and src/native_host.py: raindrop_id (the source id; the file writer derives a deterministic GUID from it with uuid5, and the extension's ledger is keyed on it), name, url, and folder_path, a /-separated path relative to the bookmarks bar. A leading Bookmarks Bar component is tolerated and stripped; that tolerance exists because a bug once treated it as a folder to create and built a duplicate tree beside the real one, which a dry run against a copy caught before it reached a live profile. Missing folders are created, and marked on the file-writer side. Skip the token and the first classification pass, and never invoke the skill. Ledger and File Formats documents the file.

Can I run the classification half without touching Brave?

Yes. Phase A never writes a browser file; it writes collections and tags to Raindrop and stages promotions in desired.json. If no applier is running, the staged file simply accumulates. bootstrap.sh installs and loads the launchd agent by default, so unload it (launchctl bootout gui/$(id -u)/com.raindrop-sync.apply) if you want Raindrop classification alone, and skip the extension steps of SETUP.md.

Where do I ask something that is not here?

Discussions for questions, Issues for bugs and research corrections, and Contributing and Roadmap for how to test a change without touching a library you care about. Never include real bookmarks, tokens, collection ids, extension ids, or absolute paths containing a username in anything you post.

Clone this wiki locally