Repository navigation
Architecture
This page follows a bookmark from the moment it is saved in Raindrop.io to the moment it appears on Brave's bookmarks bar, naming every process on the way, what each one is allowed to touch, how the two browser-side appliers avoid duplicating each other, and the timing that governs each hand-off.
The README gives the rules. This page gives the mechanism behind each rule and what follows from it. For the on-disk shapes referenced here (ledger tables, desired.json, Brave's node format) see Ledger and File Formats. For why the ownership rules are safe, see Safety and Ownership Model.
The system is two phases joined by exactly one file.
| Phase A: classify | Phase B: apply | |
|---|---|---|
| What it does | Reads the Raindrop library, classifies the delta with an LLM, writes collections and tags back to Raindrop, stages promotions | Places staged promotions into Brave |
| Runs as | A Claude Code skill, on demand (/raindrop-sync) |
An unpacked MV3 extension plus a launchd agent, unattended |
| Needs | The network, the Raindrop token, a model | Nothing but the local disk |
| Touches | Raindrop, state.db, taxonomy.json, collection-map.json, desired.json
|
Brave's in-memory bookmark model, or Brave's Bookmarks file |
| Never touches | Any file inside the Brave profile | The network, the Raindrop API, the token |
The hand-off is ~/.raindrop-sync/desired.json. Phase A appends to it; both Phase B appliers read it whole on every tick and compute what is missing from the browser. Nothing else crosses the boundary.
Four consequences follow from that split, and each one is load-bearing:
- Phase A can run whether or not Brave is open. It writes no browser file, so there is no race to think about on the classification side.
- Phase B runs with no secret in its environment. The launchd agent and the native host never see the Raindrop token, so an unattended process holds nothing worth stealing.
- A Phase A bug cannot corrupt the browser file, and a Phase B bug cannot touch the library. The blast radius of each half is bounded by the other.
-
Phase B works on a hand-written
desired.json. If you only want the browser half, you can write the staging file yourself and never run a model.
The one asymmetry worth stating: Phase A has no fallback. There is no keyword-rules mode. If the LLM is unavailable, nothing is classified and desired.json simply does not grow; Phase B keeps running and keeps finding nothing new to do.
| Process | Started by | Reads | Writes | Never touches |
|---|---|---|---|---|
Claude Code session running the raindrop-sync skill
|
You, with /raindrop-sync
|
Raindrop (through the connector), state.db tables bookmarks and meta, taxonomy.json, collection-map.json
|
Raindrop collections and tags; state.db tables bookmarks, meta, runs; collection-map.json when a collection is created; desired.json (append); reset_folders.json
|
The Bookmarks file, anything in the Brave profile, the extension_applied table |
Brave, running extension/sw.js as an MV3 service worker |
Brave, on worker load, onInstalled, onStartup, and the sync alarm |
Replies from the native host; the bookmark tree through chrome.bookmarks
|
The in-memory bookmark model through chrome.bookmarks; chrome.storage.local (lastRun) |
Any file, any network endpoint, state.db directly |
native_host.py |
Brave, one fresh process per sendNativeMessage call |
desired.json, reset_folders.json, state.db table extension_applied
|
state.db table extension_applied (creating it if absent); log/native_host.log
|
The Bookmarks file, the network, the bookmarks, meta and runs tables |
apply_brave.py under the launchd agent com.raindrop-sync.apply
|
launchd, every 900 s and at load |
desired.json, Bookmarks, Bookmarks.bak
|
Bookmarks (atomic replace), backups/, applied.json; stdout and stderr go to log/apply.out.log and log/apply.err.log through launchd |
state.db, the network, Raindrop, the extension's ledger |
scripts/bootstrap.sh |
You, once | The checkout | The runtime tree, key.pem, the extension copy with its pinned key, both native-host manifests, the launchd plist, the skill copy |
Anything in the data path; it is an installer, not a runtime component |
Two helpers sit outside the data path: get-token.sh and seed-token.sh resolve and store the Raindrop token. Only Phase A ever consumes the token, and only through a pipe. See Operations for the resolution order.
Phase A is prose, not code: the skill file is a set of rules and a procedure that a Claude Code session follows, using a Raindrop connector for the API calls and the sqlite3 CLI or Python for the ledger. What it does on every run:
The skill pages the whole library with the connector's find_bookmarks, sorted created_asc, 150 per page. Ascending order matters: if a bookmark is saved mid-run, newest-first paging shifts the window and an item falls between two pages. A missed item looks exactly like a deleted one to any deletion logic, which is one of the reasons deletion propagation is off (see Safety and Ownership Model).
The research notes two cheaper designs: gating on GET /rest/v1/user/stats (meta.changedBookmarksDate) to skip the read entirely, and a single export.csv call as an atomic server-side render. Neither is in use. Whether a tag-only edit advances changedBookmarksDate is untested, so every run is a full read. See Research Status.
The skill loads raindrop_id and content_hash for every ledger row, recomputes the hash from the raw values the API just returned, and selects an item for classification if and only if:
- its id is absent from the ledger (NEW), or
- its stored hash differs from the fresh one (EDITED), or
-
meta.taxonomy_versionhas moved since the row was classified.
A timestamp watermark is never used. Raindrop bumps lastUpdate on every write, including this job's own write-back, so a watermark re-selects the whole library on every run and never settles. The hash definition and the raw-versus-curated invariant that keeps it stable are in Ledger and File Formats.
The tree in taxonomy.json goes into the prompt. Each delta item gets exactly one path from collection-map.json and one to four tags from the taxonomy's controlled vocabulary. The routine pass never re-derives the tree; it may extend it with a new collection when nothing fits, which is the required outcome rather than a fallback, and there is no catch-all. See Taxonomy and Classification for the rules and the reasoning.
update_bookmarks is called grouped by (collection, tagset), at most 150 ids per call. Two API behaviours shape this step: Raindrop's tag write appends rather than replaces, so a correction is a read-modify-write; and add and remove cannot be combined in one call, so they are split. Raindrop is the source of truth after this step: the classification is stored there, not only in the ledger.
Only a small, heavily filtered subset of classified items goes to the browser. Each survivor becomes one entry in desired.json:
{"raindrop_id": 12345, "name": "Dovetail Joints and Layout", "url": "https://example.com/dovetail", "folder_path": "Raindrop/Woodworking"}folder_path is relative to the bookmarks bar, and the skill keeps it flat: one level under the managed folder, because every extra level of nesting is one more hover menu. It is normal for a run to classify many items and promote none.
The row's content_hash is written only after the item has been committed to desired.json, and a row is appended to runs with classified_count. The ordering is deliberate: a crash between the two costs one redundant reclassification on the next run; the other order would produce a silent skip that nothing ever flags again. classified_count is the canary; on a steady-state run it should be a handful, and a value above about 50 means delta detection is broken, not that the library changed.
It is not scheduled. The connector it reads through is not available to a headless run, so as built, classification is something you start. The design leaves room for a daily fetch agent, but that agent is not installed. And it never opens the Bookmarks file, not even to read it.
Phase B is two appliers, both live at once. They differ in where they write: the extension writes into Brave's running in-memory model through chrome.bookmarks; the launchd agent rewrites the Bookmarks file on disk. That difference decides everything else about them.
Brave reads Bookmarks exactly once, at profile load, into an in-memory model that is thereafter authoritative. There is no file watcher. Roughly 2.5 s after any bookmark change, and again at quit, Brave serialises its whole in-memory tree over the file. So:
- A node written to the file while Brave runs is invisible to Brave and is discarded the next time Brave serialises.
- A node written to the file while Brave is closed is picked up in full at the next start.
- A node created through
chrome.bookmarksgoes into the model directly, is visible immediately, and is what Brave serialises.
The extension is therefore the primary path (instant, no race) and the file writer is the fallback that covers Brave being closed or the extension being disabled. Each one alone leaves a gap; together they cover every state Brave can be in.
The extension is plain MV3 with no build step: manifest.json declares the bookmarks, nativeMessaging, alarms and storage permissions and a single service worker, sw.js. It is loaded unpacked, with a key pinned into the manifest by bootstrap.sh so that the extension id is a function of your keypair rather than of the folder's absolute path.
Entry points. Four things start a sync: the worker's top-level code running (every time the worker is spawned), chrome.runtime.onInstalled, chrome.runtime.onStartup, and the sync alarm. In MV3 these are not ordered relative to each other; worker load and onInstalled have been observed firing in the same millisecond. Every entry point therefore goes through one promise chain:
let chain = Promise.resolve();
function serialize(fn) {
const next = chain.then(fn, fn);
chain = next.catch(() => {});
return next;
}Concurrent callers queue behind the running sync instead of racing it. chain.then(fn, fn) runs the next task even if the previous one rejected, and the separate .catch keeps the chain alive while still surfacing the rejection to the caller. The lock protects within one worker instance only; MV3 kills idle workers, so cross-instance safety comes from the existence check plus the durable ledger outside the browser.
The alarm. chrome.alarms.create("sync", { periodInMinutes: 1 }) is issued on every worker start, not only in onInstalled. Creating an alarm with an existing name replaces it, so this is idempotent, and it guarantees that a changed period takes effect even when onInstalled does not fire.
One sync, step by step (runSync in sw.js):
-
chrome.runtime.sendNativeMessage(HOST, { op: "pending", client: CLIENT }). If the host is unreachable the sync logs a warning and returns; nothing else happens. - For each folder name in the reply's
reset_folders, remove that folder outright if it is a direct child of the bookmarks bar (chrome.bookmarks.removeTree). This is the only destructive operation in the whole system, and it runs only for a folder the host explicitly names; see Safety and Ownership Model. - For each item in
items: ifchrome.bookmarks.search({ url })returns a node whoseurlmatches exactly, recordalready_presentand move on. Otherwise resolvefolder_pathunder the bar (find each segment by title among folder children, create it when missing) andchrome.bookmarks.create({ parentId, title, url }). Errors are caught per item and recorded aserror. -
sendNativeMessage(HOST, { op: "applied", client: CLIENT, results })so the host can write the ledger. - Store a
lastRunsummary inchrome.storage.localand log a one-line count.
The bookmarks bar is located by folderType === "bookmarks-bar" on a root child, falling back to the node with id "1", then to the first child. Path resolution strips empty segments, the bar's own title and the literal Bookmarks Bar, for the same reason the file writer does (below).
What it cannot do. chrome.bookmarks.BookmarkTreeNode has no custom metadata field. The extension cannot stamp its nodes with the file format's meta_info marker and cannot read one. Ownership of its work therefore lives outside the browser, in the extension_applied table, written by the host.
The extension talks to native_host.py over standard input and output, using Chromium's native messaging protocol. Brave finds the host through a manifest, com.raindrop_sync.host.json, which bootstrap.sh installs into two directories: Chrome's NativeMessagingHosts directory, which is the one Brave actually reads on macOS, and Brave's own, in case a future Brave honours it. The manifest names the host, gives an absolute path to the script, declares "type": "stdio", and allowlists exactly one origin, chrome-extension://<your-id>/, trailing slash included.
Transport. Each message is a frame: a 4-byte unsigned length in the host's native byte order (Python's struct.pack("@I", n)), followed by n bytes of UTF-8 JSON. A 13-byte body is prefixed by 0d 00 00 00. The host reads frames in a loop until it gets fewer than four bytes, logs stream closed, and exits 0. Nothing may be written to stdout except frames; a stray print() corrupts the stream and Brave drops the connection without saying why. Every diagnostic goes to ~/.raindrop-sync/log/native_host.log.
Lifecycle. sendNativeMessage is one-shot: Brave spawns the host, writes one frame, reads one reply and closes the pipe. So a tick with nothing pending spawns the host once; a tick with pending items spawns it twice (pending, then applied). Each spawn lasts about 30 ms. The healthy log shape, once a minute, is host start (pid N), pending[brave]: 0 of M staged, stream closed.
Operations. The host understands three op values.
| Request | Reply | Notes |
|---|---|---|
{"op": "ping"} |
{"ok": true, "version": "<VERSION from src/native_host.py>"} |
Used by the manual test in SETUP.md, which pipes a frame straight into the script and bypasses Brave |
{"op": "pending", "client": "brave"} |
{"items": [...], "reset_folders": [...]} |
items is every desired.json entry whose raindrop_id has no row with status created or already_present for this client. reset_folders is the contents of reset_folders.json, included only when items is non-empty. With no desired.json, the reply is {"items": []}; with an unreadable one, {"items": [], "error": "desired unreadable"}
|
{"op": "applied", "client": "brave", "results": [...]} |
{"ok": true, "recorded": n} |
Each result is {raindrop_id, status, bookmark_id?, created_folders?, error?}; the host upserts one extension_applied row per result, copying url from desired.json
|
| anything else | {"ok": false, "error": "unknown op"} |
Logged |
Folder resets are not an op of their own: they ride in the pending reply, driven by reset_folders.json. The host reads that file but does not delete it. Its own comment describes the file as consumed once and then cleared, and nothing in the host or the worker performs the clearing, so treat it as a one-shot file you remove yourself once the rebuild has landed. If it is left in place, the next tick that has pending items will tear the named folder down again, and only the items still pending will be re-created. See Ledger and File Formats for the file's shape.
Client normalisation. The worker reports which browser it is: "brave" in navigator means Brave (its user agent deliberately claims to be Chrome, so UA sniffing gets this wrong by design), Edg/ in the user agent means Edge, otherwise Chrome. The host lower-cases the value and accepts brave, chrome, edge, vivaldi or opera; anything else, including a missing field from a worker running older code, is stored as unknown. The degradation is benign: an unknown client costs one redundant already_present and nothing else.
The fallback is apply_brave.py, run by launchd as com.raindrop-sync.apply. The plist, generated from launchd/com.raindrop-sync.apply.plist.template, sets StartInterval 900, RunAtLoad true, ProcessType Background, LowPriorityBackgroundIO, a fixed PATH, and redirects stdout and stderr to log/apply.out.log and log/apply.err.log. The interpreter is /usr/bin/python3 because the Homebrew bin symlink moves on upgrade and silently breaks the agent.
One run, in order:
-
Nothing to do? No
desired.json, or an empty list: log it and exit 0. Nothing else is opened. -
Preflight. Abort with exit 2, logging the reason, if
Bookmarksis missing (it is never created), ifAccountBookmarks,EncryptedBookmarks2orEncryptedAccountBookmarks2exists in the profile, ifversionis not 1, if any of the rootsbookmark_bar,other,syncedis missing, if a top-levelsync_metadatakey is present (Brave Sync is on), or if the recomputed checksum differs from the stored one (something else is editing the file). -
Index the tree. Walk all three roots and collect every
id, everyguid(with its node), and everyurl. -
Decide what is missing. For each
desired.jsonentry, derive its GUID,uuid5(NAMESPACE_DNS, "url:<raindrop_id>"). An entry is already present if a node with that GUID exists and carries the marker, or if its URL exists anywhere in the tree. Everything else is to-do. If the to-do set is empty, logall N promotions already present and marked; no write neededand exit 0 without writing. -
Back up. Copy
BookmarksandBookmarks.bakintobackups/with a UTC timestamp, re-open the copy to prove it parses, prune old copies. -
Place nodes. For each to-do entry, resolve
folder_pathunderbookmark_barwithfind_folder, which strips a leading segment equal to the bar's own name,Bookmarks Bar, or empty, then walks or creates marked folders segment by segment. Append a marked URL node. New nodes getid: Nonefor now. - Assign ids. Walk the three roots in tree order and give every id-less node the next integer above the current maximum.
-
Checksum, then atomic write. Compute the checksum,
mkstempin the profile directory (same volume), write, flush,fsync,chmod 0600,os.replace,fsyncthe directory. Any exception unlinks the temp file, restores the backup, and exits 1. - Verify from disk. Re-read the file and assert: it parses, the checksum recomputes, ids are unique, GUIDs are unique, and every wanted entry landed (by GUID or URL). Any failure restores the backup and exits 1.
-
Record and report. Write
applied.json, log each created folder andOK: N applied, M total marked nodes present. Ifpgrep -x "Brave Browser"finds a running Brave, addNOTE: Brave is running. It may overwrite this from memory; the next tick re-applies.
The writer only ever appends under bookmark_bar. It reads other and synced for id and URL indexing and for uniqueness checks, and never adds to them.
The find_folder stripping rule exists because of a real bug caught on a copy of a live profile: an earlier version treated a leading Bookmarks Bar segment as a folder to create and built a duplicate nested tree beside the user's real folders. Always dry-run the writer against a copy; see Operations.
The race, and why it is not fought. The writer does not wait for Brave to close. If Brave is running, its next serialise may drop the nodes the writer just added. That costs only the pending additions, never the user's bookmarks, because both writers are atomic (Python's os.replace on one side, Chromium's ImportantFileWriter rename on the other) so one version always wins whole, and the user's bookmarks live in Brave's memory, not in the losing file. The next tick sees the URLs missing and writes again. The write sticks on the first quiet tick and is certain to stick once Brave restarts and reads the file. Killing Brave to win the race would trigger the quit-time serialise, the very thing that discards the write.
The obvious design, one shared ownership marker, is impossible: the file writer marks its nodes with meta_info: {"raindrop_sync": "v1"} and the extension physically cannot write or read that field. A marker-only check in the file writer would therefore not see extension-created nodes and would re-create every one of them. The fix has two halves.
Each applier recognises the other's work by URL. The file writer's already-present test is (GUID present and marked) OR (URL present anywhere in the three roots). The extension's test is chrome.bookmarks.search({ url }) with an exact url match. Neither cares who created the node it finds. Both comparisons are exact string comparisons of the url value in desired.json against the stored URL.
Each applier keeps its own ledger. The file writer's record is the marker plus a deterministic GUID inside the Bookmarks file itself, and applied.json beside it. The extension's record is the extension_applied table in state.db, written by the host. The two never read each other's ledgers; they only need to agree on the URL.
How that plays out in each state Brave can be in:
| Brave | Extension | What happens |
|---|---|---|
| Running | Enabled | The extension creates the node within a minute. The writer's next tick finds the URL in the file (once Brave has serialised) and does not write. |
| Running | Disabled or broken | The writer adds a marked node to the file. Brave may discard it on its next serialise; the writer retries every 15 minutes until a quiet tick or a restart lets it stick. |
| Closed | Any | The writer's marked node is read at the next start. When the extension next syncs, search({ url }) finds it and records already_present. |
| Running, both fire at once | Enabled | Worst case, the writer's file node is discarded by Brave's serialise while the extension's model node persists. The writer's next tick sees the URL and stops. It converges. |
Neither applier deletes. The writer's ownership rule permits removing marked nodes and nothing else, and the current code has no removal path at all. The extension's single removal is the explicit folder reset described above.
extension_applied is keyed (client, raindrop_id), not raindrop_id alone. The reason is that desired.json is one shared list of promotions, while every browser that runs the extension must apply that list independently: a bookmark created in Brave does not exist in Chrome. The host answers "what is pending" relative to the asking client, so each browser sees the full list minus what that browser has already done.
With a bare raindrop_id key, the first browser to sync would record the item and every other browser would be told nothing is pending. The item would silently never arrive anywhere else, and no log line would say so. That is why the worker reports client, why Brave detection uses "brave" in navigator rather than the user agent, and why an unrecognised or missing client is stored under unknown rather than rejected: the worst case for an old worker is one redundant already_present row.
Two edges of this design are worth knowing. The file writer has no client concept because its ledger is the profile's own Bookmarks file, which is per profile by construction (RAINDROP_SYNC_PROFILE selects it). And the client key is the browser brand, not the profile: two profiles of the same browser on one machine would both report brave and share one applied-set. The sources do not cover that case.
Take a page about dovetail joints, saved to Raindrop on a Tuesday.
- Save. The item sits in Raindrop's Unsorted with a raw title and a raw link, possibly with tracking parameters. Nothing local knows about it yet.
-
Run Phase A. You invoke
/raindrop-sync. The skill pages the library ascending, finds the id absent from the ledger, and marks it NEW. -
Classify. With the pinned taxonomy in the prompt, the item lands in
Woodworking(or in a new collection, if nothing fits) with one to four vocabulary tags. Provenance such as a short link becomes a tag; it never drives placement. -
Write back. The skill moves the item into the
Woodworkingcollection in Raindrop and appends the tags. Raindrop'slastUpdateon the item now changes, which is exactly why the ledger ignores timestamps. - Gate. The promotion gate decides whether this belongs on a curated bar. Most items stop here and lose nothing. Say this one passes.
-
Stage. One entry is appended to
desired.jsonwith the curated title asname, the URL, andfolder_path: "Raindrop/Woodworking". -
Commit. The ledger row is written, hash last, and a
runsrow records the count. -
Extension tick. Within 60 seconds, if Brave is open, the alarm fires. The host reports the item pending for
brave. The worker finds no bookmark with that URL, resolves or createsRaindrop/Woodworkingunder the bar, creates the bookmark, and reportscreated. The host records(brave, raindrop_id). The bookmark is on the bar now, with no file write at all. -
Brave serialises. About 2.5 s later Brave writes its in-memory tree, including the new node, to
Bookmarks. -
Writer tick. Within 15 minutes the launchd agent runs, reads
desired.json, finds the URL present in the file, and exits 0 without writing.
If Brave was closed at step 8, steps 8 through 10 reorder: the writer adds a marked node to the file, Brave reads it at the next start, and the extension's first sync of that session records already_present.
| Component | Cadence | Basis |
|---|---|---|
| Phase A | On demand, when you run the skill | As built; no scheduler is installed |
| Extension alarm | Every 1 minute (PERIOD_MINUTES = 1), plus an immediate sync on every worker start |
The project's research records one minute as the floor chrome.alarms allows, so this is as fast as the alarms API goes. Genuinely instant delivery would need a long-lived connectNative port, which MV3's idle-worker teardown fights |
| Native host | Spawned per message, about 30 ms | Observed |
| launchd writer | Every 900 s, plus once at load (RunAtLoad) |
Plist |
| Brave, reading the file | Once, at profile load | Verified (research finding 4) |
| Brave, writing the file | About 2.5 s after any bookmark change, and at quit | Verified for the behaviour; the constant is a Chromium internal and should not be depended on |
| Time to visible, Brave open with extension | Under a minute | Follows from the alarm floor |
| Time to visible, Brave closed | The next Brave start, after the writer's next tick | Follows from finding 4 |
sequenceDiagram
autonumber
participant U as You
participant R as Raindrop.io
participant A as Phase A skill
participant DB as state.db
participant D as desired.json
participant X as Extension sw.js
participant H as native_host.py
participant M as Brave bookmark model
participant L as launchd apply_brave.py
participant F as Bookmarks file
U->>R: save a bookmark
U->>A: /raindrop-sync
A->>R: page library, ascending
A->>DB: compare content_hash per id
A->>A: classify the delta into the pinned taxonomy
A->>R: write collection and tags
A->>D: append promotion (raindrop_id, name, url, folder_path)
A->>DB: write content_hash, insert runs row
loop every 60 s while Brave is open
X->>H: op=pending, client=brave
H->>D: read
H->>DB: SELECT applied rows for client
H-->>X: items, reset_folders
X->>M: search(url), create if absent
X->>H: op=applied, results
H->>DB: INSERT OR REPLACE extension_applied
M-->>F: Brave serialises about 2.5 s later
end
loop every 900 s
L->>D: read
L->>F: preflight, index URLs and marked GUIDs
alt everything present
L-->>L: exit 0, no write
else something missing
L->>F: backup, add marked nodes, atomic replace, verify
end
end
F-->>M: read once, at the next Brave start
Every path in the runtime is overridable so the same code can run against a throwaway copy. RAINDROP_SYNC_ROOT moves the state directory for both apply_brave.py and native_host.py; RAINDROP_SYNC_PROFILE points the writer at a different Brave profile directory. bootstrap.sh additionally honours RAINDROP_SYNC_NMH_CHROME, RAINDROP_SYNC_NMH_BRAVE, RAINDROP_SYNC_LAUNCHAGENTS, RAINDROP_SYNC_PYTHON, RAINDROP_SYNC_SKILLS and SKIP_LAUNCHCTL=1. The dry-run procedure that uses them is in Operations, and the failure signatures of each component are in Troubleshooting. Terms used on this page are defined in Glossary.
MIT licence | raindrop-brave-sync on GitHub | Roadmap board
raindrop-brave-sync
Start here
How it works
Running it
Research
Community