Part 1 of 3 · Web extensions from first principles
Anatomy of a web extension
We wanted to record the trail of how you read across the web — every page you land on, every link you follow. Doing that means building a browser extension. So first: what is one actually made of, and how do the parts cooperate to turn a click into a saved fact?
Think about the last thing you read online that mattered. You almost certainly did not type its address. You arrived there — a search result, then a link in that page, then a link someone quoted, maybe a new tab you opened to keep the first one. The thing you read is saved in your history. The path you took to reach it — which page sent you where, what you opened alongside what — is gone the moment you move on.
That path has a name worth keeping: your reading provenance. It is the difference between “I read an article about X” and “I read it because a paper I trust linked it, while comparing it against a tab I’d opened from a different search.” For a memory system that wants to understand how you think, the path is often worth more than the destination.
Browsers do not hand you that path. The only software allowed close enough to the act of reading to record it is a browser extension. This series is the story of building one — for Safari specifically — and of the things Safari taught us that no amount of planning could have. But a story about building an extension is unreadable if you don’t know what an extension is. So this first part is a guided tour of the machine. By the end you will know its organs by name and have watched them work together.
So what is a web extension, really?
Strip away the jargon and a web extension is a small bundle of ordinary web code — HTML, CSS, JavaScript — that the browser agrees to run with powers a normal web page is never given. A page you visit is a guest: it can draw inside its own window and nothing more. An extension is more like a member of staff. It can watch every page you visit, run its own code inside those pages, listen for browser-wide events like “a new tab opened,” keep private storage, and reach the network on its own behalf.
Those powers are exactly why an extension is the only thing that can capture reading provenance — and exactly why the browser fences them off behind a permission system. An extension does not simply take those powers; it declares the ones it needs, and (on Safari especially, as Part 2 will show) the user must agree. The modern rulebook for how extensions are packaged and what they may do is called Manifest V3 (MV3). Chrome, Edge, Firefox and Safari all implement it — mostly the same way. The “mostly” is where Parts 2 and 3 live.
Crucially, an extension is not one program. It is a small society of cooperating pieces, each living in a different place with a different view of the world — one inside the page, one running in the background, a manifest that introduces them, a permission list that empowers them. They talk by passing messages. Understanding an extension means understanding that division of labour. Let’s meet the members.
The parts, and what each one does
Click any part to see what it is, what it does in our extension, and where it lives in the code.
Here are the organs of our extension (plus the one piece that lives just outside it). Each is small; the design only works because of how they divide the labour. Tap through them — then watch them run together in the next section.
Two of these deserve a closer look before we see them move, because they are the ones a newcomer most often misunderstands: the background worker and the content script. They exist as two separate things for one deep reason — they live in different worlds and can see different things.
The content script runs inside the web page. It shares the page’s document — it can see the headline, read the links, and feel a mouse click the instant it happens. But it is sandboxed: it cannot reach the network freely or hold durable storage.
The background worker runs outside every page, in its own private context. It has the real powers — network, storage, the browser-wide event feeds — but it is blind to what happens inside any page. It never sees your click on a link.
Neither can do the job alone. So the content script, which can see the click, shouts it across a little bridge to the background worker, which can act on it. That bridge — message passing — is where a very expensive bug hid (Part 3).
How the parts cooperate
Abstractions blur; a worked example sticks. Below, pick something you might do in a browser and watch the fact travel from the thing you did all the way to a line saved on disk. The two flows look different on purpose — that difference is the two-worlds rule made concrete.
Notice what the “click a link” flow has that the “open a page” flow does not: two extra steps at the front —inside the page, then cross to the background. That is the content script seeing what the background can’t, then handing it over. A page visit needs no content script at all, because the browser itself offers a background-level event for navigation; a link click has no such event, so we must catch it in the page. This is the single most useful intuition for reading any extension: ask where each piece of information first becomes visible. In-page facts (clicks, form input, scroll) start in a content script; browser-frame facts (navigation, tabs, alarms) start in the background.
From events to facts
The extension’s whole job is to turn fleeting browser events into durable facts — small, self-describing records it can save and later reason over. A fact is just a labelled map of data. Three kinds carry reading provenance:
- ▤
:page/visited - You landed on a page. Carries the URL, the tab it happened in, and when. events.cljs:7 builds this from the browser’s navigation event.
- ▦
:link/clicked - You activated a link. Carries where you were (
:from/url), where it points (:to/url), and any modifier keys held (⌘, ctrl, middle-click). - ◈
:tab/opened - A new tab appeared. Meant to carry which tab/page spawned it — its opener. This one has a sad ending in Part 3.
Each fact is built by a tiny, pure function with a name that reads like its
job: commit->fact, message->fact,
created->fact.
All three live in one file, deliberately kept free of any browser call so they can be tested on their own. events.cljs:7,18,27
Keeping the decision (“what fact does this event become?”)
separate from the browser plumbing (“how do I hear the
event?”) is the move that let us test almost everything without a
browser at all — and, just as importantly, made it obvious which slivers
could only be tested in Safari. Part 2 is about exactly those
slivers.
Why it needs a memory of its own
One more surprise about the background worker, and it shapes everything. Despite the name, it is not always running. To save battery and memory, the browser shuts the worker down whenever it is idle and starts a fresh copy when the next event arrives. Any variable it was holding in memory is simply gone. This is the biggest mental shift from a normal program. A background worker is less a running process than a set of reflexes: it wakes, handles one event, and may vanish before the next. Code that assumes it stays alive is the classic MV3 mistake.
So the extension cannot keep its growing list of facts in a variable — it would evaporate between clicks. It needs durable storage (a small key-value store the browser persists for it), and it must reload its state every time it wakes. It also can’t set an ordinary timer to flush facts periodically, because a timer dies with the worker; it must ask the browser to wake it on a schedule (an “alarm”).
Between the events arriving and the facts being saved sits one more piece of care: because the worker can be handling several events at once, the writes to storage are run through a single serialized queue — one change at a time, in order — so two facts can never collide and overwrite each other. The queue, the durable buffer, and the periodic flush are the unglamorous machinery that makes “capture a fact” actually reliable. queue.cljs:18 · store.cljs:20 Facts accumulate in this buffer; on a timer the worker bundles the unsent ones and POSTs them to a small local program — the receiver — which appends each to a file. That file is the permanent record of your reading provenance, and (as Part 2 will show) it doubles as our way of checking the whole machine actually works.
An extension is a society of parts: a manifest that declares it, permissions that empower it, a background worker with the real powers but no view into pages, content scripts that live inside pages and message what they see, browser event APIs for its senses, storage for a memory that survives the worker dozing off, and a toolbar surface the user sees. They cooperate to turn a click into a saved fact.
What we don’t yet know
Everything above is the design. We built it carefully, and tested every pure decision without a browser. But an extension is only real when it runs in a real browser — and Safari, it turns out, has opinions. Does the localhost flush even get through its security gates? Does an automated test window actually load our extension? Does a click captured “inside the page” survive the trip across the bridge?
Part 2 is where the design meets the machine. Some of it passed on the first try. Some of it did not — and the not is the interesting part.