<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ context.dev - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ context.dev - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 25 Jul 2026 22:27:58 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/context-dev/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI-Powered, Local-First Chrome Extension That Turns Your Browsing History into an Intent Map ]]>
                </title>
                <description>
                    <![CDATA[ Your browser remembers every page you've ever opened, but it has no idea why you opened any of them. You might spend three days comparing laptops across a dozen tabs, get distracted, come back a week  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-powered-local-first-chrome-extension/</link>
                <guid isPermaLink="false">6a357903529dee82e5b4624b</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ context.dev ]]>
                    </category>
                
                    <category>
                        <![CDATA[ claude ]]>
                    </category>
                
                    <category>
                        <![CDATA[ indexeddb ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Fri, 19 Jun 2026 17:14:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/26289969-a243-46ff-87aa-095d4168bf17.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your browser remembers every page you've ever opened, but it has no idea why you opened any of them.</p>
<p>You might spend three days comparing laptops across a dozen tabs, get distracted, come back a week later, and your history just shows a flat list of timestamps and titles, with no sense that those visits were one thing, a decision you started and never finished.</p>
<p>In this tutorial, you'll build <strong>openloops</strong>, an open-source, local-first Chrome extension that fixes this by scanning your browsing history and grouping it into "intent threads" – the decisions, research, and open questions you keep coming back to – then scoring each one for how alive it still is. Optionally, it also uses Claude to label those threads in plain language, suggest a concrete next step, and power a chat assistant you can ask "what should I close this week?"</p>
<p>By the end, you'll have built:</p>
<ul>
<li><p>A Manifest V3 Chrome extension with a service worker and a full-tab dashboard</p>
</li>
<li><p>A local pipeline that captures, cleans, segments, and clusters browsing history entirely in IndexedDB</p>
</li>
<li><p>A clustering algorithm tuned and debugged on real (messy) browsing data</p>
</li>
<li><p>An AI labeling layer using Claude, with a grounding step that uses brand data from context.dev</p>
</li>
<li><p>A chat assistant that reasons across your threads and tells you what to do next</p>
</li>
<li><p>A polished dashboard with onboarding, a design system, and a working pipeline status machine</p>
</li>
</ul>
<p>Everything runs on-device, and the only network calls are optional and opt-in, made with your own API keys.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-openloops-is-structured">How openloops Is Structured</a></p>
<ul>
<li><p><a href="#heading-the-shared-types">The shared types</a></p>
</li>
<li><p><a href="#heading-the-manifest">The manifest</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-scaffold-the-extension">How to Scaffold the Extension</a></p>
</li>
<li><p><a href="#heading-how-to-capture-your-browsing-history">How to Capture Your Browsing History</a></p>
<ul>
<li><p><a href="#heading-a-few-shared-helpers">A few shared helpers</a></p>
</li>
<li><p><a href="#heading-the-database-layer-so-far">The database layer (so far)</a></p>
</li>
<li><p><a href="#heading-capturing-new-visits-live">Capturing new visits live</a></p>
</li>
<li><p><a href="#heading-backfilling-14-days-of-history">Backfilling 14 days of history</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-turn-noise-into-sessions">How to Turn Noise into Sessions</a></p>
<ul>
<li><p><a href="#heading-filtering-out-noise">Filtering out noise</a></p>
</li>
<li><p><a href="#heading-extracting-keywords">Extracting keywords</a></p>
</li>
<li><p><a href="#heading-extending-the-database-for-sessions">Extending the database for sessions</a></p>
</li>
<li><p><a href="#heading-segmenting-events-into-sessions">Segmenting events into sessions</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-cluster-sessions-into-intent-threads">How to Cluster Sessions into Intent Threads</a></p>
<ul>
<li><p><a href="#heading-detecting-ambient-domains">Detecting ambient domains</a></p>
</li>
<li><p><a href="#heading-extending-the-database-for-intent-threads">Extending the database for intent threads</a></p>
</li>
<li><p><a href="#heading-clustering-sessions-into-threads">Clustering sessions into threads</a></p>
</li>
<li><p><a href="#heading-scoring-and-classifying-threads">Scoring and classifying threads</a></p>
</li>
<li><p><a href="#heading-putting-it-together">Putting it together</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-clean-up-self-referential-noise">How to Clean Up Self-Referential Noise</a></p>
<ul>
<li><p><a href="#heading-the-two-problems">The two problems</a></p>
</li>
<li><p><a href="#heading-one-definition-applied-everywhere">One definition, applied everywhere</a></p>
</li>
<li><p><a href="#heading-defending-the-enrichment-boundary-too">Defending the enrichment boundary too</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-label-threads-with-claude">How to Label Threads with Claude</a></p>
<ul>
<li><p><a href="#heading-storing-keys-locally">Storing keys locally</a></p>
</li>
<li><p><a href="#heading-the-first-version-and-how-it-broke">The first version, and how it broke</a></p>
</li>
<li><p><a href="#heading-batching-the-requests">Batching the requests</a></p>
</li>
<li><p><a href="#heading-building-the-prompt-and-merging-results">Building the prompt and merging results</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-ground-labels-with-contextdev">How to Ground Labels with context.dev</a></p>
<ul>
<li><p><a href="#heading-what-the-api-returns">What the API returns</a></p>
</li>
<li><p><a href="#heading-fetching-one-brand">Fetching one brand</a></p>
</li>
<li><p><a href="#heading-enriching-domains-in-batches">Enriching domains in batches</a></p>
</li>
<li><p><a href="#heading-how-grounding-feeds-back-into-labeling">How grounding feeds back into labeling</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-design-the-dashboard">How to Design the Dashboard</a></p>
<ul>
<li><p><a href="#heading-the-three-column-layout">The three-column layout</a></p>
</li>
<li><p><a href="#heading-the-pipeline-state-machine">The pipeline state machine</a></p>
</li>
<li><p><a href="#heading-driving-the-welcome-screen-from-the-same-machine">Driving the welcome screen from the same machine</a></p>
</li>
<li><p><a href="#heading-wiring-the-handlers">Wiring the handlers</a></p>
</li>
<li><p><a href="#heading-the-resume-button">The Resume button</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-build-the-ai-assistant">How to Build the AI Assistant</a></p>
<ul>
<li><p><a href="#heading-grounding-the-conversation">Grounding the conversation</a></p>
</li>
<li><p><a href="#heading-sending-a-message">Sending a message</a></p>
</li>
<li><p><a href="#heading-model-and-effort-controls">Model and effort controls</a></p>
</li>
<li><p><a href="#heading-rendering-replies-and-the-empty-state">Rendering replies and the empty state</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-youve-built-and-where-to-take-it">What You've Built, and Where to Take It</a></p>
<ul>
<li><p><a href="#heading-what-the-privacy-model-adds-up-to">What the privacy model adds up to</a></p>
</li>
<li><p><a href="#heading-where-to-take-it-next">Where to take it next</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping up</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
<ul>
<li><p><a href="#heading-source-code">Source code</a></p>
</li>
<li><p><a href="#heading-core-documentation">Core documentation</a></p>
</li>
<li><p><a href="#heading-services-used">Services used</a></p>
</li>
<li><p><a href="#heading-build-tooling">Build tooling</a></p>
</li>
<li><p><a href="#heading-debugging-tools">Debugging tools</a></p>
</li>
<li><p><a href="#heading-further-reading">Further reading</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>On first run, openloops greets you with a centered welcome screen that walks you through the three pipeline steps:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/70b376c4-e08d-45c3-9526-cad948d7bc08.png" alt="openloops welcome screen, showing the three onboarding steps: scan your history, build sessions, and build your intent map" style="display:block;margin:0 auto" width="3456" height="2162" loading="lazy">

<p>Once you've scanned your history, built sessions, and built the intent map, your browsing reorganizes into status-grouped threads: active, stalled, and dormant. Each one has a confidence score, a plain-language summary, a concrete next step, and a <strong>Resume</strong> button that reopens the exact pages you left off on. The right column holds a chat assistant grounded in your own threads:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/15e4d096-76a0-44f6-9a90-d0bb4de20bb8.png" alt="openloops dashboard showing status-grouped intent threads on the left and an AI assistant chat reasoning about what to close this week on the right" style="display:block;margin:0 auto" width="3456" height="2164" loading="lazy">

<p>That assistant response reasons across the user's actual threads, ranking them by how easy they are to close against how much of a real decision they still need. It also explains why, which is the most novel part of this build, and depends on the context.dev grounding step you'll add later in this tutorial.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you'll need:</p>
<ul>
<li><p><strong>Node 18+</strong> and a Chromium-based browser (Chrome, Brave, Edge, and so on).</p>
</li>
<li><p>Comfort with <strong>TypeScript</strong> and <strong>React</strong>. You don't need to be an expert, but you should be comfortable reading hooks and async/await.</p>
</li>
<li><p>Basic familiarity with <strong>IndexedDB</strong> is helpful but not required, as you'll learn what you need as you go.</p>
</li>
</ul>
<p>Two parts of this build are optional and require your own API key, each with a free tier:</p>
<ul>
<li><p>An <strong>Anthropic API key</strong> (from <a href="https://platform.claude.com/settings/keys">platform.claude.com</a>) for AI labeling and the chat assistant</p>
</li>
<li><p>A <strong>context.dev API key</strong> (from <a href="https://www.context.dev/login">context.dev</a>) for the brand-grounding step</p>
</li>
</ul>
<p>You can build and use the entire core pipeline, capture, clustering, scoring, without either key, since both are additive layers on top of it.</p>
<h2 id="heading-how-openloops-is-structured">How openloops Is Structured</h2>
<p>Before writing any code, it helps to see the whole shape of the thing. Every stage of openloops reads from one IndexedDB store and writes to the next:</p>
<pre><code class="language-plaintext">chrome.history (backfill) ──┐
chrome.tabs.onUpdated (live)─┴─→ raw_events
                                     │  noise filter
                                     ▼
                                  sessions
                                     │  ambient detection + clustering + scoring
                                     ▼
                               intent_threads
                                     │
                                     ▼
                              React dashboard
                                     │  optional, opt-in
                                     ├──→ brand enrichment   (context.dev)
                                     └──→ AI labeling + next step (Claude)
                                              │
                                              ▼  optional, opt-in
                                        AI assistant chat (Claude)
</code></pre>
<p>Each stage is a separate module under <code>src/pipeline/</code>, and each one is independently inspectable: you can open Chrome DevTools, look at <code>raw_events</code>, <code>sessions</code>, or <code>intent_threads</code> directly in the Application tab, and rebuild any single stage without touching the others.</p>
<h3 id="heading-the-shared-types">The Shared Types</h3>
<p>Every stage consumes and produces the same handful of TypeScript interfaces, defined once in <code>src/types.ts</code>:</p>
<pre><code class="language-typescript">// Shared TypeScript interfaces for the openloops pipeline.
// Each stage of the pipeline consumes and produces these types.

export interface RawEvent {
  id: string;
  url: string;
  domain: string;
  title: string;
  visitedAt: number;         // epoch ms
  source: "backfill" | "live";
}

export interface Session {
  id: string;
  events: RawEvent[];
  startedAt: number;
  endedAt: number;
  domains: string[];
  keywords: string[];
}

export interface IntentThread {
  id: string;
  title: string;
  summary?: string;
  nextStep?: string;   // one concrete action to move the thread forward
  sessions: Session[];
  type: "buying" | "research" | "planning" | "learning" | "unclassified";
  confidence: number;        // 0-1
  status: "active" | "stalled" | "dormant";
  firstSeen: number;
  lastSeen: number;
  distinctDays: number;
  signals: string[];
}

export interface Brand {
  domain: string;
  name: string;
  description: string;
  industry: string;
  logoUrl: string;
  brandColor: string;
}
</code></pre>
<p>Most fields on <code>IntentThread</code>, <code>confidence</code>, <code>status</code>, <code>signals</code>, and <code>distinctDays</code> get filled in by pure local heuristics later in this guide, when you cluster and score threads. <code>summary</code> and <code>nextStep</code> stay <code>undefined</code> until the optional AI labeling step, covered after that, fills them in.</p>
<p>This is the pattern that makes the whole project work: the core data model functions on its own, and AI makes it richer.</p>
<h3 id="heading-the-manifest">The Manifest</h3>
<p>openloops is a Manifest V3 extension with three permissions and three host permissions:</p>
<pre><code class="language-json">{
  "manifest_version": 3,
  "name": "openloops",
  "version": "0.0.1",
  "description": "Reconstruct your browsing history into an AI-labeled map of intent threads: active decisions, stalled research, open questions. Fully local.",

  "permissions": ["history", "tabs", "storage"],
  "host_permissions": [
    "https://api.anthropic.com/*",
    "https://api.context.dev/*",
    "https://logos.context.dev/*"
  ],

  "background": {
    "service_worker": "src/background.ts",
    "type": "module"
  },

  "options_page": "src/dashboard/index.html",

  "icons": {
    "16": "icons/icon16.png",
    "32": "icons/icon32.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },

  "action": {
    "default_title": "openloops",
    "default_icon": {
      "16": "icons/icon16.png",
      "32": "icons/icon32.png"
    }
  }
}
</code></pre>
<p>The permissions, host permissions, and <code>options_page</code> entry each carry specific weight:</p>
<ul>
<li><p><code>permissions: ["history", "tabs", "storage"]</code> are the only permissions the <em>core pipeline</em> needs. <code>history</code> reads your browsing history for the backfill, <code>tabs</code> lets the service worker observe new page loads and lets "Resume" reopen tabs, and <code>storage</code> is where API keys and preferences live.</p>
</li>
<li><p><code>host_permissions</code> are separate, and only matter if you use the optional AI features. They're what let the dashboard make <code>fetch()</code> calls to Anthropic and context.dev without hitting CORS errors.</p>
</li>
<li><p><code>options_page</code> points at the dashboard. Setting it this way, instead of a <code>default_popup</code>, means clicking the toolbar icon opens the dashboard as a full browser tab rather than a tiny popup, which matters once you're looking at a multi-column layout with status-grouped cards and a chat panel.</p>
</li>
</ul>
<h2 id="heading-how-to-scaffold-the-extension">How to Scaffold the Extension</h2>
<p>Start with Vite and the <a href="https://crxjs.dev/vite-plugin">CRXJS plugin</a>, which compiles a Manifest V3 extension with hot module reloading:</p>
<pre><code class="language-bash">npm create vite@latest openloops -- --template react-ts
cd openloops
npm install @crxjs/vite-plugin idb react-markdown
</code></pre>
<p>Your <code>vite.config.ts</code> wires CRXJS to your <code>manifest.json</code>, and from there, Vite handles compiling <code>src/background.ts</code> to a real <code>.js</code> file that Chrome can load (a raw <code>.ts</code> service worker path in the manifest will fail with a registration error, which we'll debug in the next section).</p>
<p>The dashboard's entry point is a standard React 18 root:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;openloops&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id="root"&gt;&lt;/div&gt;
    &lt;script type="module" src="./main.tsx"&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<pre><code class="language-typescriptreact">import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./app.css";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  &lt;StrictMode&gt;
    &lt;App /&gt;
  &lt;/StrictMode&gt;
);
</code></pre>
<p>Build it, then load it as an unpacked extension:</p>
<pre><code class="language-bash">npm run build
</code></pre>
<p>In Chrome, go to <code>chrome://extensions</code>, enable <strong>Developer mode</strong>, click <strong>Load unpacked</strong>, and select the <code>dist/</code> folder. With nothing else built yet, clicking the toolbar icon should open a blank dashboard tab, and the service worker (visible from the extension card's "service worker" link) should log <code>[openloops] Extension installed.</code> on install.</p>
<p>With that foundation in place, it's time to start filling <code>raw_events</code> with your actual browsing history.</p>
<h2 id="heading-how-to-capture-your-browsing-history">How to Capture Your Browsing History</h2>
<p>Every record in openloops starts life as a <code>RawEvent</code>, the type you saw earlier: a URL, a domain, a title, a timestamp, and a <code>source</code> of either <code>"backfill"</code> or <code>"live"</code>.</p>
<p>Two pipelines populate it:</p>
<ul>
<li><p>A <strong>one-time backfill</strong> that reads your last 14 days of <code>chrome.history</code> on demand</p>
</li>
<li><p><strong>Live capture</strong>, which listens for new page loads from this point forward</p>
</li>
</ul>
<p>Both paths share a handful of small helpers and write through the same IndexedDB layer, so it's worth building those first.</p>
<h3 id="heading-a-few-shared-helpers">A Few Shared Helpers</h3>
<p>Create <code>src/lib/util.ts</code>:</p>
<pre><code class="language-typescript">export function isHttpUrl(url: string): boolean {
  return url.startsWith("http://") || url.startsWith("https://");
}

export function extractDomain(url: string): string {
  try {
    const { hostname } = new URL(url);
    return hostname.replace(/^www\./, "");
  } catch {
    return url;
  }
}

export function isLocalHost(domain: string): boolean {
  if (domain === "localhost" || domain === "127.0.0.1") return true;
  if (domain.endsWith(".local")) return true;

  const octets = domain.split(".");
  if (octets.length === 4 &amp;&amp; octets.every((o) =&gt; /^\d{1,3}$/.test(o))) {
    const [a, b] = octets.map(Number);
    if (a === 10) return true;
    if (a === 172 &amp;&amp; b &gt;= 16 &amp;&amp; b &lt;= 31) return true;
    if (a === 192 &amp;&amp; b === 168) return true;
  }

  return false;
}

export function hashId(url: string, visitedAt: number): string {
  const str = `\({url}|\){visitedAt}`;
  let hash = 5381;
  for (let i = 0; i &lt; str.length; i++) {
    hash = ((hash &lt;&lt; 5) + hash) ^ str.charCodeAt(i);
    hash |= 0;
  }
  return (hash &gt;&gt;&gt; 0).toString(36);
}
</code></pre>
<p>Each of these four functions solves a problem you won't notice until later in the build:</p>
<ul>
<li><p><code>isHttpUrl</code> is the shared scheme guard used by both live capture and the backfill, and the single gate that keeps <code>chrome://</code>, <code>chrome-extension://</code>, <code>about:</code>, and <code>file://</code> URLs out of your data entirely. Both capture paths call it before anything else.</p>
</li>
<li><p><code>extractDomain</code> strips a leading <code>www.</code> and returns the hostname, which is a simplification: <a href="http://bbc.co.uk"><code>bbc.co.uk</code></a> and <a href="http://news.bbc.co.uk"><code>news.bbc.co.uk</code></a> wouldn't collapse to the same domain under this logic, since true registrable-domain extraction needs the <a href="https://publicsuffix.org/">Public Suffix List</a>. If the URL is malformed, it just returns the input unchanged rather than throwing.</p>
</li>
<li><p><code>isLocalHost</code> exists for one reason: when you add brand enrichment later in this guide, you'll be sending domain names to an external API. <code>localhost:5173</code> or <code>192.168.1.50</code> are meaningless to that API and would just be wasted lookups, so it's better to filter them here, once, at the source. It checks for <code>localhost</code>, <code>127.0.0.1</code>, <code>.local</code> hostnames, and the standard private IPv4 ranges (<code>10.x.x.x</code>, <code>172.16.x.x</code>–<code>172.31.x.x</code>, <code>192.168.x.x</code>).</p>
</li>
<li><p><code>hashId</code> combines the URL and timestamp into a short, deterministic string using a simple hashing algorithm (djb2), so the same <code>(url, visitedAt)</code> pair always produces the same ID. This makes writes idempotent: re-running the backfill produces the <em>same</em> IDs for the <em>same</em> visits, so IndexedDB's <code>put</code> overwrites cleanly instead of duplicating, which is what makes "Scan my history" safe to click more than once.</p>
</li>
</ul>
<h3 id="heading-the-database-layer-so-far">The Database Layer (So Far)</h3>
<p>openloops stores everything in IndexedDB via the <a href="https://github.com/jakearchibald/idb"><code>idb</code></a> wrapper, which gives you a typed, promise-based API over the raw IndexedDB calls. Create <code>src/db/index.ts</code>:</p>
<pre><code class="language-typescript">import { openDB, type DBSchema, type IDBPDatabase } from "idb";
import type { RawEvent } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
}

const DB_NAME = "openloops";
const DB_VERSION = 1;

let _db: Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; | null = null;

export function getDB(): Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; {
  if (!_db) {
    _db = openDB&lt;OpenloopsDB&gt;(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
      },
    });
  }
  return _db;
}

export async function clearEvents(): Promise&lt;void&gt; {
  const db = await getDB();
  return db.clear("raw_events");
}

export async function putEvents(events: RawEvent[]): Promise&lt;void&gt; {
  if (events.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("raw_events", "readwrite");
  await Promise.all([...events.map((e) =&gt; tx.store.put(e)), tx.done]);
}

export async function getAllEvents(): Promise&lt;RawEvent[]&gt; {
  const db = await getDB();
  return db.getAllFromIndex("raw_events", "by_visitedAt");
}

export async function getEventCount(): Promise&lt;number&gt; {
  const db = await getDB();
  return db.count("raw_events");
}
</code></pre>
<p>Four small functions round out this first version of the database layer: <code>clearEvents</code> wipes the store, which the backfill calls first so every scan starts from a clean snapshot. <code>putEvents</code> writes a batch using IDB's <code>put</code>, which overwrites rather than duplicates. <code>getAllEvents</code> returns everything sorted by <code>visitedAt</code> via the index. And <code>getEventCount</code> returns a simple count for the dashboard.</p>
<p><code>_db</code> is a module-level singleton promise, so every part of the extension, the service worker and the dashboard alike, shares one connection. <code>DB_VERSION</code> starts at <code>1</code> here. As you add sessions, intent threads, and brand data in later parts, you'll add new stores guarded by <code>if (!db.objectStoreNames.contains(...))</code> and bump this number. That guard means existing users upgrade safely without touching stores that already exist.</p>
<h3 id="heading-capturing-new-visits-live">Capturing New Visits Live</h3>
<p>The service worker is the always-on part of the extension. Create <code>src/background.ts</code>:</p>
<pre><code class="language-typescript">import { hashId, extractDomain, isHttpUrl } from "./lib/util";
import { putEvents } from "./db/index";
import type { RawEvent } from "./types";

chrome.runtime.onInstalled.addListener(() =&gt; {
  console.log("[openloops] Extension installed.");
});

chrome.action.onClicked.addListener(() =&gt; {
  chrome.runtime.openOptionsPage();
});

const DEDUP_MS = 3_000;
const recentCaptures = new Map&lt;number, { url: string; at: number }&gt;();

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) =&gt; {
  if (changeInfo.status !== "complete" || !tab.url) return;

  const url = tab.url;

  if (!isHttpUrl(url)) return;

  const last = recentCaptures.get(tabId);
  const now = Date.now();
  if (last &amp;&amp; last.url === url &amp;&amp; now - last.at &lt; DEDUP_MS) {
    console.log(`[openloops] dedup skip — tab \({tabId} \){url}`);
    return;
  }

  recentCaptures.set(tabId, { url, at: now });

  const event: RawEvent = {
    id: hashId(url, now),
    url,
    domain: extractDomain(url),
    title: tab.title ?? url,
    visitedAt: now,
    source: "live",
  };

  putEvents([event]).then(() =&gt; {
    console.log(`[openloops] captured \({event.domain} — \){event.title}`);
  }).catch((err) =&gt; {
    console.error("[openloops] putEvents failed:", err);
  });
});
</code></pre>
<p><code>chrome.action.onClicked</code> is what makes the toolbar icon open the dashboard as a tab rather than a popup, working together with the <code>options_page</code> entry in your manifest.</p>
<p>Live capture happens inside the <code>tabs.onUpdated</code> listener, which Chrome fires repeatedly as a page loads, redirects, and updates its title, though you should only care about the moment <code>changeInfo.status === "complete"</code>. From there, <code>isHttpUrl</code> drops anything that isn't a real web page, the dedup guard collapses the duplicate "complete" events that SPAs love to fire, and the rest becomes a <code>RawEvent</code> with <code>source: "live"</code>.</p>
<p>That dedup guard is best-effort by design: <code>recentCaptures</code> is a plain in-memory <code>Map</code>, and Chrome can suspend the service worker between events, which wipes the <code>Map</code> along with it. It still collapses duplicate bursts within a single waking session, just not across service worker restarts, and that's an acceptable tradeoff since <code>hashId</code> already makes any duplicate that slips through harmless once it reaches IndexedDB.</p>
<p>The final write also looks slightly unusual: <code>putEvents([event]).then(...).catch(...)</code> instead of <code>await</code>. The listener doesn't need to block on the write finishing, and the service worker stays alive long enough to complete a single IndexedDB write even if it's about to be suspended, so firing the write and moving on is enough.</p>
<p>That <code>source</code> field carries more weight than it first appears, since it's how later code distinguishes "the user actually scanned their history" from "the extension has only been open for five minutes". This matters for onboarding when you design the dashboard later in this guide.</p>
<p>Build and reload the extension now (<code>npm run build</code>, then click the reload icon on the extension card in <code>chrome://extensions</code>), browse a few pages, then open the service worker's DevTools by clicking "service worker" on the extension card. You'll be able to see <code>[openloops] captured ...</code> log lines appear as confirmation that live capture is working.</p>
<h3 id="heading-backfilling-14-days-of-history">Backfilling 14 Days of History</h3>
<p>Live capture only sees what happens <em>after</em> you install the extension, so to make openloops useful immediately, you also need to backfill recent history. Create <code>src/pipeline/backfill.ts</code>:</p>
<pre><code class="language-typescript">import { extractDomain, hashId, isHttpUrl } from "../lib/util";
import { putEvents, clearEvents } from "../db/index";
import type { RawEvent } from "../types";

const CONCURRENCY = 50;

async function visitsForItem(
  item: chrome.history.HistoryItem,
  startTime: number
): Promise&lt;RawEvent[]&gt; {
  if (!item.url) return [];
  if (!isHttpUrl(item.url)) return [];

  const visits = await chrome.history.getVisits({ url: item.url });

  const events: RawEvent[] = [];
  for (const visit of visits) {
    if (!visit.visitTime || visit.visitTime &lt; startTime) continue;

    events.push({
      id: hashId(item.url, visit.visitTime),
      url: item.url,
      domain: extractDomain(item.url),
      title: item.title ?? item.url,
      visitedAt: visit.visitTime,
      source: "backfill",
    });
  }

  return events;
}

export async function backfillHistory(days = 14): Promise&lt;number&gt; {
  await clearEvents();

  const startTime = Date.now() - days * 24 * 60 * 60 * 1000;

  const historyItems = await chrome.history.search({
    text: "",
    startTime,
    maxResults: 100_000,
  });

  let totalWritten = 0;

  for (let i = 0; i &lt; historyItems.length; i += CONCURRENCY) {
    const batch = historyItems.slice(i, i + CONCURRENCY);
    const batchResults = await Promise.all(
      batch.map((item) =&gt; visitsForItem(item, startTime))
    );
    const events = batchResults.flat();
    await putEvents(events);
    totalWritten += events.length;
  }

  return totalWritten;
}
</code></pre>
<p><code>backfillHistory</code> starts by calling <code>clearEvents</code> and wiping the store so each run produces a clean snapshot for the chosen window. Every real visit still exists in <code>chrome.history</code>, so nothing is lost by starting over. It then searches with <code>maxResults: 100_000</code>, since the default of 100 is far too low for anyone with more than a few days of real browsing.</p>
<p>Each matching <code>HistoryItem</code> goes through <code>visitsForItem</code>, which skips items that Chrome returns with no <code>url</code> at all, a quirk of some deleted-history entries, and skips non-web URLs using <code>isHttpUrl</code>, before fetching that item's full visit list.</p>
<p>Calling <code>getVisits</code> here, instead of relying on <code>search</code> alone, matters because <code>chrome.history.search</code> is tempting as a single call, but it collapses every visit to a URL down to just the <em>most recent</em> one. If you visited the same Stack Overflow answer three times over two days while debugging something, <code>search</code> gives you one row, and in the next section, where you segment events into sessions, you need all three: that's the difference between "one visit, three days ago" and "a sustained debugging session."</p>
<p><code>getVisits</code> gives you that full timestamp list, but it returns <em>all</em> history for a URL regardless of date range, so <code>visitsForItem</code> filters by <code>startTime</code> itself. And because <code>chrome.history.search</code> can return tens of thousands of items for a heavy browser history, the backfill fans out to <code>getVisits</code> in batches of <code>CONCURRENCY</code>, set to 50, rather than firing everything at once. Chrome doesn't document a hard limit on concurrent <code>getVisits</code> calls, but 50 in flight at a time keeps things responsive without flooding it.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>You can verify live capture by browsing normally and watching <code>raw_events</code> fill up: open <code>chrome://extensions</code>, click "service worker" on the openloops card, then go to the <strong>Application</strong> tab → <strong>IndexedDB</strong> → <code>openloops</code> → <code>raw_events</code>, where each row should be a <code>RawEvent</code> with <code>source: "live"</code>.</p>
<p><code>backfillHistory</code> itself doesn't have a UI yet, but you'll wire it up to a "Scan my history" button when you build the dashboard rail in Part 13. For now, it's enough that it compiles and that <code>raw_events</code> is filling up from live capture. In the next part you'll start turning that raw stream into something structured: sessions.</p>
<h2 id="heading-how-to-turn-noise-into-sessions">How to Turn Noise into Sessions</h2>
<p>A real browsing history is full of activity that has nothing to do with what you were actually trying to do. An afternoon of research might be interleaved with dozens of visits to Gmail, Slack, or YouTube, along with pages whose titles are just "New Tab" or "Dashboard" because the page hadn't finished loading when the browser recorded it.</p>
<p>Before any of this can be grouped into something meaningful, two things need to happen: the noise needs to be filtered out, and what remains needs to be broken into sessions, contiguous stretches of activity separated by gaps in time.</p>
<p>This section builds both of those steps, along with a small keyword extractor that each session uses to describe what it was about, since that description is what later powers clustering.</p>
<h3 id="heading-filtering-out-noise">Filtering Out Noise</h3>
<p>Create <code>src/pipeline/noise.ts</code>:</p>
<pre><code class="language-typescript">import type { RawEvent } from "../types";
import { isHttpUrl, isLocalHost } from "../lib/util";

export const BLOCKED_DOMAINS: readonly string[] = [
  "mail.google.com",
  "outlook.live.com",
  "outlook.office.com",
  "calendar.google.com",
  "slack.com",
  "app.slack.com",
  "discord.com",
  "web.whatsapp.com",
  "teams.microsoft.com",
  "messenger.com",
];

export const ADULT_DOMAINS: readonly string[] = [
  "xvideos.com",
  "pornhub.com",
  "xnxx.com",
  "xhamster.com",
  "redtube.com",
  "youporn.com",
  "spankbang.com",
];

export const JUNK_DOMAINS: readonly string[] = [
  "trk.myperfect2give.com",
  "t.buenotraffic.com",
  "bwredir.com",
  "osom.saintscommunity.net",
];

const ALL_BLOCKED = [...BLOCKED_DOMAINS, ...ADULT_DOMAINS, ...JUNK_DOMAINS];

function domainIsBlocked(domain: string): boolean {
  return ALL_BLOCKED.some(
    (blocked) =&gt; domain === blocked || domain.endsWith("." + blocked)
  );
}

export const NOISE_TITLE_PREFIXES: readonly string[] = [
  "new tab",
  "new chat",
  "untitled",
  "inbox",
  "home",
  "dashboard",
  "sign in",
  "log in",
  "loading",
];

function titleIsGeneric(title: string, domain: string): boolean {
  if (title.trim() === "") return true;
  if (title.toLowerCase() === domain.toLowerCase()) return true;

  const lower = title.toLowerCase();
  return NOISE_TITLE_PREFIXES.some((prefix) =&gt; lower.startsWith(prefix));
}

export function isNoise(event: RawEvent): boolean {
  if (!isHttpUrl(event.url)) return true;
  if (isLocalHost(event.domain)) return true;
  return domainIsBlocked(event.domain) || titleIsGeneric(event.title, event.domain);
}
</code></pre>
<p><code>isNoise</code> is the single function the rest of the pipeline calls, and it layers four checks on top of each other, each one catching a different kind of noise.</p>
<p>The first two checks reuse the helpers from earlier: <code>isHttpUrl</code> and <code>isLocalHost</code> drop anything that isn't a real web page or that points at a local development server, the same filters that already protect capture. Checking them again here is a deliberate belt-and-suspenders measure: if anything ever reaches <code>raw_events</code> without having passed through capture's checks, it still can't make it into a session.</p>
<p><code>BLOCKED_DOMAINS</code> covers communication and productivity tools, Gmail, Slack, Discord, WhatsApp Web, and similar. Those tools that you visit constantly but that carry no research intent of their own. <code>domainIsBlocked</code> matches both the exact domain and any subdomain, so <code>slack.com</code> in the list also catches <code>app.slack.com</code>. <code>ADULT_DOMAINS</code> and <code>JUNK_DOMAINS</code> exist for related reasons, keeping adult content and known tracker or redirect domains out of your threads entirely.</p>
<p><code>BLOCKED_DOMAINS</code> is a curated, static list, and later in this guide it's complemented by a second, frequency-based detector in <code>ambient.ts</code>. This drops any domain that shows up in nearly every session regardless of what that domain actually is.</p>
<p>The last check, <code>titleIsGeneric</code>, catches pages whose titles tell you nothing useful: an empty title, a title that's identical to the domain name, or a title that starts with a generic prefix like "New Tab", "Dashboard", "Loading...", or "Sign in". <code>NOISE_TITLE_PREFIXES</code> is matched against the start of the lowercased title, so "Dashboard | Vercel" gets dropped right alongside a bare "Dashboard", while a content-rich title on that same domain passes through untouched.</p>
<h3 id="heading-extracting-keywords">Extracting Keywords</h3>
<p>Create <code>src/pipeline/keywords.ts</code>. This isn't NLP, just frequency counting after stopword removal. This is good enough to surface something like "typescript generics" or "react hooks" from a session of related browsing:</p>
<pre><code class="language-typescript">import { BLOCKED_DOMAINS } from "./noise";

export const STOPWORDS: ReadonlySet&lt;string&gt; = new Set([
  "the", "and", "for", "with", "you", "your", "how", "what", "this", "that",
  "from", "are", "was", "not", "but", "all", "can", "has", "have", "will",
  "its", "out", "one", "get", "our", "had", "just", "about", "also", "more",
  "into", "than", "then", "when", "their", "there", "which", "would", "been",
  "his", "her", "who", "they", "she", "him", "now", "any", "way", "use",
  "using", "used", "make", "made",
  "google", "youtube", "search", "chat", "new", "home", "www", "com", "org",
  "net", "page", "site", "tab", "view", "app", "log", "sign", "login",
  "official", "free", "online", "best", "top", "open",
]);

export const PLATFORM_STOPWORDS: ReadonlySet&lt;string&gt; = new Set([
  "instagram", "facebook", "youtube", "claude", "google", "linkedin",
  "twitter", "reddit", "netflix", "amazon", "gmail", "whatsapp", "tiktok",
  "messenger",
  "stories", "story", "reel", "reels", "shorts", "short", "feed", "watch",
  "video", "videos", "music", "post", "posts", "message", "messages",
  "dm", "dms", "notification", "notifications", "profile", "home", "login",
  "signin", "follow", "followers",
]);

function derivedDomainLabels(): Set&lt;string&gt; {
  const labels = new Set&lt;string&gt;();
  for (const domain of BLOCKED_DOMAINS) {
    const label = domain.split(".").at(-2);
    if (label) labels.add(label);
  }
  return labels;
}

const ALL_STOP_TOKENS: ReadonlySet&lt;string&gt; = new Set([
  ...STOPWORDS,
  ...PLATFORM_STOPWORDS,
  ...derivedDomainLabels(),
]);

export function extractKeywords(titles: string[], max = 8): string[] {
  const freq = new Map&lt;string, number&gt;();

  for (const title of titles) {
    const tokens = title.toLowerCase().split(/[^a-z0-9]+/);
    for (const token of tokens) {
      if (token.length &lt; 3) continue;
      if (/^\d+$/.test(token)) continue;
      if (ALL_STOP_TOKENS.has(token)) continue;

      freq.set(token, (freq.get(token) ?? 0) + 1);
    }
  }

  return [...freq.entries()]
    .sort((a, b) =&gt; b[1] - a[1])
    .slice(0, max)
    .map(([token]) =&gt; token);
}
</code></pre>
<p><code>extractKeywords</code> takes the page titles from a group of events and returns the handful of words that show up most often, after stripping out everything that isn't a topic. That stripping is doing more work than the name "stopwords" suggests.</p>
<p><code>STOPWORDS</code> covers common English function words like "the" and "with", plus generic site chrome like "search", "login", and "page". On its own, this would still let through tokens like "instagram" or "reels" from a title such as "Reels · Instagram", and those tokens would then show up as keywords for that session.</p>
<p>That gap is what <code>PLATFORM_STOPWORDS</code> closes. A title like "Reels · Instagram" or "Watch - YouTube" identifies the tool you were using, not what you were doing with it. So <code>PLATFORM_STOPWORDS</code> strips out platform and brand names along with social media UI chrome like "stories", "feed", "dm", and "notifications". Without this list, sessions on social platforms would extract keywords like "instagram" or "watch". Those would become thread titles that quietly pull unrelated sessions together during clustering, since every social-media session would share that one meaningless keyword.</p>
<p><code>derivedDomainLabels</code> keeps a third source of stopwords in sync automatically: for every domain in <code>BLOCKED_DOMAINS</code>, it takes the label immediately before the top-level domain. So <code>mail.google.com</code> becomes <code>google</code> and <code>web.whatsapp.com</code> becomes <code>whatsapp</code>. Adding a new domain to that blocklist later also prevents its name from polluting keywords, without any extra bookkeeping.</p>
<p>With all three sets merged once at module load into <code>ALL_STOP_TOKENS</code>, <code>extractKeywords</code> itself is straightforward: lowercase every title, split on anything that isn't a letter or digit, drop tokens shorter than three characters or made entirely of digits, and drop anything in <code>ALL_STOP_TOKENS</code>. Then count what's left and return the most frequent entries.</p>
<h3 id="heading-extending-the-database-for-sessions">Extending the Database For Sessions</h3>
<p>Sessions need a place to live. Earlier in this guide, <code>src/db/index.ts</code> defined a schema with just <code>raw_events</code> at version 1. We'll add a <code>sessions</code> store and bump the version to 2.</p>
<p>First, extend the schema and the <code>upgrade</code> callback:</p>
<pre><code class="language-typescript">import type { RawEvent, Session } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
  sessions: {
    key: string;
    value: Session;
    indexes: { by_startedAt: number };
  };
}

const DB_VERSION = 2;

export function getDB(): Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; {
  if (!_db) {
    _db = openDB&lt;OpenloopsDB&gt;(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
        if (!db.objectStoreNames.contains("sessions")) {
          const s = db.createObjectStore("sessions", { keyPath: "id" });
          s.createIndex("by_startedAt", "startedAt");
        }
      },
    });
  }
  return _db;
}
</code></pre>
<p>Then add the helper functions sessions need, alongside the <code>raw_events</code> helpers you already wrote. They follow the same shape: <code>putSessions</code> writes a batch idempotently, <code>clearSessions</code> wipes the store before a rebuild, <code>getAllSessions</code> returns everything sorted by <code>startedAt</code> via the index, and <code>getSessionCount</code> returns a total.</p>
<pre><code class="language-typescript">export async function putSessions(sessions: Session[]): Promise&lt;void&gt; {
  if (sessions.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("sessions", "readwrite");
  await Promise.all([...sessions.map((s) =&gt; tx.store.put(s)), tx.done]);
}

export async function clearSessions(): Promise&lt;void&gt; {
  const db = await getDB();
  return db.clear("sessions");
}

export async function getAllSessions(): Promise&lt;Session[]&gt; {
  const db = await getDB();
  return db.getAllFromIndex("sessions", "by_startedAt");
}

export async function getSessionCount(): Promise&lt;number&gt; {
  const db = await getDB();
  return db.count("sessions");
}
</code></pre>
<p>The <code>if (!db.objectStoreNames.contains(...))</code> guard from earlier is what makes this safe: anyone who already has a version-1 database, with <code>raw_events</code> full of real data, gets the new <code>sessions</code> store added on top, without touching what's already there.</p>
<h3 id="heading-segmenting-events-into-sessions">Segmenting Events into Sessions</h3>
<p>A session is a contiguous block of browsing activity, with a new one starting whenever the gap between two consecutive events exceeds <code>SESSION_GAP_MS</code>. Create <code>src/pipeline/sessions.ts</code>:</p>
<pre><code class="language-typescript">import { getAllEvents, clearSessions, putSessions } from "../db/index";
import { isNoise } from "./noise";
import { extractKeywords } from "./keywords";
import { hashId } from "../lib/util";
import type { RawEvent, Session } from "../types";

const SESSION_GAP_MS = 30 * 60 * 1000;

function rankDomains(events: RawEvent[]): string[] {
  const freq = new Map&lt;string, number&gt;();
  for (const e of events) {
    freq.set(e.domain, (freq.get(e.domain) ?? 0) + 1);
  }
  return [...freq.entries()]
    .sort((a, b) =&gt; b[1] - a[1])
    .map(([domain]) =&gt; domain);
}

function buildSession(events: RawEvent[]): Session {
  const startedAt = events[0].visitedAt;
  const endedAt = events[events.length - 1].visitedAt;

  return {
    id: hashId(events[0].url, startedAt),
    events,
    startedAt,
    endedAt,
    domains: rankDomains(events),
    keywords: extractKeywords(events.map((e) =&gt; e.title)),
  };
}

export async function buildSessions(): Promise&lt;{ events: number; sessions: number }&gt; {
  const allEvents = await getAllEvents();

  const meaningful = allEvents.filter((e) =&gt; !isNoise(e));

  if (meaningful.length === 0) {
    await clearSessions();
    return { events: 0, sessions: 0 };
  }

  const sessions: Session[] = [];
  let currentGroup: RawEvent[] = [meaningful[0]];

  for (let i = 1; i &lt; meaningful.length; i++) {
    const gap = meaningful[i].visitedAt - meaningful[i - 1].visitedAt;

    if (gap &gt; SESSION_GAP_MS) {
      sessions.push(buildSession(currentGroup));
      currentGroup = [meaningful[i]];
    } else {
      currentGroup.push(meaningful[i]);
    }
  }
  sessions.push(buildSession(currentGroup));

  const substantive = sessions.filter(
    (s) =&gt; !(s.events.length === 1 &amp;&amp; s.keywords.length === 0)
  );

  await clearSessions();
  await putSessions(substantive);

  return { events: meaningful.length, sessions: substantive.length };
}
</code></pre>
<p><code>buildSessions</code> does five things in order:</p>
<ol>
<li><p>loads every raw event sorted by time,</p>
</li>
<li><p>drops anything <code>isNoise</code> flags,</p>
</li>
<li><p>walks the remaining list and starts a new session whenever the gap between two consecutive events exceeds <code>SESSION_GAP_MS</code> (pushing the final in-progress group once the loop ends since nothing else closes it off),</p>
</li>
<li><p>drops sessions that turned out to be a single event with no extractable keywords (usually stray page loads that never connected to anything else),</p>
</li>
<li><p>and persists the result.</p>
</li>
</ol>
<p>Each session's <code>domains</code> and <code>keywords</code> come from <code>rankDomains</code> and <code>extractKeywords</code> running over just the events in that group. <code>rankDomains</code> counts how many events came from each domain and orders them by frequency, so the most-visited domain in a session comes first.</p>
<p>A worked example makes "walking the list" concrete. Take five events that survive noise filtering, A through E:</p>
<pre><code class="language-plaintext">A  t= 0 min  "TypeScript generics - Stack Overflow"   stackoverflow.com
B  t= 5 min  "TypeScript Handbook"                    typescriptlang.org
C  t=10 min  "microsoft/TypeScript - GitHub"          github.com
   ↑ gap to D = 45 min  &gt;  SESSION_GAP_MS (30 min)  → SPLIT HERE
D  t=55 min  "React hooks explained - YouTube"         youtube.com
E  t=60 min  "useEffect cleanup - Stack Overflow"     stackoverflow.com
</code></pre>
<p>As the loop walks from A to B to C, each gap is under the 30-minute limit, so all three stay in the same group. The jump from C to D is 45 minutes, which crosses <code>SESSION_GAP_MS</code>, so the loop closes off <code>[A, B, C]</code> as Session 1 and starts a fresh group with D. From D to E is only 5 minutes, so E joins D, and that group becomes Session 2 once the loop ends.</p>
<p>Session 1 ends up tagged with keywords like <code>typescript</code> and <code>generics</code>, while Session 2 is tagged with <code>react</code> and <code>hooks</code>, even though both sessions happened on the same day.</p>
<p><code>SESSION_GAP_MS</code> is set to 30 minutes because that's the same default that Google Analytics and similar tools use, and it works well for most browsing patterns.</p>
<p>The tradeoff runs in both directions: a shorter gap produces more, smaller sessions, which gives clustering a more granular signal but risks fragmenting one continuous task into several pieces. A longer gap produces fewer, larger sessions, which risks merging activity that was actually unrelated.</p>
<p>30 minutes is a reasonable starting point, and it's the kind of constant you can come back and tune once you see how your own threads turn out.</p>
<h3 id="heading-checkpoint"><strong>Checkpoint</strong></h3>
<p><code>buildSessions</code> doesn't have a UI yet either. It'll get wired up to a "Build sessions" button alongside "Scan my history" when you design the dashboard later in this guide.</p>
<p>For now, the goal is just for everything in this section to compile cleanly: <code>src/pipeline/noise.ts</code>, <code>src/pipeline/keywords.ts</code>, the updated <code>src/db/index.ts</code>, and <code>src/pipeline/sessions.ts</code> should all build without errors. <code>getDB()</code> should report version 2 the next time the extension reloads (visible in DevTools under <strong>Application</strong> → <strong>IndexedDB</strong> → <code>openloops</code>, where the database now lists both <code>raw_events</code> and <code>sessions</code> as object stores).</p>
<p>With sessions in place, the next section takes this structured-but-unconnected data and groups sessions together into the intent threads this whole project is named after.</p>
<h2 id="heading-how-to-cluster-sessions-into-intent-threads">How to Cluster Sessions into Intent Threads</h2>
<p>Sessions group events that happened close together in time. But the things you're actually trying to do rarely fit inside one session. Comparing laptops might span three sessions over four days. A question you keep meaning to look into might surface for ten minutes every few days for two weeks.</p>
<p>This section groups related sessions together into intent threads, then scores each thread for how confident openloops is that it represents something real and how alive it still is.</p>
<p>Two files do this work. <code>src/pipeline/ambient.ts</code> detects domains that are part of your daily routine rather than any particular intent, so they don't create false similarity between unrelated sessions. <code>src/pipeline/threads.ts</code> does the actual clustering and scoring.</p>
<h3 id="heading-detecting-ambient-domains">Detecting Ambient Domains</h3>
<p>Some domains show up in almost every session regardless of what you're doing: <a href="http://youtube.com">youtube.com</a> as background noise, <a href="http://github.com">github.com</a> if you're a developer who commits daily, or <a href="http://claude.ai">claude.ai</a> if you use it as a general assistant. If clustering compared sessions on these domains the same way it compares them on anything else, two completely unrelated sessions would look similar just because they both touched <a href="http://youtube.com">youtube.com</a>, and everything would eventually merge into one enormous thread.</p>
<p><code>ambient.ts</code> solves this with a frequency check: a domain is ambient if it shows up on a large enough fraction of your active days, regardless of topic.</p>
<p>Create <code>src/pipeline/ambient.ts</code>:</p>
<pre><code class="language-typescript">import type { Session } from "../types";

export const UBIQUITY_THRESHOLD = 0.6;
export const MIN_ACTIVE_DAYS = 3;

function toDay(epochMs: number): string {
  return new Date(epochMs).toDateString();
}

export function detectAmbientDomains(sessions: Session[]): Set&lt;string&gt; {
  const allEvents = sessions.flatMap((s) =&gt; s.events);

  const activeDays = new Set(allEvents.map((e) =&gt; toDay(e.visitedAt)));
  const totalActiveDays = activeDays.size;

  if (totalActiveDays &lt; MIN_ACTIVE_DAYS) {
    return new Set();
  }

  const domainDayMap = new Map&lt;string, Set&lt;string&gt;&gt;();
  for (const event of allEvents) {
    const day = toDay(event.visitedAt);
    if (!domainDayMap.has(event.domain)) {
      domainDayMap.set(event.domain, new Set());
    }
    domainDayMap.get(event.domain)!.add(day);
  }

  const ambient = new Set&lt;string&gt;();
  for (const [domain, days] of domainDayMap) {
    const ubiquity = days.size / totalActiveDays;
    if (ubiquity &gt;= UBIQUITY_THRESHOLD) {
      ambient.add(domain);
      console.log(
        `[openloops] ambient: \({domain} (\){days.size}/\({totalActiveDays} days, ubiquity=\){ubiquity.toFixed(2)})`
      );
    }
  }

  return ambient;
}
</code></pre>
<p><code>toDay</code> collapses a timestamp down to a calendar-day string, so two events on the same day produce the same key, regardless of the exact time.</p>
<p><code>detectAmbientDomains</code> first counts how many distinct days had any browsing activity at all – that's <code>totalActiveDays</code> – then builds a map from each domain to the set of days it appeared on. A domain's ubiquity is <code>days.size / totalActiveDays</code>, the fraction of your active days that domain showed up on. Anything at or above <code>UBIQUITY_THRESHOLD</code> 0.6 gets added to the returned set.</p>
<p><code>MIN_ACTIVE_DAYS</code> exists because with only one or two days of data, almost every domain you visited would technically appear on 100% of your active days, and the detector would mark everything as ambient. Below three active days, it returns an empty set and skips detection entirely.</p>
<p>This approach has a real tradeoff. It correctly identifies genuinely ambient tools, but it can also suppress a domain you happened to research intensively every single day for a week, which would also cross the 60% threshold.</p>
<p><code>UBIQUITY_THRESHOLD</code> is the knob for that tradeoff: raising it reduces false positives at the cost of letting some real ambient noise back in.</p>
<h3 id="heading-extending-the-database-for-intent-threads">Extending the Database for Intent Threads</h3>
<p>Threads need their own store. Bump <code>DB_VERSION</code> to 3 and add <code>intent_threads</code>, indexed by <code>lastSeen</code>, so the dashboard can show the most recently active threads first:</p>
<pre><code class="language-typescript">import type { RawEvent, Session, IntentThread } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
  sessions: {
    key: string;
    value: Session;
    indexes: { by_startedAt: number };
  };
  intent_threads: {
    key: string;
    value: IntentThread;
    indexes: { by_lastSeen: number };
  };
}

const DB_VERSION = 3;

export function getDB(): Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; {
  if (!_db) {
    _db = openDB&lt;OpenloopsDB&gt;(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
        if (!db.objectStoreNames.contains("sessions")) {
          const s = db.createObjectStore("sessions", { keyPath: "id" });
          s.createIndex("by_startedAt", "startedAt");
        }
        if (!db.objectStoreNames.contains("intent_threads")) {
          const s = db.createObjectStore("intent_threads", { keyPath: "id" });
          s.createIndex("by_lastSeen", "lastSeen");
        }
      },
    });
  }
  return _db;
}
</code></pre>
<p>Then add the matching helpers:</p>
<pre><code class="language-typescript">export async function putThreads(threads: IntentThread[]): Promise&lt;void&gt; {
  if (threads.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("intent_threads", "readwrite");
  await Promise.all([...threads.map((t) =&gt; tx.store.put(t)), tx.done]);
}

export async function clearThreads(): Promise&lt;void&gt; {
  const db = await getDB();
  return db.clear("intent_threads");
}

export async function getAllThreads(): Promise&lt;IntentThread[]&gt; {
  const db = await getDB();
  const index = db
    .transaction("intent_threads", "readonly")
    .store.index("by_lastSeen");

  let cursor = await index.openCursor(null, "prev");
  const results: IntentThread[] = [];
  while (cursor) {
    results.push(cursor.value);
    cursor = await cursor.continue();
  }
  return results;
}

export async function getThreadCount(): Promise&lt;number&gt; {
  const db = await getDB();
  return db.count("intent_threads");
}
</code></pre>
<p><code>putThreads</code>, <code>clearThreads</code>, and <code>getThreadCount</code> follow the same pattern as the <code>sessions</code> helpers from earlier. <code>getAllThreads</code> is the odd one out: instead of <code>getAllFromIndex</code>, which only returns ascending order, it opens a cursor on <code>by_lastSeen</code> in <code>"prev"</code> direction and walks it manually. That gives you threads ordered with the most recently active first, the order the dashboard wants for status-grouped cards.</p>
<h3 id="heading-clustering-sessions-into-threads">Clustering Sessions into Threads</h3>
<p>With ambient domains identified, <code>src/pipeline/threads.ts</code> now does the real work: grouping sessions into threads, then scoring and classifying each one.</p>
<p>The approach is <a href="https://research.google/blog/scaling-hierarchical-agglomerative-clustering-to-trillion-edge-graphs/">greedy agglomerative clustering</a>. Walk through sessions in chronological order, and for each one, either merge it into the most similar existing thread or start a new thread if nothing is similar enough.</p>
<p>Start with the imports, the tuning constants, and the similarity calculation:</p>
<pre><code class="language-typescript">import { getAllSessions, clearThreads, putThreads } from "../db/index";
import { detectAmbientDomains } from "./ambient";
import { hashId } from "../lib/util";
import type { Session, IntentThread } from "../types";

export const SIMILARITY_THRESHOLD = 0.15;
export const DOMAIN_WEIGHT = 0.5;
export const KEYWORD_WEIGHT = 0.5;

interface ThreadBuilder {
  id: string;
  sessions: Session[];
  domainSet: Set&lt;string&gt;;
  keywordSet: Set&lt;string&gt;;
}

function jaccard(a: Set&lt;string&gt;, b: Set&lt;string&gt;): number {
  if (a.size === 0 &amp;&amp; b.size === 0) return 0;
  let intersection = 0;
  for (const item of a) {
    if (b.has(item)) intersection++;
  }
  const union = a.size + b.size - intersection;
  return intersection / union;
}

function similarity(
  session: Session,
  thread: ThreadBuilder,
  ambient: Set&lt;string&gt;
): number {
  const sessionDomains  = new Set(session.domains.filter((d) =&gt; !ambient.has(d)));
  const threadDomains   = new Set([...thread.domainSet].filter((d) =&gt; !ambient.has(d)));
  const sessionKeywords = new Set(session.keywords);

  const domainScore   = jaccard(sessionDomains, threadDomains);
  const keywordScore  = jaccard(sessionKeywords, thread.keywordSet);

  return DOMAIN_WEIGHT * domainScore + KEYWORD_WEIGHT * keywordScore;
}
</code></pre>
<p><code>ThreadBuilder</code> is a mutable accumulator used only during clustering: a thread in progress, with its sessions plus the union of all domains and keywords seen so far. <code>jaccard</code> is the standard set-similarity measure, the size of the intersection divided by the size of the union, returning 0 for two empty sets rather than dividing zero by zero.</p>
<p><code>similarity</code> compares one candidate session against one in-progress thread. Before comparing domains, it filters ambient domains out of both sides, so a shared <code>youtube.com</code> never contributes to the score. It then computes a domain Jaccard score and a keyword Jaccard score separately, and combines them with <code>DOMAIN_WEIGHT</code> and <code>KEYWORD_WEIGHT</code>, both 0.5, giving domain overlap and keyword overlap equal say in the final number.</p>
<p>Next, the clustering loop itself:</p>
<pre><code class="language-typescript">function clusterSessions(
  sessions: Session[],
  ambient: Set&lt;string&gt;
): ThreadBuilder[] {
  const threads: ThreadBuilder[] = [];

  for (const session of sessions) {
    let bestThread: ThreadBuilder | null = null;
    let bestScore = 0;

    for (const thread of threads) {
      const score = similarity(session, thread, ambient);
      if (score &gt; bestScore) {
        bestScore = score;
        bestThread = thread;
      }
    }

    if (bestThread &amp;&amp; bestScore &gt;= SIMILARITY_THRESHOLD) {
      bestThread.sessions.push(session);
      for (const d of session.domains)  bestThread.domainSet.add(d);
      for (const k of session.keywords) bestThread.keywordSet.add(k);
    } else {
      threads.push({
        id: hashId(session.id, session.startedAt),
        sessions: [session],
        domainSet:  new Set(session.domains),
        keywordSet: new Set(session.keywords),
      });
    }
  }

  return threads;
}
</code></pre>
<p><code>clusterSessions</code> relies on <code>sessions</code> already being sorted chronologically, which <code>getAllSessions</code> guarantees via its index. For each session, it scores against every thread built so far and keeps the best match.</p>
<p>If that best score clears <code>SIMILARITY_THRESHOLD</code>, the session merges in and its domains and keywords get folded into the thread's accumulated sets. This means that later sessions are compared against the thread's <em>entire</em> accumulated history rather than only its seed session. If nothing clears the threshold, the session becomes the seed of a brand-new thread.</p>
<p>A worked example shows how this plays out. Suppose <code>detectAmbientDomains</code> returned <code>{ youtube.com }</code>, and three sessions arrive in this order:</p>
<pre><code class="language-plaintext">S1: domains=[stackoverflow.com, typescriptlang.org]
    keywords=[typescript, generics, interface, mapped]

S2: domains=[stackoverflow.com, typescriptlang.org, github.com]
    keywords=[typescript, generics, utility, types]

S3: domains=[python.org, docs.python.org]
    keywords=[python, async, await, coroutine]
</code></pre>
<p>S1 arrives first. With no threads yet, it seeds Thread A: <code>domainSet = {stackoverflow.com, typescriptlang.org}</code>, <code>keywordSet = {typescript, generics, interface, mapped}</code>.</p>
<p>S2 is scored against Thread A. Neither set contains the ambient <code>youtube.com</code>, so nothing gets filtered out. The domain Jaccard is <code>|{stackoverflow.com, typescriptlang.org}| / |{stackoverflow.com, typescriptlang.org, github.com}|</code>, or 2/3 ≈ 0.667. The keyword Jaccard is <code>|{typescript, generics}| / |{typescript, generics, interface, mapped, utility, types}|</code>, or 2/6 ≈ 0.333. The combined similarity is <code>0.5 × 0.667 + 0.5 × 0.333 = 0.5</code>, comfortably above <code>SIMILARITY_THRESHOLD</code> (0.15), so S2 merges into Thread A, whose sets grow to include <code>github.com</code>, <code>utility</code>, and <code>types</code>.</p>
<p>S3 is scored against Thread A. There's no overlap at all between <code>{python.org, docs.python.org}</code> and Thread A's domains, or between their keyword sets, so both Jaccard scores are 0 and the combined similarity is 0. That's below the threshold, so S3 seeds a new Thread B.</p>
<p>The result: Thread A holds the TypeScript research across two sessions, and Thread B holds the Python session on its own.</p>
<p><code>SIMILARITY_THRESHOLD</code> is the single most consequential constant in this file, and 0.15 is lower than you might guess for a 50/50 weighted Jaccard score. A starting value like 0.3 sounds more principled. That would mean two sessions need to share roughly a third of their combined domains and keywords before they're considered part of the same thread.</p>
<p>Run that against real, messy browsing history, though, and it produces far too many threads: sessions that were obviously part of the same research, but didn't share quite enough keywords to clear 0.3, end up scattered across separate threads.</p>
<p>Dropping the threshold to 0.15 lets sessions merge on weaker but still real signal. Two sessions sharing just one domain and one keyword out of several can already cross 0.15, and the result is fewer, more coherent threads that actually match what the browsing history looks like.</p>
<p>This is the kind of constant you tune empirically rather than deriving it from first principles: build your threads, look at the result, and adjust.</p>
<p><code>buildThreads</code>, covered next, prints a table of every thread's title, type, status, confidence, and top keywords specifically so you can eyeball this. If two threads obviously belong together, lower <code>SIMILARITY_THRESHOLD</code>. If one thread is clearly several unrelated topics glued together, raise it.</p>
<h3 id="heading-scoring-and-classifying-threads">Scoring and Classifying Threads</h3>
<p>Clustering produces groups of sessions, but a group of sessions isn't yet an <code>IntentThread</code>. The rest of <code>threads.ts</code> turns each group into something with a type, a confidence score, a status, and a set of human-readable signals explaining why.</p>
<p>A few small helpers come first:</p>
<pre><code class="language-typescript">export const BUYING_WORDS: readonly string[] = [
  "vs", "versus", "alternative", "alternatives",
  "comparison", "pricing", "price", "review", "reviews", "best",
];

export const LEARNING_WORDS: readonly string[] = [
  "how to", "tutorial", "tutorials", "docs", "documentation",
  "guide", "learn", "example", "examples", "crash course", "introduction",
];

const STATUS_ACTIVE_MS  = 48 * 60 * 60 * 1000;
const STATUS_STALLED_MS = 7  * 24 * 60 * 60 * 1000;

function toTitleCase(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

function findMatches(titles: string[], wordList: readonly string[]): string[] {
  const lower = titles.map((t) =&gt; t.toLowerCase());
  const found = new Set&lt;string&gt;();

  for (const word of wordList) {
    const isPhrase = word.includes(" ");
    for (const title of lower) {
      if (isPhrase) {
        if (title.includes(word)) found.add(word);
      } else {
        const tokens = title.split(/[^a-z0-9]+/);
        if (tokens.includes(word)) found.add(word);
      }
    }
  }

  return [...found];
}

function toCalendarDay(epochMs: number): string {
  return new Date(epochMs).toDateString();
}
</code></pre>
<p><code>BUYING_WORDS</code> and <code>LEARNING_WORDS</code> are small vocabularies that signal intent. <code>findMatches</code> checks a list of page titles against one of these vocabularies, and handles single words and phrases differently: a multi-word entry like "how to" is checked as a substring, since it's specific enough that false positives are unlikely. But a single word like "review" is checked as a whole token, split out of the title on non-alphanumeric characters.</p>
<p>Without that distinction, "review" would match inside "overview" too, which would misclassify any thread that happened to involve an "Overview" page. <code>toTitleCase</code> and <code>toCalendarDay</code> are small formatting helpers used by the scoring function next.</p>
<p>That scoring function, <code>scoreThread</code>, is the longest function in the project, since it's where every signal collected so far gets turned into the fields on <code>IntentThread</code>:</p>
<pre><code class="language-typescript">function scoreThread(builder: ThreadBuilder): IntentThread {
  const { sessions, keywordSet } = builder;

  const firstSeen  = sessions[0].startedAt;
  const lastSeen   = sessions[sessions.length - 1].endedAt;

  const allEvents  = sessions.flatMap((s) =&gt; s.events);
  const totalEvents = allEvents.length;
  const daySet     = new Set(allEvents.map((e) =&gt; toCalendarDay(e.visitedAt)));
  const distinctDays = daySet.size;

  const allTitles      = allEvents.map((e) =&gt; e.title);
  const buyingMatches  = findMatches(allTitles, BUYING_WORDS);
  const learningMatches = findMatches(allTitles, LEARNING_WORDS);

  let type: IntentThread["type"];
  if (buyingMatches.length &gt; 0) {
    type = "buying";
  } else if (learningMatches.length &gt; 0) {
    type = "learning";
  } else if (distinctDays &gt; 5 &amp;&amp; sessions.length &gt;= 3) {
    type = "planning";
  } else if (totalEvents &gt;= 3) {
    type = "research";
  } else {
    type = "unclassified";
  }

  const age = Date.now() - lastSeen;
  const status: IntentThread["status"] =
    age &lt; STATUS_ACTIVE_MS  ? "active"  :
    age &lt; STATUS_STALLED_MS ? "stalled" :
    "dormant";

  const confidence = parseFloat((
    Math.min(distinctDays / 5, 1) * 0.35 +
    Math.min(sessions.length / 5, 1) * 0.25 +
    Math.min(totalEvents / 20, 1)  * 0.20 +
    (type !== "unclassified" ? 1 : 0)  * 0.20
  ).toFixed(2));

  const signals: string[] = [];

  if (distinctDays &gt; 1)
    signals.push(`revisited across ${distinctDays} days`);
  if (type === "buying" &amp;&amp; buyingMatches.length &gt; 0)
    signals.push(`comparison language: ${buyingMatches.join(", ")}`);
  if (type === "learning" &amp;&amp; learningMatches.length &gt; 0)
    signals.push(`learning language: ${learningMatches.join(", ")}`);
  signals.push(`\({sessions.length} session\){sessions.length !== 1 ? "s" : ""}`);
  if (totalEvents &gt; 5)
    signals.push(`${totalEvents} total events`);
  if (type === "planning")
    signals.push("sustained activity across many days");

  const ageDays = Math.floor(age / (24 * 60 * 60 * 1000));
  if (ageDays === 0)       signals.push("last active today");
  else if (ageDays === 1)  signals.push("last active yesterday");
  else                     signals.push(`last active ${ageDays} days ago`);

  const title =
    [...keywordSet].slice(0, 3).map(toTitleCase).join(" ") || "Untitled Thread";

  return {
    id: builder.id,
    title,
    sessions,
    type,
    confidence,
    status,
    firstSeen,
    lastSeen,
    distinctDays,
    signals,
  };
}
</code></pre>
<p>There's a lot here, so it's worth walking through each field on <code>IntentThread</code> in the order it's computed.</p>
<p><code>firstSeen</code> and <code>lastSeen</code> come straight from the boundary sessions, since <code>sessions</code> arrives in chronological order from clustering. <code>distinctDays</code> reuses the same calendar-day collapsing as <code>ambient.ts</code>. This time it counts how many different days <em>this thread's</em> events span, regardless of how many total active days you had overall.</p>
<p>Classification into <code>type</code> is a cascade, and the order matters. Comparison language (<code>BUYING_WORDS</code>) is checked first, because a thread where you're comparing two frameworks is "buying" even if it also contains tutorial pages. Comparison intent is the stronger signal.</p>
<p>Learning language comes next. After that, <code>planning</code> is reserved for threads that span more than five distinct days <em>and</em> have at least three sessions of sustained, recurring activity rather than a single deep dive.</p>
<p><code>research</code> is the catch-all for anything with at least three events that didn't match anything more specific, and <code>unclassified</code> is what's left, usually threads with too little activity to say anything confident about.</p>
<p><code>status</code> is purely a function of how long ago <code>lastSeen</code> was: under 48 hours is <code>active</code>, under 7 days is <code>stalled</code>, anything older is <code>dormant</code>.</p>
<p><code>confidence</code> is a weighted sum of four signals, each normalized to a maximum of 1 before weighting, so the total can't exceed 1 either. <code>distinctDays / 5</code>, capped at 1, contributes up to 35%, treating five or more distinct days as fully confident on that axis. <code>sessions.length / 5</code>, capped at 1, contributes up to 25%. <code>totalEvents / 20</code>, capped at 1, contributes up to 20%. And whether <code>type</code> is anything other than <code>unclassified</code> contributes the final 20% as an all-or-nothing bonus.</p>
<p>A thread revisited across five-plus days, across five-plus sessions, with twenty-plus events, that also classified cleanly, scores a full 1.0. A thread that's a single session with two events and no classification scores close to 0.</p>
<p><code>signals</code> is a plain-English audit trail for the confidence score and status: it explains why a thread looks the way it does, listing things like how many days it was revisited across, what comparison or learning language was found, the session and event counts, and how recently it was last active. The dashboard surfaces these directly.</p>
<p>Finally, <code>title</code> is a placeholder: the top three keywords from the thread's accumulated <code>keywordSet</code>, title-cased and joined with spaces, or <code>"Untitled Thread"</code> if there are none.</p>
<p>This is deliberately weak. Later in this guide, AI labeling replaces this heuristic title, along with <code>summary</code> and <code>nextStep</code>, with something grounded in what the thread is actually about (but the thread is fully usable without that step, too).</p>
<h3 id="heading-putting-it-together">Putting it Together</h3>
<p><code>buildThreads</code> ties everything in this section together:</p>
<pre><code class="language-typescript">export async function buildThreads(): Promise&lt;{ sessions: number; threads: number }&gt; {
  const sessions = await getAllSessions();

  if (sessions.length === 0) {
    await clearThreads();
    return { sessions: 0, threads: 0 };
  }

  const ambient = detectAmbientDomains(sessions);

  const builders = clusterSessions(sessions, ambient);

  const substantive = builders.filter(
    (b) =&gt; !(b.sessions.length === 1 &amp;&amp; b.sessions[0].events.length &lt; 3)
  );

  const threads = substantive.map(scoreThread);

  await clearThreads();
  await putThreads(threads);

  console.table(
    threads.map((t) =&gt; ({
      title:        t.title,
      type:         t.type,
      status:       t.status,
      confidence:   t.confidence,
      distinctDays: t.distinctDays,
      sessions:     t.sessions.length,
      events:       t.sessions.reduce((n, s) =&gt; n + s.events.length, 0),
      keywords:     [...new Set(t.sessions.flatMap((s) =&gt; s.keywords))].slice(0, 5).join(", "),
    }))
  );

  return { sessions: sessions.length, threads: threads.length };
}
</code></pre>
<p>The order here matters. <code>detectAmbientDomains</code> runs once, over every session, before any clustering happens, since ambient detection needs the full picture of your browsing to know what counts as "every day".</p>
<p><code>clusterSessions</code> then produces <code>ThreadBuilder</code>s, which get filtered before scoring: a <code>ThreadBuilder</code> with exactly one session and fewer than three events is almost always a stray page load that didn't merge with anything, so it's dropped rather than becoming a thread with a confidence near zero.</p>
<p>Everything that survives gets scored by <code>scoreThread</code>, persisted, and printed via <code>console.table</code>, which is the tuning aid mentioned earlier. If you open the service worker's console after running this, every thread is laid out in a sortable table. This is the fastest way to spot a <code>SIMILARITY_THRESHOLD</code> that's too high or too low.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>Like the previous two sections, <code>buildThreads</code> doesn't have a UI yet. It'll get wired up to a "Build intent map" button alongside the other two when you design the dashboard later in this guide.</p>
<p>For now, confirm that <code>src/pipeline/ambient.ts</code>, the updated <code>src/db/index.ts</code>, and <code>src/pipeline/threads.ts</code> all build without errors, and that <code>getDB()</code> reports version 3 the next time the extension reloads. <code>intent_threads</code> should now be listed alongside <code>raw_events</code> and <code>sessions</code> in DevTools.</p>
<p>At this point, the entire core pipeline runs end to end, locally, with no API keys involved: your browsing history becomes raw events, raw events become sessions, and sessions become scored, classified intent threads.</p>
<p>Everything from here is optional and additive: cleaning up a source of self-referential noise this pipeline doesn't yet handle (which you probably want to look at and incorporate), then AI labeling, brand grounding, and the dashboard that ties it all together.</p>
<h2 id="heading-how-to-clean-up-self-referential-noise">How to Clean Up Self-Referential Noise</h2>
<p>Run the pipeline a few times against your own browsing and a strange kind of thread starts appearing: one made entirely of openloops itself.</p>
<p>The dashboard is a web page, so every time you open it to check your threads, that page load gets captured as an event. If you're also developing the extension, your <code>localhost</code> dev server and any private-network addresses end up in the data too.</p>
<p>The tool ends up watching itself use itself, and that self-reference pollutes the intent map in two distinct ways which are worth separating.</p>
<h3 id="heading-the-two-problems">The Two Problems</h3>
<p>The first problem is the extension's own pages. A Chrome extension's dashboard loads from a <code>chrome-extension://</code> URL, and Chrome's own internal pages use <code>chrome://</code>. Left unfiltered, opening the openloops dashboard ten times in an afternoon produces ten events on a <code>chrome-extension://</code> origin, which cluster happily into a thread about, essentially, looking at your threads.</p>
<p>This is circular and useless, and because you tend to open the dashboard often while the rest of your browsing is quieter, this self-thread can score deceptively high on recency and session count.</p>
<p>The second problem is local development infrastructure. If you're building the extension, or any local project, your history fills with <code>localhost:5173</code>, <code>127.0.0.1:8080</code>, and maybe LAN addresses like <code>192.168.1.40</code>. These are real page visits as far as Chrome is concerned, but they carry no browsing intent in the sense openloops cares about. Worse, they'd later be sent to <a href="http://context.dev">context.dev</a> during brand enrichment, where they can never resolve to anything and would only waste API credits.</p>
<p>Both problems share a root cause: the pipeline is capturing URLs that aren't really part of your browsing in the first place. The fix is to define what counts as a real, external web page once, and apply that definition everywhere a URL or domain enters the system.</p>
<h3 id="heading-one-definition-applied-everywhere">One Definition, Applied Everywhere</h3>
<p>The two helpers that do this, <code>isHttpUrl</code> and <code>isLocalHost</code>, were written back when you first built <code>src/lib/util.ts</code>. We deliberately introduced them early for exactly this moment.</p>
<p><code>isHttpUrl</code> returns true only for <code>http://</code> and <code>https://</code> URLs, which excludes <code>chrome-extension://</code>, <code>chrome://</code>, <code>about:</code>, and <code>file://</code> in one stroke. <code>isLocalHost</code> returns true for <code>localhost</code>, loopback and private IP ranges, and <code>.local</code> hostnames.</p>
<p>The thing that makes them effective is consistency: the same two functions guard every entry point, so the definition of "a real page" can never drift between one part of the pipeline and another. There are three such entry points.</p>
<p>Live capture, in <code>src/background.ts</code>, calls <code>isHttpUrl</code> before recording anything:</p>
<pre><code class="language-typescript">if (!isHttpUrl(url)) return;
</code></pre>
<p>The backfill, in <code>src/pipeline/backfill.ts</code>, applies the same guard to every history item before fetching its visits:</p>
<pre><code class="language-typescript">if (!item.url) return [];
if (!isHttpUrl(item.url)) return [];
</code></pre>
<p>And the noise filter, in <code>src/pipeline/noise.ts</code>, checks both helpers at the very top of <code>isNoise</code>, before any of its domain or title rules run:</p>
<pre><code class="language-typescript">export function isNoise(event: RawEvent): boolean {
  if (!isHttpUrl(event.url)) return true;
  if (isLocalHost(event.domain)) return true;
  return domainIsBlocked(event.domain) || titleIsGeneric(event.title, event.domain);
}
</code></pre>
<p>Capture and backfill already screen out non-web URLs, so checking <code>isHttpUrl</code> a third time inside <code>isNoise</code> looks redundant, and in normal operation it is. The third check is a guarantee: if a stray non-web event ever reaches <code>raw_events</code> through some path you didn't anticipate (like a future capture mechanism, imported data, or a bug), it still can't survive into a session.</p>
<p>Each stage defends its own input rather than trusting that an earlier stage did its job. This is what keeps a single missed case from silently propagating all the way into the intent map.</p>
<h3 id="heading-defending-the-enrichment-boundary-too">Defending the Enrichment Boundary Too</h3>
<p>The same <code>isLocalHost</code> check appears once more, in the brand enrichment step you'll build next, where domains get sent to <a href="http://context.dev">context.dev</a>. Even though <code>isNoise</code> already strips local addresses before sessionization, the enrichment function filters them again before making any network call:</p>
<pre><code class="language-typescript">const unique = [...new Set(domains)].filter((d) =&gt; !isLocalHost(d));
</code></pre>
<p>The reasoning is the same defense-in-depth idea, applied to a boundary where the cost of a mistake is higher. A local address that somehow reached a thread's domain list shouldn't just be useless noise in the UI. It should never leave your machine as part of an API request. Putting the filter directly at the network boundary means that guarantee holds regardless of what happened upstream.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>After loading the updated build, openloops should stop appearing in its own intent map. To verify, open the dashboard a handful of times, browse some real pages, then rebuild the pipeline: the <code>chrome-extension://</code> self-thread should be gone, and no <code>localhost</code> or private-IP domains should appear in any thread's domain list.</p>
<p>If you inspect <code>raw_events</code> in DevTools, you may still see live-captured events from before this fix, since the backfill clears and rewrites events but live capture appends. Running a fresh "Scan my history" wipes and repopulates <code>raw_events</code> cleanly under the new rules.</p>
<p>With the pipeline now producing a clean intent map of genuinely external browsing, it's worth making those threads more legible.</p>
<p>Up to now, each thread's title is just its top three keywords stitched together, and there's no summary or suggested next step at all. The next section adds the first optional, key-gated layer: AI labeling with Claude.</p>
<h2 id="heading-how-to-label-threads-with-claude">How to Label Threads with Claude</h2>
<p>A thread titled "Typescript Generics Handbook" is readable, but it's a description of the keywords – not of what you were trying to do. "Learning TypeScript's advanced type system" is the kind of label a person would actually write, and the difference between those two is the gap this section closes.</p>
<p>Claude reads each thread's keywords, domains, and sample page titles, and returns a real title, a one-sentence summary, a classification, and a concrete next step.</p>
<p>This is the first part of openloops that calls an external API and requires a key. Everything about its design is shaped by one constraint: the request has to survive real data, where a person might have thirty or forty threads, each carrying a dozen page titles.</p>
<p>The naïve version of this is to send all the threads in one request and ask for all the labels back. And that's exactly what the first implementation did. But it failed in a way worth walking through, because the fix is the most instructive part of the whole section.</p>
<h3 id="heading-storing-keys-locally">Storing Keys Locally</h3>
<p>Before any API call, the key needs somewhere to live. openloops keeps it in <code>chrome.storage.local</code>, which never syncs anywhere and never leaves the device. Create <code>src/lib/settings.ts</code>:</p>
<pre><code class="language-typescript">export async function getApiKey(): Promise&lt;string | null&gt; {
  const result = await chrome.storage.local.get("anthropicApiKey");
  return (result.anthropicApiKey as string) ?? null;
}

export async function setApiKey(key: string): Promise&lt;void&gt; {
  await chrome.storage.local.set({ anthropicApiKey: key });
}
</code></pre>
<p>The same file later grows parallel getters and setters for the <a href="http://context.dev">context.dev</a> key and the assistant's model and effort preferences, all following this identical shape. So it's enough to understand this one pair to understand all of them.</p>
<h3 id="heading-the-first-version-and-how-it-broke">The First Version, and How it Broke</h3>
<p>The first labeling implementation sent every thread to Claude in a single request: serialize all forty threads into one JSON payload, ask for a JSON array of forty labels in return, parse it, write it back. It worked perfectly with five or six threads during early testing, then silently produced nothing once a real history with thirty-plus threads went through it. There was no error or thrown exception, just threads that kept their old keyword titles as if the labeling had never run.</p>
<p>The cause was output token truncation. A request specifies <code>max_tokens</code>, the ceiling on how much the model may generate in response, and forty threads' worth of titles, summaries, and next steps is a lot of output. When the response hit that ceiling mid-generation, the JSON array was cut off partway through an opening <code>[</code> and thirty complete objects followed by half of the thirty-first and no closing <code>]</code>. <code>JSON.parse</code> on that throws, the catch block logged it and returned nothing, and because labeling was designed to fail gracefully and leave existing titles intact, the failure was invisible from the UI.</p>
<p>Two design changes came out of this, and both are in the final code: split the work into small batches so no single response can grow large enough to truncate, and make the parsing resilient enough that one bad batch can't take down the whole run.</p>
<h3 id="heading-batching-the-requests">Batching the Requests</h3>
<p>Create <code>src/pipeline/label.ts</code>, starting with the per-batch request function:</p>
<pre><code class="language-typescript">import { getAllThreads, putThreads, getAllBrands } from "../db/index";
import type { IntentThread } from "../types";

interface ThreadDescriptor {
  id: string;
  keywords: string[];
  domains: string[];
  sampleTitles: string[];
  domainContext: string[];
}

interface LabelResult {
  id: string;
  title: string;
  summary: string;
  type: string;
  nextStep: string;
}

const VALID_TYPES: ReadonlySet&lt;IntentThread["type"]&gt; = new Set([
  "buying",
  "research",
  "learning",
  "planning",
  "unclassified",
]);

const BATCH_SIZE = 10;
const MAX_TOKENS_PER_BATCH = 4000;

async function callClaudeBatch(
  apiKey: string,
  systemPrompt: string,
  batch: ThreadDescriptor[],
): Promise&lt;LabelResult[] | null&gt; {
  const response = await fetch("https://api.anthropic.com/v1/messages", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-api-key": apiKey,
      "anthropic-version": "2023-06-01",
      "anthropic-dangerous-direct-browser-access": "true",
    },
    body: JSON.stringify({
      model: "claude-haiku-4-5-20251001",
      max_tokens: MAX_TOKENS_PER_BATCH,
      system: systemPrompt,
      messages: [
        {
          role: "user",
          content: JSON.stringify(batch),
        },
      ],
    }),
  });

  if (!response.ok) {
    let body = "";
    try { body = (await response.text()).slice(0, 400); } catch { }
    console.error(
      `[openloops] label: API request failed\n` +
      `  → HTTP \({response.status} \){response.statusText}\n` +
      `  body: ${body || "(empty)"}`,
    );
    if (response.status === 401) {
      throw new Error("Invalid API key. Check your Anthropic API key and try again.");
    }
    throw new Error(`API request failed: \({response.status} \){response.statusText}`);
  }

  const data = await response.json();
  const raw: string = data.content[0].text;

  const cleaned = raw
    .trim()
    .replace(/^```(?:json)?\s*/, "")
    .replace(/```\s*$/, "")
    .trim();

  try {
    return JSON.parse(cleaned);
  } catch (err) {
    console.error(`[openloops] label: parse error: ${err instanceof Error ? err.message : String(err)}`);
    console.error(`[openloops] label: raw tail (last 400 chars):\n${raw.slice(-400)}`);
    return null;
  }
}
</code></pre>
<p><code>BATCH_SIZE</code> of 10 with <code>MAX_TOKENS_PER_BATCH</code> of 4000 is the direct answer to the truncation problem. Ten threads' worth of labels comfortably fits inside 4000 output tokens with room to spare, so a batch can't hit the ceiling and get cut off. A history with forty threads becomes four independent requests rather than one oversized one.</p>
<p>The request itself uses raw <code>fetch</code> rather than Anthropic's TypeScript SDK, because the SDK isn't built to run in a browser or extension context.</p>
<p>Browser-originated calls to the Anthropic API also require the <code>anthropic-dangerous-direct-browser-access</code> header, which is what opts into this usage pattern. The model is Claude Haiku, the fastest and cheapest in the lineup, which is well-matched to a high-volume, structured-output task like this one where you're making several calls and want them quick.</p>
<p>The error handling splits into two deliberately different behaviors. An HTTP-level failure (a 401 from a bad key, a 429 from rate limiting) throws, because every subsequent batch would fail the same way and there's no point continuing. A <em>parse</em> failure, by contrast, returns <code>null</code> rather than throwing, so the caller can skip just that one batch and keep going with the rest.</p>
<p>The fence-stripping before <code>JSON.parse</code> handles a common real-world wrinkle: models sometimes wrap JSON output in a Markdown code fence (<code>```json</code>), even when asked for raw JSON. The two <code>.replace</code> calls strip a leading fence and a trailing fence if present, tolerating surrounding whitespace, so a response comes through whether or not it arrived wrapped.</p>
<p>When parsing still fails, the catch logs the last 400 characters of the raw response, which is precisely where you'd see the truncation signature of a cut-off array, the diagnostic that would have made the original bug obvious in minutes.</p>
<h3 id="heading-building-the-prompt-and-merging-results">Building the Prompt and Merging Results</h3>
<p>The public <code>labelThreads</code> function builds the descriptors, runs the batches, and merges what comes back:</p>
<pre><code class="language-typescript">export async function labelThreads(apiKey: string): Promise&lt;{ labeled: number }&gt; {
  const threads = await getAllThreads();
  if (threads.length === 0) return { labeled: 0 };

  const allBrands = await getAllBrands();
  const brandMap = new Map(allBrands.map((b) =&gt; [b.domain, b]));

  const descriptors: ThreadDescriptor[] = threads.map((t) =&gt; {
    const keywords = [...new Set(t.sessions.flatMap((s) =&gt; s.keywords))].slice(0, 8);
    const domains  = [...new Set(t.sessions.flatMap((s) =&gt; s.domains))].slice(0, 5);
    const titles   = [...new Set(t.sessions.flatMap((s) =&gt; s.events.map((e) =&gt; e.title)))].slice(0, 20);

    const domainContext = domains
      .map((d) =&gt; {
        const brand = brandMap.get(d);
        if (!brand || !brand.name) return null;
        let line = `\({d}: \){brand.name}`;
        if (brand.description) line += ` — ${brand.description}`;
        if (brand.industry)    line += ` (${brand.industry})`;
        return line;
      })
      .filter((s): s is string =&gt; s !== null);

    return { id: t.id, keywords, domains, sampleTitles: titles, domainContext };
  });

  const systemPrompt = `You label browsing intent threads. Return ONLY a JSON array — no markdown fences, no explanation.
Each element: { "id": "&lt;thread id&gt;", "title": "&lt;3-6 word title&gt;", "summary": "&lt;1 sentence&gt;", "type": "&lt;buying|research|learning|planning|unclassified&gt;", "nextStep": "&lt;one concrete, specific action to move this thread forward or close the loop&gt;" }
The nextStep must be grounded in what the person was actually looking at. Be specific — name the actual decision, comparison, or action (e.g. "Decide between MacBook Pro and Dell XPS — your open question was battery life") rather than generic advice ("continue researching"). Use the sampleTitles and domainContext to ground it.
Each thread descriptor may include a "domainContext" array of company descriptions for the sites visited. When present, use these to produce sharper, more specific titles, summaries, and next steps grounded in what each company actually does.
Respond with exactly one array covering every thread in the request.`;

  const allResults: LabelResult[] = [];
  let failedBatches = 0;
  for (let i = 0; i &lt; descriptors.length; i += BATCH_SIZE) {
    const batch = descriptors.slice(i, i + BATCH_SIZE);
    const results = await callClaudeBatch(apiKey, systemPrompt, batch);
    if (results === null) {
      failedBatches++;
      continue;
    }
    allResults.push(...results);
  }

  const byId = new Map(allResults.map((r) =&gt; [r.id, r]));

  let labeled = 0;
  const updated = threads.map((t) =&gt; {
    const label = byId.get(t.id);
    if (!label) return t;

    const type = VALID_TYPES.has(label.type as IntentThread["type"])
      ? (label.type as IntentThread["type"])
      : t.type;

    labeled++;
    return {
      ...t,
      title:    label.title    || t.title,
      summary:  label.summary  || undefined,
      nextStep: label.nextStep || undefined,
      type,
    };
  });

  await putThreads(updated);
  return { labeled };
}
</code></pre>
<p>Each thread is compressed into a <code>ThreadDescriptor</code> carrying only what Claude needs to label it: up to eight keywords, five domains, and twenty sample page titles, capped so a thread with hundreds of events doesn't bloat the payload.</p>
<p>The <code>domainContext</code> field is the hook for the brand-grounding step covered in the next section. It's empty for now since no brands have been fetched yet, which is exactly why labeling works fine on its own and gets sharper once grounding is added.</p>
<p>The merge step is where a failed batch costs you only its own threads. Results come back as a flat list across all successful batches, indexed by thread id into <code>byId</code>.</p>
<p>Then every thread is walked: if a label came back for it, the AI title, summary, next step, and type are merged in, with the returned <code>type</code> validated against <code>VALID_TYPES</code> and falling back to the heuristic type if the model returned something unexpected. If no label came back, because that thread's batch failed to parse, the thread is returned untouched, keeping the keyword title and heuristic classification it already had.</p>
<p>A single failed batch costs you ten threads' worth of polish, not the entire run, and never corrupts a thread with malformed data.</p>
<p>Notice that <code>title</code>, <code>summary</code>, and <code>nextStep</code> all guard against empty strings with <code>|| t.title</code> and <code>|| undefined</code>. A thread always has a usable title even if the model returned a blank one, and <code>summary</code> and <code>nextStep</code> stay <code>undefined</code> rather than becoming empty strings. This keeps the dashboard's "does this thread have a summary?" checks honest.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>Labeling needs a key and a button, both of which arrive with the dashboard later in this guide, so a full end-to-end test waits until then.</p>
<p>What you can verify now is that <code>src/lib/settings.ts</code> and <code>src/pipeline/label.ts</code> compile, and that the request shape is correct by calling <code>labelThreads</code> with a real key from a temporary test harness if you want immediate feedback. When it runs against built threads, the <code>console</code> will show batch progress, and your threads' titles in IndexedDB will change from keyword fragments to readable phrases, with <code>summary</code> and <code>nextStep</code> fields appearing for the first time.</p>
<p>The labels are already a large improvement, but they're working from keywords and bare domain names. This means a thread built around <code>mastra.ai</code> and <code>langchain.com</code> has no idea those are AI agent frameworks. It only sees two domain strings.</p>
<p>The next section closes that gap by resolving domains into real company descriptions before labeling. This is the grounding step that gives the AI something concrete to reason about.</p>
<h2 id="heading-how-to-ground-labels-with-contextdev">How to Ground Labels with <a href="http://context.dev">context.dev</a></h2>
<p>This is the most distinctive idea in openloops, so it's worth stating plainly before any code: instead of asking the model to label a thread from keywords and bare domain names, openloops first resolves each domain into a real company description – what the company is, what industry it's in, what it actually does – and feeds those descriptions into the labeling prompt. The model labels the thread knowing that <code>mastra.ai</code> and <code>langchain.com</code> are both AI agent frameworks, rather than seeing two opaque strings it has to guess about.</p>
<p>A thread whose keywords are "mastra langchain sholajegede" produces, ungrounded, a title like "Mastra Langchain Sholajegede", a literal echo of the keywords. Grounded with the knowledge that those domains are competing agent frameworks, the same thread becomes "Benchmarking Mastra against LangChain", a title that names the actual intent.</p>
<p>The raw material for a good label was always there in the browsing. What was missing was the context to interpret it, and that context is exactly what a brand-intelligence API provides.</p>
<h3 id="heading-what-the-api-returns">What the API Returns</h3>
<p>openloops uses context.dev, which resolves a domain into a structured brand record: company name, a one-line description, industry classification, brand colors, and logo URLs. The grounding step needs the name, description, and industry, while the logo and colors get used later by the dashboard to render domain chips.</p>
<p>This step is entirely optional: the labeling from the previous section works without it, and grounding simply makes the output sharper when a context.dev key is present.</p>
<p>Like the Anthropic key, the context.dev key lives in <code>chrome.storage.local</code>, via the same getter/setter pattern in <code>src/lib/settings.ts</code>:</p>
<pre><code class="language-typescript">export async function getContextKey(): Promise&lt;string | null&gt; {
  const result = await chrome.storage.local.get("contextDevApiKey");
  return (result.contextDevApiKey as string) ?? null;
}

export async function setContextKey(key: string): Promise&lt;void&gt; {
  await chrome.storage.local.set({ contextDevApiKey: key });
}
</code></pre>
<p>Brand records also need a place to be cached, since resolving the same domain twice is wasteful and costs API credits. Bump <code>DB_VERSION</code> to 4 and add a <code>domain_brands</code> store keyed by domain:</p>
<pre><code class="language-typescript">import type { RawEvent, Session, IntentThread, Brand } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: { key: string; value: RawEvent; indexes: { by_visitedAt: number } };
  sessions: { key: string; value: Session; indexes: { by_startedAt: number } };
  intent_threads: { key: string; value: IntentThread; indexes: { by_lastSeen: number } };
  domain_brands: {
    key: string;
    value: Brand;
  };
}

const DB_VERSION = 4;
</code></pre>
<p>Inside the <code>upgrade</code> callback, the new store is added with the same guard as the others, and <code>domain_brands</code> is keyed on <code>domain</code> rather than <code>id</code> because a domain is its own natural unique key:</p>
<pre><code class="language-typescript">if (!db.objectStoreNames.contains("domain_brands")) {
  db.createObjectStore("domain_brands", { keyPath: "domain" });
}
</code></pre>
<p>The matching helpers add one that's specific to caching, <code>getCachedDomains</code>. This returns the set of domains already resolved so the enrichment step can skip them:</p>
<pre><code class="language-typescript">export async function getBrand(domain: string): Promise&lt;Brand | undefined&gt; {
  const db = await getDB();
  return db.get("domain_brands", domain);
}

export async function putBrands(brands: Brand[]): Promise&lt;void&gt; {
  if (brands.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("domain_brands", "readwrite");
  await Promise.all([...brands.map((b) =&gt; tx.store.put(b)), tx.done]);
}

export async function getAllBrands(): Promise&lt;Brand[]&gt; {
  const db = await getDB();
  return db.getAll("domain_brands");
}

export async function getCachedDomains(): Promise&lt;Set&lt;string&gt;&gt; {
  const db = await getDB();
  const keys = await db.getAllKeys("domain_brands");
  return new Set(keys);
}
</code></pre>
<h3 id="heading-fetching-one-brand">Fetching One Brand</h3>
<p>Create <code>src/pipeline/enrich.ts</code>. The core is a function that resolves a single domain, and most of its length is there to make sure a slow or failing lookup can never hang or crash the whole step:</p>
<pre><code class="language-typescript">import { getCachedDomains, putBrands } from "../db/index";
import { isLocalHost } from "../lib/util";
import type { Brand } from "../types";

const API_BASE        = "https://api.context.dev/v1";
const LOGO_LINK_BASE  = "https://logos.context.dev";

const REQUEST_TIMEOUT_MS = 15_000;
const BATCH_SIZE     = 3;
const BATCH_DELAY_MS = 2_000;

interface FetchResult {
  brand: Brand | null;
  errorCode?: string;
}

async function fetchBrand(domain: string, contextKey: string): Promise&lt;FetchResult&gt; {
  const url = `\({API_BASE}/brand/retrieve?domain=\){encodeURIComponent(domain)}`;
  const headers = { Authorization: `Bearer ${contextKey}` };

  async function attempt(): Promise&lt;Response&gt; {
    const ctrl = new AbortController();
    const tid  = setTimeout(() =&gt; ctrl.abort(), REQUEST_TIMEOUT_MS);
    try {
      return await fetch(url, { headers, signal: ctrl.signal });
    } finally {
      clearTimeout(tid);
    }
  }

  try {
    let res = await attempt();

    if (res.status === 408) {
      res = await attempt();
    }

    if (!res.ok) {
      let body = "";
      try { body = (await res.text()).slice(0, 400); } catch { }
      console.error(`[openloops] enrich: HTTP \({res.status} for "\){domain}" — ${body}`);
      return { brand: null, errorCode: String(res.status) };
    }

    let data: { status?: string; brand?: Record&lt;string, unknown&gt; };
    try {
      data = await res.json();
    } catch (e) {
      return { brand: null, errorCode: "parse" };
    }

    if (data.status !== "ok" || !data.brand) {
      return { brand: null, errorCode: "shape" };
    }

    const b = data.brand as {
      title?:        string;
      description?:  string;
      colors?:       { hex?: string }[];
      logos?:        { url?: string }[];
      industries?:   { eic?: { industry?: string; subindustry?: string }[] };
    };

    const logoUrl =
      b.logos?.[0]?.url ||
      `\({LOGO_LINK_BASE}?domain=\){encodeURIComponent(domain)}`;

    return {
      brand: {
        domain,
        name:        b.title                          ?? domain,
        description: b.description                    ?? "",
        industry:    b.industries?.eic?.[0]?.industry ?? "",
        logoUrl,
        brandColor:  b.colors?.[0]?.hex               ?? "",
      },
    };

  } catch (err) {
    if (err instanceof Error &amp;&amp; err.name === "AbortError") {
      return { brand: null, errorCode: "timeout" };
    }
    return { brand: null, errorCode: "network" };
  }
}
</code></pre>
<p>The request authenticates with a bearer token and hits a single <code>brand/retrieve</code> endpoint. The <code>attempt</code> inner function wraps each call in an <code>AbortController</code> with a 15-second timeout, so a stalled connection aborts itself rather than hanging the enrichment step indefinitely.</p>
<p>The <code>finally</code> clears the timer whether the request succeeds, fails, or aborts. A <code>408</code> response from context.dev means a cold cache miss on their side, which their documentation says to retry once, so a single retry handles it before giving up.</p>
<p>The response is unpacked defensively at every level: a non-OK status returns a <code>FetchResult</code> with the HTTP code, a body that won't parse returns a <code>"parse"</code> error, and a response whose shape isn't what's expected returns a <code>"shape"</code> error.</p>
<p>When the brand record does come through, each field falls back to a sensible default if absent, the company name falls back to the domain itself, the description and industry to empty strings, and the logo to context.dev's keyless logo CDN if the record carries no logo URL.</p>
<p>Every failure path returns <code>{ brand: null, errorCode }</code> rather than throwing, which is what lets the batch driver above it treat a single domain's failure as a skip rather than a crash.</p>
<h3 id="heading-enriching-domains-in-batches">Enriching Domains in Batches</h3>
<p>The public <code>enrichDomains</code> function resolves a list of domains, skipping ones already cached and respecting the API's rate limit:</p>
<pre><code class="language-typescript">export async function enrichDomains(
  contextKey: string,
  domains: string[],
): Promise&lt;{ enriched: number; failed: number; error?: string }&gt; {
  const unique = [...new Set(domains)].filter((d) =&gt; !isLocalHost(d));

  let cached: Set&lt;string&gt;;
  try {
    cached = await getCachedDomains();
  } catch (err) {
    return { enriched: 0, failed: 0, error: "DB error" };
  }

  const toFetch = unique.filter((d) =&gt; !cached.has(d));
  if (toFetch.length === 0) return { enriched: 0, failed: 0 };

  let enriched = 0;
  let failed   = 0;
  let firstErrorCode: string | undefined;

  for (let i = 0; i &lt; toFetch.length; i += BATCH_SIZE) {
    const batch   = toFetch.slice(i, i + BATCH_SIZE);
    const results = await Promise.all(batch.map((d) =&gt; fetchBrand(d, contextKey)));

    const brands = results.map((r) =&gt; r.brand).filter((b): b is Brand =&gt; b !== null);

    for (const r of results) {
      if (!r.brand) {
        failed += 1;
        if (!firstErrorCode) firstErrorCode = r.errorCode;
      }
    }

    if (brands.length &gt; 0) {
      try {
        await putBrands(brands);
        enriched += brands.length;
      } catch (err) {
        failed += brands.length;
      }
    }

    if (i + BATCH_SIZE &lt; toFetch.length) {
      await new Promise&lt;void&gt;((resolve) =&gt; setTimeout(resolve, BATCH_DELAY_MS));
    }
  }

  let error: string | undefined;
  if (firstErrorCode) {
    const map: Record&lt;string, string&gt; = {
      "401":     "401 — invalid key",
      "403":     "403 — check key permissions",
      "429":     "429 — rate limited, try again later",
      "timeout": "request timeout (15 s)",
      "network": "unreachable — check network/CORS",
    };
    error = map[firstErrorCode] ?? firstErrorCode;
  }

  return { enriched, failed, error };
}
</code></pre>
<p>The function opens by stripping local addresses with <code>isLocalHost</code>, the enrichment-boundary guard discussed in the self-referential noise section. This means that a dev server can never be sent to context.dev even if it slipped into a thread's domain list. It then removes already-cached domains via <code>getCachedDomains</code>, so re-running enrichment only ever fetches domains it hasn't seen. This keeps credit usage proportional to new browsing rather than total browsing.</p>
<p>The remaining domains are fetched three at a time, with a two-second pause between batches. This keeps the request rate well under the API's limit without making the user wait through a long serial queue.</p>
<p>Failures are tallied rather than thrown: a domain that fails to resolve increments <code>failed</code> and records its error code, but the loop carries on. The first error code encountered gets mapped to a human-readable message at the end so the UI can show something useful, such as an invalid-key or rate-limit notice.</p>
<p>The whole function returns counts rather than raising, which matters because the dashboard runs enrichment immediately before labeling, and a problem fetching brands should never prevent the labeling that follows it.</p>
<h3 id="heading-how-grounding-feeds-back-into-labeling">How Grounding Feeds Back into Labeling</h3>
<p>Grounding connects back to <code>labelThreads</code> from the previous section, which already builds a <code>domainContext</code> array for each thread by looking up every domain in the brand cache:</p>
<pre><code class="language-typescript">const domainContext = domains
  .map((d) =&gt; {
    const brand = brandMap.get(d);
    if (!brand || !brand.name) return null;
    let line = `\({d}: \){brand.name}`;
    if (brand.description) line += ` — ${brand.description}`;
    if (brand.industry)    line += ` (${brand.industry})`;
    return line;
  })
  .filter((s): s is string =&gt; s !== null);
</code></pre>
<p>Before enrichment runs, the brand cache is empty, every lookup returns nothing, <code>domainContext</code> is an empty array, and the prompt falls back to keywords and domain names alone.</p>
<p>After enrichment, the same code produces lines like <code>mastra.ai: Mastra — TypeScript framework for building AI agents (Developer Tools)</code>, and the labeling prompt's instruction to use <code>domainContext</code> "to produce sharper, more specific titles, summaries, and next steps" finally has something to work with.</p>
<p>The two steps are decoupled by design: labeling never requires grounding, but grounding measurably improves labeling. This is why the dashboard runs them in sequence as a single "enrich, then label" action.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>Like the labeling step, enrichment is exercised through the dashboard, so the full path waits for the dashboard section. For now, confirm that <code>src/pipeline/enrich.ts</code> and the updated <code>src/db/index.ts</code> compile, and that <code>getDB()</code> reports version 4 with <code>domain_brands</code> present in DevTools.</p>
<p>Once it runs against real threads with a context.dev key, the <code>domain_brands</code> store fills with cached records, and your thread labels should noticeably sharpen. The clearest single demonstration will be any thread built around niche or technical domains whose names don't, on their own, reveal what they are.</p>
<p>Every piece of the engine now exists: capture, sessions, clustering, scoring, labeling, and grounding. What's missing is the surface that drives them and shows the results.</p>
<p>The next section builds the dashboard, the three-column React interface with its onboarding flow and pipeline state machine, that turns this pipeline into something a person actually uses.</p>
<h2 id="heading-how-to-design-the-dashboard">How to Design the Dashboard</h2>
<p>The dashboard is a single React component tree rendered into the full-tab page you wired up at the very start when you set <code>options_page</code> in the manifest.</p>
<p>It does three jobs: it drives the pipeline (the buttons that run scanning, session-building, thread-building, and labeling), it displays the resulting intent map (threads grouped by status), and it hosts the assistant covered in the next section.</p>
<p>This section focuses on the structure and the one piece of genuinely interesting logic: the state machine that decides which pipeline button is live at any moment. We'll treat the styling at a summary level here, since it's mostly conventional CSS.</p>
<h3 id="heading-the-three-column-layout">The Three-Column Layout</h3>
<p><code>src/dashboard/App.tsx</code> lays out three columns inside a flex shell. The left rail holds the pipeline controls, the API-key inputs, and the status filter. The center column is the main content: either the onboarding welcome screen or the intent map of threads. The right column holds overview statistics and the assistant chat.</p>
<pre><code class="language-plaintext">┌──────────────┬───────────────────────────┬──────────────────┐
│  LEFT RAIL   │       MAIN COLUMN         │  RIGHT COLUMN    │
│              │                           │                  │
│  Pipeline    │  Welcome screen           │  Overview stats  │
│   · Scan     │    — or —                 │                  │
│   · Sessions │  Intent map:              │  Assistant chat  │
│   · Threads  │   ACTIVE   threads        │   · messages     │
│              │   STALLED  threads        │   · composer     │
│  Keys        │   DORMANT  threads        │   · model/effort │
│  Filter      │                           │                  │
└──────────────┴───────────────────────────┴──────────────────┘
</code></pre>
<p>Each thread renders as a card showing its title, type and status pills, the AI summary, the next-step row with a Resume button, a confidence bar, and a collapsible details section with domains, keywords, and signals.</p>
<p>The cards are grouped into ACTIVE, STALLED, and DORMANT sections, sorted by confidence within each group. The threads most worth acting on rise to the top of the most urgent group.</p>
<p>The styling lives in <code>src/dashboard/app.css</code> and is conventional: a dark theme defined through CSS custom properties (a near-black background, a single orange accent at <code>--accent: #ff5c33</code>, a small scale of grays for text and borders), a monospace font for labels and metadata, and a sans-serif for content.</p>
<p>The design choices that matter for usability are the status-based color coding (the accent for active, a muted amber for stalled, gray for dormant) and the confidence bar's width mapping directly to the thread's confidence score.</p>
<p>None of the CSS is load-bearing for understanding the build, so rather than reproduce it, the rest of this section focuses on the logic the styling sits on top of.</p>
<h3 id="heading-the-pipeline-state-machine">The Pipeline State Machine</h3>
<p>The pipeline has a strict order: you can't build sessions before scanning history, and you can't build threads before building sessions. The dashboard encodes this as a small state machine, and getting it right is what makes the interface feel guided rather than confusing. Every button is either disabled (its input doesn't exist yet), highlighted as the next action to take, or done (re-runnable, but no longer the obvious next step).</p>
<pre><code class="language-typescript">type PipelineState = "disabled" | "next" | "done";

function pipelineStates(
  hasScanned: boolean,
  eventCount: number | null,
  sessionCount: number | null,
  threadCount: number | null,
): { scan: PipelineState; sessions: PipelineState; threads: PipelineState } {
  const hasEvents   = (eventCount   ?? 0) &gt; 0;
  const hasSessions = (sessionCount ?? 0) &gt; 0;
  const hasThreads  = (threadCount  ?? 0) &gt; 0;

  if (!hasScanned)  return { scan: "next", sessions: "disabled", threads: "disabled" };
  if (!hasSessions) return { scan: "done", sessions: hasEvents ? "next" : "disabled", threads: "disabled" };
  if (!hasThreads)  return { scan: "done", sessions: "done", threads: "next" };
  return { scan: "done", sessions: "done", threads: "done" };
}
</code></pre>
<p>The function reads the presence of data at each stage and returns the state of all three buttons. Before any scan, only Scan is live, marked <code>next</code>, while the other two are disabled.</p>
<p>Once events exist but sessions don't, Scan flips to <code>done</code> and Sessions becomes <code>next</code>. Once sessions exist but threads don't, Threads becomes <code>next</code>. Once all three stages have produced output, everything is <code>done</code>, every step re-runnable but none demanding attention. The cascade walks the pipeline in order and lights up exactly one <code>next</code> action at a time, which is what turns a row of three buttons into a guided sequence.</p>
<p>The first parameter, <code>hasScanned</code>, is more subtle than a simple count. It's where a piece of plumbing from the very first capture section pays off.</p>
<p>The check can't just be "are there any events," because live capture starts populating <code>raw_events</code> the moment the extension is installed. There would <em>always</em> be events, and the onboarding would skip straight past the Scan step before the user had ever scanned.</p>
<p>The fix is the <code>source</code> field on every <code>RawEvent</code>, set to <code>"backfill"</code> or <code>"live"</code> back when you built capture. <code>hasScanned</code> comes from a dedicated query that checks specifically for backfill events:</p>
<pre><code class="language-typescript">export async function hasBackfillEvents(): Promise&lt;boolean&gt; {
  const db = await getDB();
  let cursor = await db.transaction("raw_events", "readonly").store.openCursor();
  while (cursor) {
    if (cursor.value.source === "backfill") return true;
    cursor = await cursor.continue();
  }
  return false;
}
</code></pre>
<p>This walks <code>raw_events</code> until it finds a single event with <code>source === "backfill"</code>, returning early the moment it does. Live-captured events alone never satisfy it, so "Scan my history" stays lit as the first step until the user actually runs a backfill, which is the correct onboarding behavior. The seemingly minor decision to tag each event with its origin, made several sections ago, is what makes this distinction possible now.</p>
<h3 id="heading-driving-the-welcome-screen-from-the-same-machine">Driving the Welcome Screen from the Same Machine</h3>
<p>A first-time user with no threads sees a centered welcome screen instead of an empty intent map. But rather than give that screen its own separate logic, the dashboard drives it from the same <code>pipelineStates</code> output. Whichever step is currently <code>next</code> determines which single call-to-action the welcome screen shows:</p>
<pre><code class="language-typescript">let welcomeStep: 1 | 2 | 3 = 1;
let welcomeCtaLabel = "Scan my history";
let welcomeCtaClick = handleScan;
if (scanState === "next") {
  welcomeStep = 1;
  welcomeCtaLabel = scanning ? "Scanning…" : "Scan my history";
  welcomeCtaClick = handleScan;
} else if (sessionsState === "next") {
  welcomeStep = 2;
  welcomeCtaLabel = buildingSessions ? "Building…" : "Build sessions";
  welcomeCtaClick = handleBuildSessions;
} else if (threadsState === "next") {
  welcomeStep = 3;
  welcomeCtaLabel = buildingThreads ? "Building…" : "Build your intent map";
  welcomeCtaClick = handleBuildThreads;
}
</code></pre>
<p>The welcome screen's single button always mirrors the rail's <code>next</code> action, so a user can move through scan, build sessions, and build threads by clicking one prominent button three times. The moment threads exist, the welcome screen is replaced by the intent map. The rail and the welcome screen never disagree about what to do next, because both read from the same source of truth.</p>
<h3 id="heading-wiring-the-handlers">Wiring the Handlers</h3>
<p>The handlers themselves are thin: each runs a pipeline stage, then refreshes the component's view of the database. The action that runs grounding and labeling together is the one worth seeing, because it puts into practice the decoupling described in the previous two sections:</p>
<pre><code class="language-typescript">async function handleEnrichAndLabel() {
  setLabelError(null);
  setEnrichError(null);

  if (contextKey.trim() &amp;&amp; contextKeySaved) {
    setEnriching(true);
    try {
      const allDomains = [...new Set(
        threads.flatMap((t) =&gt; t.sessions.flatMap((s) =&gt; s.domains))
      )];
      const result = await enrichDomains(contextKey.trim(), allDomains);
      if (result.error) setEnrichError(`context.dev: ${result.error}`);
      if (result.enriched &gt; 0) {
        const all = await getAllBrands();
        setBrands(new Map(all.map((b) =&gt; [b.domain, b])));
      }
    } catch (err) {
      setEnrichError(`context.dev: ${err instanceof Error ? err.message : "unknown error"}`);
    } finally {
      setEnriching(false);
    }
  }

  setLabeling(true);
  try {
    await labelThreads(apiKey.trim());
    setThreads(await getAllThreads());
  } catch (err) {
    setLabelError(err instanceof Error ? err.message : "Labeling failed.");
  } finally {
    setLabeling(false);
  }
}
</code></pre>
<p>Enrichment runs only if a context.dev key is present, and it's wrapped so that any failure (like a network error, a bad key, or a rate limit) sets an error message but never stops execution. Labeling then runs unconditionally afterward, outside the enrichment block, so it proceeds whether enrichment succeeded, failed, or was skipped entirely for lack of a key.</p>
<p>That structure is the decoupling from the grounding section made concrete: grounding improves labeling when it works, and labeling degrades gracefully to keyword-and-domain context when it doesn't.</p>
<p>The enrichment error surfaces in amber rather than red, because it's a warning (labeling still happened) rather than a blocking failure. This is a small UI cue that matches the actual severity of what went wrong.</p>
<h3 id="heading-the-resume-button">The Resume Button</h3>
<p>One interaction ties the intent map back to live browsing. Each thread card has a Resume button that reopens the pages you were on, so acting on a thread is one click rather than a hunt through history:</p>
<pre><code class="language-typescript">const RESUME_SKIP_DOMAINS = new Set([
  "google.com", "youtube.com", "bing.com", "duckduckgo.com",
  "gmail.com", "mail.google.com",
]);

function resumeThread(thread: IntentThread): void {
  const seen = new Set&lt;string&gt;();
  const urls: string[] = [];

  const sorted = thread.sessions
    .flatMap((s) =&gt; s.events)
    .sort((a, b) =&gt; b.visitedAt - a.visitedAt);

  for (const ev of sorted) {
    if (RESUME_SKIP_DOMAINS.has(ev.domain)) continue;
    if (seen.has(ev.url)) continue;
    seen.add(ev.url);
    urls.push(ev.url);
    if (urls.length &gt;= 3) break;
  }

  urls.forEach((url, i) =&gt; {
    chrome.tabs.create({ url, active: i === 0 });
  });
}
</code></pre>
<p>Resume sorts the thread's events newest-first, skips search engines and webmail (which are waypoints rather than destinations you'd want to return to), dedupes by URL, and opens the three most recent meaningful pages. The first is the active tab and the rest are in the background. It's a small feature, but it's the thing that makes a thread feel like a place you can return to rather than a record of where you've been.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>With the dashboard wired up, the entire pipeline is finally usable end to end through the interface. Reload the extension, open the dashboard, and you should see the welcome screen prompting you to scan.</p>
<p>Click through scan, build sessions, build your intent map, and the threads should appear, grouped by status. Add an Anthropic key, optionally a context.dev key, and click "Label &amp; enrich" to see titles and next steps sharpen. The full loop you've built across every previous section now runs from a single screen.</p>
<p>What remains is the conversational layer on the right: an AI assistant that can reason across all your threads at once and answer questions like "what should I close this week?" The next section builds it.</p>
<h2 id="heading-how-to-build-the-ai-assistant">How to Build the AI Assistant</h2>
<p>The labeling step asks Claude to describe one thread at a time. The assistant asks something harder: to reason across all of your threads together and answer open-ended questions about them, like what to close this week, what you've stalled on longest, or how to finish a particular one.</p>
<p>This is a chat interface, but a constrained one – grounded entirely in your own thread data, so its answers reference real threads by name rather than offering generic productivity advice.</p>
<p>The whole design rests on one idea: a chat assistant is only as good as the context it's given. So most of the work here is in building the right grounding context for each message, not in the chat mechanics themselves.</p>
<h3 id="heading-grounding-the-conversation">Grounding the Conversation</h3>
<p>Before any message goes to Claude, the assistant assembles a system prompt describing the user's threads. It does this in one of two modes, depending on whether the user has clicked into a specific thread.</p>
<p>With no thread selected, it builds a compact digest of every thread. With one selected, it gives rich detail on that thread and a brief list of the others.</p>
<pre><code class="language-typescript">function buildGroundingContext(
  threads: IntentThread[],
  brands: Map&lt;string, Brand&gt;,
  selectedThread: IntentThread | null,
): string {
  if (!selectedThread) {
    const digest = threads
      .map((t) =&gt; {
        const domains = [...new Set(t.sessions.flatMap((s) =&gt; s.domains))].slice(0, 5).join(", ");
        return `- \({t.title} (\){t.status}, \({t.type}): \){t.summary ?? "no summary yet"} | next: \({t.nextStep ?? "none"} | domains: \){domains || "none"}`;
      })
      .join("\n");

    return `\({SYSTEM_INSTRUCTION}\n\nHere is a digest of all the user's open intent threads:\n\){digest || "(no threads yet)"}`;
  }

  const keywords = [...new Set(selectedThread.sessions.flatMap((s) =&gt; s.keywords))].slice(0, 10).join(", ");
  const domains = [...new Set(selectedThread.sessions.flatMap((s) =&gt; s.domains))].slice(0, 5);

  const domainLines = domains
    .map((d) =&gt; {
      const brand = brands.get(d);
      if (brand?.description) return `- \({d}: \){brand.name} — ${brand.description}`;
      return `- ${d}`;
    })
    .join("\n");

  const sampleTitles = [...new Set(selectedThread.sessions.flatMap((s) =&gt; s.events.map((e) =&gt; e.title)))]
    .slice(0, 20)
    .map((t) =&gt; `- ${t}`)
    .join("\n");

  const otherTitles = threads
    .filter((t) =&gt; t.id !== selectedThread.id)
    .map((t) =&gt; t.title)
    .join(", ");

  return `${SYSTEM_INSTRUCTION}

The user is focused on this thread:
Title: ${selectedThread.title}
Status: ${selectedThread.status}
Type: ${selectedThread.type}
Summary: ${selectedThread.summary ?? "none"}
Next step: ${selectedThread.nextStep ?? "none"}
Keywords: ${keywords || "none"}

Domains visited:
${domainLines || "(none)"}

Recent page titles:
${sampleTitles || "(none)"}

For context, the user's other open threads are: ${otherTitles || "none"}.`;
}
</code></pre>
<p>The two modes match the two kinds of questions people ask. A question like "what should I close this week?" is about the whole set, so the digest mode gives Claude a one-line summary of every thread. This is enough breadth to compare and prioritize across all of them.</p>
<p>A question like "how do I finish this one?", on the other hand, is about a single thread, so the focused mode trades breadth for depth. It hands over that thread's keywords, its domains with their brand descriptions, and up to twenty real page titles, while still naming the other threads so Claude knows what else is in play.</p>
<p>The focused mode is where brand grounding shows up again. The same brand records fetched during enrichment get woven into the domain list, so when the user asks about a thread, Claude sees <code>mastra.ai: Mastra — TypeScript framework for building AI agents</code> rather than a bare domain. This is the identical grounding principle from labeling, now applied to conversation.</p>
<p>The system instruction that prefixes both modes pins the assistant to its data:</p>
<pre><code class="language-typescript">const SYSTEM_INSTRUCTION =
  `You are the assistant inside "openloops", a browser extension that reconstructs ` +
  `the user's browsing history into "intent threads" — decisions, research, or ` +
  `plans they started and haven't closed. Help the user understand and act on ` +
  `these open loops. Be concrete: reference the actual threads by name and ` +
  `suggest real next actions. You are grounded only in the thread data provided ` +
  `below — if the user asks about something not present in it, say so plainly ` +
  `rather than guessing.`;
</code></pre>
<p>The final instruction is the important one: telling the model to admit when something isn't in its data, rather than inventing a plausible answer, is what keeps the assistant trustworthy when a user asks about a thread that doesn't exist or a detail the data doesn't contain.</p>
<h3 id="heading-sending-a-message">Sending a Message</h3>
<p>The send function rebuilds the grounding context fresh on every message. The assistant always reflects the current state of the threads (including any that changed since the conversation started) and posts the whole message history to Claude:</p>
<pre><code class="language-typescript">async function send(text: string) {
  const trimmed = text.trim();
  if (!trimmed || sending) return;

  if (!keySaved) {
    setError("Add your Anthropic key above to chat.");
    return;
  }

  setError(null);
  const nextMessages: Message[] = [...messages, { role: "user", content: trimmed }];
  setMessages(nextMessages);
  setInput("");
  setSending(true);

  try {
    const systemPrompt = buildGroundingContext(threads, brands, selectedThread);
    const maxTokens = EFFORT_OPTIONS.find((e) =&gt; e.id === effort)?.maxTokens ?? 1024;

    const response = await fetch("https://api.anthropic.com/v1/messages", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-api-key": apiKey,
        "anthropic-version": "2023-06-01",
        "anthropic-dangerous-direct-browser-access": "true",
      },
      body: JSON.stringify({
        model,
        max_tokens: maxTokens,
        system: systemPrompt,
        messages: nextMessages.map((m) =&gt; ({ role: m.role, content: m.content })),
      }),
    });

    if (!response.ok) {
      if (response.status === 401) {
        throw new Error("Invalid API key. Check your Anthropic API key and try again.");
      }
      throw new Error(`API request failed: \({response.status} \){response.statusText}`);
    }

    const data: { content: AnthropicContentBlock[] } = await response.json();
    const reply = data.content
      .filter((b) =&gt; b.type === "text" &amp;&amp; b.text)
      .map((b) =&gt; b.text)
      .join("");

    setMessages((prev) =&gt; [...prev, { role: "assistant", content: reply || "(empty response)" }]);
  } catch (err) {
    setError(err instanceof Error ? err.message : "Something went wrong.");
  } finally {
    setSending(false);
  }
}
</code></pre>
<p>The mechanics mirror the labeling request, the same endpoint, the same browser-access header, and the same 401-aware error handling, since both talk to the same API from the same constrained environment. The user's message gets appended to the running <code>messages</code> array, the full array is sent so the model has the conversation so far, and the assembled grounding context rides along as the <code>system</code> prompt. The reply is extracted by concatenating the text blocks from the response, with a fallback string if the model returned nothing usable.</p>
<p>Rebuilding <code>buildGroundingContext</code> on every send rather than once per conversation is a deliberate choice: if the user re-runs the pipeline or labels their threads mid-conversation, the next message reflects the updated data automatically, with no stale snapshot from when the chat began.</p>
<h3 id="heading-model-and-effort-controls">Model and Effort Controls</h3>
<p>The assistant exposes two selectors: which model to use and how much depth to allow. Both are persisted to <code>chrome.storage.local</code> through the same settings pattern as the keys:</p>
<pre><code class="language-typescript">const MODEL_OPTIONS = [
  { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5 — fastest" },
  { id: "claude-sonnet-4-6",          label: "Sonnet 4.6 — balanced" },
  { id: "claude-opus-4-8",            label: "Opus 4.8 — most capable" },
];

const EFFORT_OPTIONS = [
  { id: "low",    label: "Low",    maxTokens: 512 },
  { id: "medium", label: "Medium", maxTokens: 1024 },
  { id: "high",   label: "High",   maxTokens: 2048 },
];
</code></pre>
<p>The model selector spans the speed-versus-capability range: Haiku for quick answers, Opus for harder reasoning over a tangled set of threads. The effort selector maps to <code>max_tokens</code>, controlling how long an answer the model may produce. This is a reasonable proxy for response depth given the Messages API has no dedicated depth control. A user wanting a one-line answer picks Low, while one wanting a reasoned, prioritized plan picks High.</p>
<h3 id="heading-rendering-replies-and-the-empty-state">Rendering Replies and the Empty State</h3>
<p>The assistant renders Claude's replies as Markdown, since the model naturally formats prioritized lists and step-by-step suggestions with headings and bullets. This would look like raw asterisks and hashes if rendered as plain text. Using <code>react-markdown</code>, the reply component is essentially <code>&lt;ReactMarkdown&gt;{m.content}&lt;/ReactMarkdown&gt;</code> for assistant messages, with user messages rendered as plain text. The accompanying styles target the rendered Markdown elements to match the dashboard's type scale.</p>
<p>Before any conversation starts, the panel shows an empty state with a one-line explanation and a few suggested prompts as clickable chips, "What should I close this week?", "Summarize my open loops", "What have I stalled on longest?". These both demonstrate what the assistant can do and give a one-click way to start.</p>
<p>The suggested prompts shift slightly when a thread is focused, offering "How do I finish this one?" in place of the whole-set summary, matching the focused grounding mode.</p>
<p>A privacy line sits permanently below the composer, stating that chats send thread titles and summaries to Anthropic and nothing else leaves the device. This is the same honest disclosure principle applied throughout, placed where the user will see it before they type.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>With the assistant in place, openloops is feature-complete. Reload, build your intent map, add your Anthropic key, and try the suggested prompts. Ask what to close this week and the assistant should name specific threads and reason about which are easy wins versus which need a real decision. Click into a single thread and ask how to finish it, and the answer should narrow to that thread's specifics.</p>
<p>The conversation reflects your real, current threads, and nothing about it leaves your machine except the thread summaries you can see in the grounding context itself.</p>
<p>The build is done. The final section steps back to look at what you've made: how it compares to the one mainstream attempt at this idea, what the privacy model adds up to, and where you might take it next.</p>
<h2 id="heading-what-youve-built-and-where-to-take-it">What You've Built, and Where to Take It</h2>
<p>You've built a complete system: browsing history flows in through capture, gets cleaned and segmented into sessions, clustered and scored into intent threads, optionally labeled and grounded by AI, and surfaced through a dashboard with a conversational assistant. Every stage runs on your own machine, and the AI layers are optional additions on top of a pipeline that works without them.</p>
<p>If the clustering reminds you of Chrome's old <a href="https://blog.google/products-and-platforms/products/chrome/finding-answers-gets-better-chrome/">Journeys</a> feature, that's a fair connection. Grouping history by topic instead of by time is the same starting point.</p>
<p>openloops takes it further: every thread carries a confidence score and a status, the AI layer adds labels and a concrete next step, the assistant reasons across threads on demand, and the whole thing is open source and local-first. This means that you can read and change exactly what it does with your data.</p>
<h3 id="heading-what-the-privacy-model-adds-up-to">What the Privacy Model Adds Up To</h3>
<p>Privacy shaped the build at every step, and it's worth collecting what that amounted to in one place. The entire core pipeline, capture through scored threads, runs locally in IndexedDB with no network calls of any kind. Your browsing history – the raw events, the sessions, the threads – never leaves your machine for the parts of the system that work without a key.</p>
<p>The two AI layers are the only paths by which any data leaves the device, and both are opt-in, gated on you providing your own API key. When they run, what they send is deliberately minimal: brand enrichment sends only bare domain names to context.dev, never URLs or page contents, and stripped of any local addresses first. Labeling and the assistant send thread titles, summaries, keywords, and sample page titles to Anthropic, the grounding context you can read directly in the code, and nothing more. Keys themselves live in <code>chrome.storage.local</code>, which never syncs.</p>
<h3 id="heading-where-to-take-it-next">Where to Take it Next</h3>
<p>The build leaves a few deliberate simplifications that make good exercises.</p>
<p>The most satisfying one builds directly on code you've already written. The domain side has <code>ambient.ts</code>, which drops domains that appear on most of your active days. But the keyword side has no equivalent, so a word that's ubiquitous <em>for you</em> (say <code>typescript</code>, if you're a TypeScript developer) survives in every session's keywords and can nudge unrelated threads together.</p>
<p>The fix is a frequency-based keyword detector that mirrors <code>detectAmbientDomains</code> almost line for line, counting days-per-keyword instead of days-per-domain:</p>
<pre><code class="language-typescript">export function detectAmbientKeywords(sessions: Session[]): Set&lt;string&gt; {
  const allEvents = sessions.flatMap((s) =&gt; s.events);
  const activeDays = new Set(allEvents.map((e) =&gt; new Date(e.visitedAt).toDateString()));
  const totalActiveDays = activeDays.size;
  if (totalActiveDays &lt; MIN_ACTIVE_DAYS) return new Set();

  const keywordDayMap = new Map&lt;string, Set&lt;string&gt;&gt;();
  for (const session of sessions) {
    const day = new Date(session.startedAt).toDateString();
    for (const kw of session.keywords) {
      if (!keywordDayMap.has(kw)) keywordDayMap.set(kw, new Set());
      keywordDayMap.get(kw)!.add(day);
    }
  }

  const ambient = new Set&lt;string&gt;();
  for (const [kw, days] of keywordDayMap) {
    if (days.size / totalActiveDays &gt;= UBIQUITY_THRESHOLD) ambient.add(kw);
  }
  return ambient;
}
</code></pre>
<p>You'd then strip these keywords inside <code>similarity</code> exactly as ambient domains are stripped today, filtering them out of both <code>sessionKeywords</code> and the thread's <code>keywordSet</code> before the Jaccard call.</p>
<p>Two smaller exercises round it out. The session gap, similarity threshold, and ambient ubiquity threshold are all hardcoded constants. Lifting them into a settings panel backed by <code>chrome.storage.local</code> (the same store the API keys already use) would let you tune clustering to your own browsing.</p>
<p>And <code>extractDomain</code> strips only a leading <code>www.</code>, so <code>news.bbc.co.uk</code> and <code>bbc.co.uk</code> are treated as different domains. Swapping its hostname logic for a library that uses the <a href="https://publicsuffix.org/">Public Suffix List</a> (the canonical list of domain suffixes like <code>.co.uk</code> that browsers use to know where a registrable domain actually ends) would collapse subdomains of the same site correctly.</p>
<p>Since the whole pipeline is local and inspectable, each of these is straightforward to try against your own real data and see the effect immediately.</p>
<h2 id="heading-wrapping-up">Wrapping up</h2>
<p>openloops turns the flat, chronological record your browser keeps into a map of what you were actually trying to do, and helps you close the loops you left open.</p>
<p>The engineering underneath&nbsp;– time-gap segmentation, weighted Jaccard clustering with ambient-domain correction, heuristic scoring, AI labeling grounded in real company data, and a conversational layer over the result – is the kind of layered system where each stage is simple on its own and the value comes from how they compose.</p>
<h2 id="heading-resources">Resources</h2>
<h3 id="heading-source-code">Source Code</h3>
<ul>
<li>The complete source is available on <a href="https://github.com/sholajegede/openloops">GitHub</a> under the MIT license, so you can run it, read it, and reshape it to fit how you browse. If it helped you, consider giving it a star.</li>
</ul>
<h3 id="heading-core-documentation">Core Documentation</h3>
<ul>
<li><p><a href="https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3">Chrome Extensions: Manifest V3</a>: the extension platform openloops is built on</p>
</li>
<li><p><a href="https://developer.chrome.com/docs/extensions/reference/api/history">chrome.history API</a>: the <code>search</code> and <code>getVisits</code> methods the backfill relies on</p>
</li>
<li><p><a href="https://developer.chrome.com/docs/extensions/reference/api/tabs">chrome.tabs API</a>: <code>onUpdated</code> for live capture and <code>create</code> for Resume</p>
</li>
<li><p><a href="http://chrome.storage">chrome.storage</a> <a href="https://developer.chrome.com/docs/extensions/reference/api/storage">API</a>: where API keys and preferences live, locally</p>
</li>
<li><p><a href="https://docs.claude.com/en/api/messages">Anthropic API reference</a>: the Messages endpoint used for labeling and the assistant</p>
</li>
</ul>
<h3 id="heading-services-used">Services used</h3>
<ul>
<li><p><a href="https://console.anthropic.com/settings/keys">Anthropic Console</a>: create the API key for AI labeling and the assistant</p>
</li>
<li><p><a href="http://context.dev">context.dev</a> <a href="https://docs.context.dev">documentation</a>: the brand-intelligence API used for grounding</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">IndexedDB (MDN)</a>: the local database every pipeline stage reads and writes</p>
</li>
</ul>
<h3 id="heading-build-tooling">Build tooling</h3>
<ul>
<li><p><a href="https://vitejs.dev/">Vite</a>: the build tool and dev server</p>
</li>
<li><p><a href="https://crxjs.dev/vite-plugin">CRXJS Vite plugin</a>: compiles a Manifest V3 extension with hot reloading</p>
</li>
<li><p><a href="https://github.com/jakearchibald/idb">idb</a>: the typed, promise-based IndexedDB wrapper</p>
</li>
<li><p><a href="https://github.com/remarkjs/react-markdown">react-markdown</a>: renders the assistant's Markdown replies</p>
</li>
</ul>
<h3 id="heading-debugging-tools">Debugging tools</h3>
<ul>
<li><p><a href="https://developer.chrome.com/docs/extensions/get-started/tutorial/debug">Chrome extension service worker DevTools</a>: inspect live-capture logs and the pipeline <code>console.table</code> output</p>
</li>
<li><p>The <strong>Application → IndexedDB</strong> panel in Chrome DevTools: browse <code>raw_events</code>, <code>sessions</code>, <code>intent_threads</code>, and <code>domain_brands</code> directly to verify each stage</p>
</li>
</ul>
<h3 id="heading-further-reading">Further reading</h3>
<ul>
<li><p><a href="https://en.wikipedia.org/wiki/Jaccard_index">Jaccard index</a>: the set-similarity measure behind thread clustering</p>
</li>
<li><p><a href="https://publicsuffix.org/">Public Suffix List</a>: the proper way to extract registrable domains, referenced as a future improvement</p>
</li>
</ul>
<p>If this tutorial was useful, feel free to share it with others who might benefit. I'd really appreciate your thoughts, you can mention me on X at <a href="https://x.com/wani_shola">@wani_shola</a> or <a href="https://linkedin.com/in/sholajegede">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
