<?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[ Shola Jegede - 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[ Shola Jegede - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 15 Jul 2026 22:32:58 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/sholajegede/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>
        
            <item>
                <title>
                    <![CDATA[ Mastra vs LangChain: Building an AI Agent Pipeline and Analyzing the Data ]]>
                </title>
                <description>
                    <![CDATA[ A week ago, I saw this tweet: I had just shipped SupportMesh, a multi-tenant AI support platform built on Mastra, so I had opinions from production. I liked the .dowhile() loop, the typed step schem ]]>
                </description>
                <link>https://www.freecodecamp.org/news/mastra-vs-langchain-building-an-ai-agent-pipeline-and-analyzing-the-data/</link>
                <guid isPermaLink="false">6a2d04106a8db5c6ef6facf4</guid>
                
                    <category>
                        <![CDATA[ langchain ]]>
                    </category>
                
                    <category>
                        <![CDATA[ MastraAI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mastra ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Convex ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #anthropic ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm evaluation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tavily ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agent-benchmarking ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Sat, 13 Jun 2026 07:17:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0e1e81b3-6e39-4532-a12b-e99f600e372f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A week ago, I saw this tweet:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/fae48919-95f1-4089-969a-98da75006424.png" alt="tweet image: @omaroubari_ asking &quot;has anyone tried mastra and langchain for agent orchestration? which is better?&quot;" style="display:block;margin:0 auto" width="1190" height="536" loading="lazy">

<p>I had just shipped SupportMesh, a multi-tenant AI support platform built on Mastra, so I had opinions from production.</p>
<p>I liked the <code>.dowhile()</code> loop, the typed step schemas, and the way <code>createWorkflow</code> kept orchestration logic in one place. What I didn't like was the token overhead: every agent step initialises Mastra's tool loop manager regardless of whether tools are needed, and across a four-step pipeline that adds up to seconds of extra latency and thousands of extra tokens per run.</p>
<p>At the same time, I was looking at LangChain for a separate project I was starting. The approach is completely different from Mastra. Instead of a workflow with typed step contracts, you build a directed graph where nodes are plain async functions and state is a single shared object.</p>
<p>The promise is leaner execution and more explicit control over exactly what goes into each model call, which, given the token overhead I had been seeing with Mastra, was exactly the kind of thing I wanted to understand properly.</p>
<p>So rather than picking one based on documentation and vibes, I built the same pipeline in both and measured everything. The same five-step research and synthesis pipeline, twice, with every piece instrumented: tokens per step, latency per step, the exact prompt sent to Claude at each stage, the raw Tavily search results, and a production-grade evaluation system that actually produces varied scores rather than giving everything a 7.</p>
<p>Then I built a real-time web dashboard on Convex and Next.js so you can run any topic yourself and see every decision both frameworks make to get there.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/950d7575-7048-42d9-9e00-9a59446c36dd.png" alt="Mastra vs LangChain dashboard showing both pipelines complete side by side, with Mastra scoring 9/10 in 25.2s using 9,846 tokens and LangChain scoring 8/10 in 19.8s using 7,875 tokens on the topic &quot;What is the real cost of running AI agents in production?&quot;" style="display:block;margin:0 auto" width="3456" height="2166" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-tools-were-using">The Tools We're Using</a></p>
</li>
<li><p><a href="#heading-why-this-pipeline">Why This Pipeline</a></p>
</li>
<li><p><a href="#heading-the-project-structure">The Project Structure</a></p>
</li>
<li><p><a href="#heading-building-the-mastra-pipeline">Building the Mastra Pipeline</a></p>
<ul>
<li><p><a href="#heading-the-search-tool">The Search Tool</a></p>
</li>
<li><p><a href="#heading-the-agents">The Agents</a></p>
</li>
<li><p><a href="#heading-the-writecriticstep-why-write-and-critic-live-in-the-same-step">The writeCriticStep: Why Write and Critic Live in the Same Step</a></p>
</li>
<li><p><a href="#heading-token-capture">Token Capture</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-building-the-langchain-pipeline">Building the LangChain Pipeline</a></p>
<ul>
<li><p><a href="#heading-the-state-annotation">The State Annotation</a></p>
</li>
<li><p><a href="#heading-the-factory-pattern">The Factory Pattern</a></p>
</li>
<li><p><a href="#heading-the-graph-and-the-node-naming-collision">The Graph and the Node Naming Collision</a></p>
</li>
<li><p><a href="#heading-the-retry-wrapper">The Retry Wrapper</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-critic-that-gave-everything-a-7-out-of-10">The Critic That Gave Everything a 7 out of 10</a></p>
<ul>
<li><p><a href="#heading-what-production-grade-evaluation-actually-looks-like">What Production-Grade Evaluation Actually Looks Like</a></p>
</li>
<li><p><a href="#heading-extracting-json-from-chain-of-thought-output">Extracting JSON from Chain-of-Thought Output</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-evaluation-bias-i-almost-shipped">The Evaluation Bias I Almost Shipped</a></p>
</li>
<li><p><a href="#heading-the-real-time-dashboard">The Real-Time Dashboard</a></p>
<ul>
<li><p><a href="#heading-the-convex-schema">The Convex Schema</a></p>
</li>
<li><p><a href="#heading-the-fire-and-forget-pattern">The Fire-and-Forget Pattern</a></p>
</li>
<li><p><a href="#heading-subscribing-to-live-updates">Subscribing to Live Updates</a></p>
</li>
<li><p><a href="#heading-deduplicating-steps-after-retries">Deduplicating Steps After Retries</a></p>
</li>
<li><p><a href="#heading-the-live-log-auto-scroll">The Live Log Auto-Scroll</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-the-data-actually-shows">What the Data Actually Shows</a></p>
</li>
<li><p><a href="#heading-try-it-yourself">Try it Yourself</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along and run this yourself, you'll need four things:</p>
<ul>
<li><p><strong>Node.js 22 or later</strong>: the pipeline packages use modern TypeScript features that require a recent Node version.</p>
</li>
<li><p><strong>An Anthropic API key</strong>: you can get one at <a href="http://console.anthropic.com">console.anthropic.com</a>. Claude Haiku 4.5 is cheap enough that running this benchmark a dozen times costs a few cents.</p>
</li>
<li><p><strong>A Tavily API key</strong>: you can get one at <a href="http://tavily.com">tavily.com</a>. The free tier gives you 1,000 searches a month, which is more than enough to run this benchmark repeatedly.</p>
</li>
<li><p><strong>A Convex account</strong>: you can sign up at <a href="http://convex.dev">convex.dev</a>. The free tier covers everything here.</p>
</li>
</ul>
<p>Once you have those, the setup section at the end of this article walks through exactly where each one goes.</p>
<h2 id="heading-the-tools-were-using">The Tools We're Using</h2>
<p>Before getting into the build, it helps to know what each tool I used is and why it's in the stack. If you're already familiar with all of these, you can skip ahead.</p>
<p><a href="https://mastra.ai">Mastra</a> is a TypeScript-first framework for building AI-powered applications and agents. The idea is that you define individual steps with typed input and output schemas, chain them into a workflow, and the framework handles the data flow between them. It has opinions about structure, which is either a feature or a constraint depending on what you're building.</p>
<p><a href="https://www.langchain.com"><strong>LangChain</strong></a> is one of the most widely used frameworks for building LLM applications. It started in Python and has a TypeScript version.</p>
<p>For agent orchestration specifically, the relevant piece is <strong>LangGraph</strong>, which is LangChain's graph-based execution layer. Instead of a workflow with typed step contracts, you build a directed graph: nodes are plain async functions, state is a single shared object that every node reads from and writes to, and the flow between nodes is controlled by edges.</p>
<p><a href="https://www.anthropic.com/claude/haiku"><strong>Claude Haiku 4.5</strong></a> is the model powering all agents. It is Anthropic's fastest and most cost-efficient model, which makes it the right choice here.</p>
<p><a href="https://www.tavily.com"><strong>Tavily</strong></a> is a web search API built specifically for AI agents. Unlike a general search API, it returns structured results with relevance scores and content snippets that are ready to pass directly into a model prompt. The free tier is generous enough to run this benchmark without paying anything.</p>
<p>I used it here because it has a clean TypeScript SDK, it works in both Mastra tools and plain LangChain nodes without any adapter layer, and the search results are consistent enough that both pipelines are working with the same quality of input.</p>
<p><a href="https://www.convex.dev"><strong>Convex</strong></a> is a real-time database with a React hook, <code>useQuery</code>, that automatically re-renders your component whenever the underlying data changes. No polling, no WebSocket setup, and no manual state syncing. When both pipelines are writing step data as they execute, the run page just updates.</p>
<p><a href="https://nextjs.org"><strong>Next.js</strong></a> is the web framework for the dashboard. App Router, API routes for the pipeline execution, and server components where they make sense.</p>
<h2 id="heading-why-this-pipeline">Why This Pipeline</h2>
<p>A simple comparison wouldn't tell me anything useful, because the difference between frameworks only shows up when you actually push them.</p>
<p>The pipeline I landed on has five steps:</p>
<pre><code class="language-plaintext">Topic
  ↓
1. RESEARCH   (Tavily web search, 5 results with relevance scores)
2. ANALYSIS   (Extract 5 key findings, 3 themes, 1 central argument)
3. WRITE      (Draft a structured ~400-word report)
4. CRITIC     (Score the draft, provide dimension-level feedback)
5. LOOP       (Revise if score below 7, output if passes or 3 iterations used)
</code></pre>
<p>I chose each step because it stresses the frameworks differently.</p>
<p>The research step requires a real tool call, which is where Mastra's Agent abstraction does its heaviest work. The analysis step needs structured JSON output, which tests how each framework enforces output shape. The write step has strict content requirements enforced purely through prompt engineering. The critic needs to do chain-of-thought reasoning and produce structured JSON at the same time, which turns out to be harder than it sounds. And the revision loop tests perhaps the most fundamental difference between the two frameworks: how each one expresses conditional iteration.</p>
<p>Taken together, this covers most of what you would actually build with an agent framework in production: tool calls, structured output, multi-step orchestration, quality evaluation, and feedback loops.</p>
<h2 id="heading-the-project-structure">The Project Structure</h2>
<p>Everything lives in a single monorepo using npm workspaces, which means all packages share a single <code>node_modules</code> at the root and can import each other directly:</p>
<pre><code class="language-plaintext">mastra-vs-langchain/
├── packages/
│   ├── mastra-pipeline/          # Mastra implementation
│   ├── langchain-pipeline/       # LangChain/LangGraph implementation
│   ├── web/                      # Next.js 16 App Router dashboard
│   └── shared/                   # Shared TypeScript types
├── convex/                       # Real-time backend
└── package.json                  # Workspace root
</code></pre>
<p>The most important piece of the shared package is the <code>PipelineCallbacks</code> interface, which both pipeline implementations must satisfy. This is the contract that lets the dashboard receive live events from either framework: step starts, step completions, token counts, and Tavily results, all without knowing anything about Mastra or LangChain specifically:</p>
<pre><code class="language-typescript">// packages/shared/src/types.ts
export interface PipelineCallbacks {
  onPipelineStart: () =&gt; Promise&lt;string&gt;;
  onPipelineComplete: (id: string, data: PipelineCompleteData) =&gt; Promise&lt;void&gt;;
  onPipelineError: (id: string, error: string) =&gt; Promise&lt;void&gt;;
  step: {
    onStepStart: (stepName: string, iteration: number, input: string) =&gt; Promise&lt;string&gt;;
    onStepComplete: (stepId: string, data: StepCompleteData) =&gt; Promise&lt;void&gt;;
    onStepError: (stepId: string, error: string) =&gt; Promise&lt;void&gt;;
  };
}
</code></pre>
<p>Every Convex write, live log entry, and token count flows through this interface. Adding a new framework to the benchmark in the future means implementing this interface and plugging it into the API route, and nothing else needs to change.</p>
<h2 id="heading-building-the-mastra-pipeline">Building the Mastra Pipeline</h2>
<p>If you haven't used Mastra before, the core mental model is this: you define individual steps with typed input and output schemas, chain them together into a workflow, and Mastra manages the data flow between them.</p>
<p>The framework is opinionated about structure but that structure gives you type safety across the entire pipeline and makes the orchestration logic easy to read.</p>
<h3 id="heading-the-search-tool">The Search Tool</h3>
<p>Mastra tools are created with <code>createTool</code>, which takes a Zod input schema and an <code>execute</code> function that receives the validated input directly:</p>
<pre><code class="language-typescript">// packages/mastra-pipeline/src/tools/search.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { tavily } from "@tavily/core";

const client = tavily({ apiKey: process.env.TAVILY_API_KEY! });

export let lastTavilyCapture: { query: string; results: any[] } = {
  query: "",
  results: [],
};

export function resetTavilyCapture() {
  lastTavilyCapture = { query: "", results: [] };
}

export const searchTool = createTool({
  id: "web-search",
  description: "Search the web for information on a topic",
  inputSchema: z.object({ query: z.string() }),
  execute: async ({ query }) =&gt; {
    lastTavilyCapture = { query, results: [] };
    const results = await client.search(query, {
      maxResults: 5,
      searchDepth: "basic",
    });
    lastTavilyCapture.results = results.results;
    return { results: results.results };
  },
});
</code></pre>
<p>The <code>lastTavilyCapture</code> module-level variable is a deliberate workaround for a real constraint. Mastra's tool execution happens inside the agent's internal tool loop, which sits one layer below the workflow step.</p>
<p>I needed to capture the Tavily query and results for the dashboard so users can see the actual URLs and relevance scores for each run, but threading a callback through the agent's tool execution context would have required patching Mastra internals. Capturing at module scope and calling <code>resetTavilyCapture()</code> at the start of each research step is less elegant but completely reliable, and it prevents stale data from a previous run bleeding into the current one.</p>
<h3 id="heading-the-agents">The Agents</h3>
<p>Each step in the Mastra pipeline runs as a separate <code>Agent</code> instance. One thing worth knowing if you're just getting started with Mastra is that it requires an explicit <code>id</code> field alongside <code>name</code>. If you skip it, TypeScript throws a confusing error about missing required fields that doesn't point at the actual problem:</p>
<pre><code class="language-typescript">// packages/mastra-pipeline/src/agents/researcher.ts
export const researcherAgent = new Agent({
  name: "Researcher",
  id: "researcher",           // required in v1.41 - easy to miss
  instructions: `You are a research agent. When given a topic, use the 
  web-search tool to find 5 relevant results. Return ALL the raw search 
  results including titles, URLs, and content snippets as a formatted string.`,
  model: anthropic("claude-haiku-4-5"),
  tools: { searchTool },
});
</code></pre>
<p>The writer agent carries all its content requirements directly in the instructions rather than in a separate validation layer. This keeps the constraints in one visible place, which matters when the critic is giving feedback about which specific requirements the draft violated:</p>
<pre><code class="language-typescript">// packages/mastra-pipeline/src/agents/writer.ts
export const writerAgent = new Agent({
  name: "Writer",
  id: "writer",
  instructions: `You are a research analyst writing for a technical audience.

STRICT REQUIREMENTS:
- Opening sentence must state a specific finding from the research.
  Never open with "X is increasingly important."
- Every paragraph makes exactly one argument. State it first.
  Support it with specific evidence.
- Name specific tools, frameworks, companies, numbers, and dates.
- Conclusion must make a specific recommendation or prediction.
  It must not restate the introduction.
- Target length: 350-450 words.

FORBIDDEN PHRASES:
"it is important to note", "it is worth noting",
"organizations must consider", "in conclusion", "in summary",
"as we look to the future", "rapidly evolving landscape",
any sentence equally true if you replaced the topic`,
  model: anthropic("claude-haiku-4-5"),
});
</code></pre>
<h3 id="heading-the-writecriticstep-why-write-and-critic-live-in-the-same-step">The writeCriticStep: Why Write and Critic Live in the Same Step</h3>
<p>While implementing Mastra, I made one architectural decision here that diverges from most tutorials, and it's worth understanding why.</p>
<p>Mastra's <code>.dowhile()</code> construct loops a single step until a condition is met. That's clean when you have one thing to repeat, but the revision loop needs two things: a write step followed by a critic step. You can either combine them into one step, or build a nested workflow where the inner workflow contains both steps.</p>
<p>A nested workflow adds a layer of complexity that doesn't buy you anything in this case, so the write and critic phases live together in <code>writeCriticStep</code>. The step runs the writer first, then immediately runs the critic on the draft, and returns a combined output that includes both the draft and the score:</p>
<pre><code class="language-typescript">const writeCriticStep = createStep({
  id: "write-critic",
  inputSchema: z.object({
    topic: z.string(),
    research: z.string(),
    analysis: z.string(),
    keyFindings: z.array(z.string()),
    mainThemes: z.array(z.string()),
    centralArgument: z.string(),
    draft: z.string().optional(),       // populated after first iteration
    score: z.number().optional(),       // populated after first iteration
    feedback: z.string().optional(),    // populated after first iteration
    iterations: z.number().optional(),
  }),
  outputSchema: z.object({
    // ... all input fields plus draft, score, feedback, iterations
  }),
  execute: async ({ inputData }) =&gt; {
    const iteration = (inputData.iterations ?? 0) + 1;

    // WRITE phase
    let writerPrompt = `Topic: "\({inputData.topic}"\n\nResearch:\n\){inputData.research}\n\nAnalysis:\n${inputData.analysis}`;
    if (inputData.feedback &amp;&amp; inputData.draft) {
      // On revisions, the writer sees its previous attempt and the specific feedback
      writerPrompt += `\n\nPrevious draft:\n\({inputData.draft}\n\nFeedback:\n\){inputData.feedback}`;
    }

    const writeStepId = await callbacks.step.onStepStart("write", iteration, writerPrompt.slice(0, 500));
    const writerResult = await writerAgent.generate(writerPrompt);
    const draft = writerResult.text;
    await callbacks.step.onStepComplete(writeStepId, { output: draft, /* token data */ });

    // CRITIC phase: runs immediately after write, on the same draft
    const criticPrompt = `RESEARCH:\n\({inputData.research}\n\nANALYSIS:\n\){inputData.analysis}\n\nDRAFT:\n${draft}`;
    const criticStepId = await callbacks.step.onStepStart("critic", iteration, draft.slice(0, 500));
    const criticResult = await criticAgent.generate(criticPrompt);
    const parsed = extractJson(criticResult.text);
    const score = parsed?.score ?? 4;
    const feedback = parsed?.feedback ?? "Score parsing failed";
    await callbacks.step.onStepComplete(criticStepId, { output: criticResult.text, criticScore: score });

    return { ...inputData, draft, score, feedback, iterations: iteration };
  },
});
</code></pre>
<p>The <code>.dowhile()</code> condition then checks whether to loop again. It receives the output of the previous <code>writeCriticStep</code> as <code>inputData</code>, so it can read the score directly:</p>
<pre><code class="language-typescript">const workflow = createWorkflow({
  id: `research-pipeline-${Date.now()}`,  // timestamp prevents conflicts on concurrent runs
  inputSchema: z.object({ topic: z.string() }),
})
  .then(researchStep)
  .then(analysisStep)
  .dowhile(
    writeCriticStep,
    async ({ inputData }) =&gt; inputData.score &lt; 7 &amp;&amp; inputData.iterations &lt; 3
  )
  .commit();
</code></pre>
<p>The <code>Date.now()</code> in the workflow ID is there because Mastra workflows with a static ID conflict when two runs start concurrently. Adding the timestamp gives each run a unique workflow instance.</p>
<h3 id="heading-token-capture">Token Capture</h3>
<p>After any <code>agent.generate()</code> call, usage data lives on the result object. The shape changes between Mastra versions, so checking both possible field names is the safe approach:</p>
<pre><code class="language-typescript">const inputTokens =
  (result as any).usage?.promptTokens ??
  (result as any).usage?.inputTokens ??
  0;
const outputTokens =
  (result as any).usage?.completionTokens ??
  (result as any).usage?.outputTokens ??
  0;
</code></pre>
<h2 id="heading-building-the-langchain-pipeline">Building the LangChain Pipeline</h2>
<p>LangChain/LangGraph solves the same problem with a fundamentally different mental model.</p>
<p>Where Mastra gives you a workflow with explicitly typed step contracts, LangGraph gives you a directed graph. Nodes are plain async functions, state is a single shared mutable object that flows through the graph, and the execution order is determined by edges rather than a chain of <code>.then()</code> calls.</p>
<h3 id="heading-the-state-annotation">The State Annotation</h3>
<p>Before writing any nodes, you define the shape of the shared state using <code>Annotation.Root</code>. Every node in the graph reads from and writes to this object:</p>
<pre><code class="language-typescript">// packages/langchain-pipeline/src/graph/state.ts
export const PipelineState = Annotation.Root({
  topic: Annotation&lt;string&gt;(),
  research: Annotation&lt;string&gt;(),
  analysis: Annotation&lt;string&gt;(),
  draft: Annotation&lt;string&gt;(),
  score: Annotation&lt;number&gt;(),
  feedback: Annotation&lt;string&gt;(),
  iterations: Annotation&lt;number&gt;(),
  finalReport: Annotation&lt;string&gt;(),
  criticDimensions: Annotation&lt;object&gt;(),
});
</code></pre>
<p>Coming from Mastra, the difference in how data flows is significant. In Mastra, each step declares what it receives and returns, and the framework enforces that contract at the TypeScript level.</p>
<p>In LangGraph, any node can read or write any field in the shared state. The structure comes from the graph topology rather than the type system, which means Mastra catches data flow bugs at compile time while LangGraph makes it easier to add new fields to the pipeline without touching every step's schema.</p>
<h3 id="heading-the-factory-pattern">The Factory Pattern</h3>
<p>LangGraph nodes are plain async functions, which is exactly what makes them lean: no framework overhead, no initialization, just your code calling the model.</p>
<p>The challenge is that I needed to thread callbacks and a shared token accumulator through all four nodes, and plain functions have no built-in mechanism for that.</p>
<p>The solution is a factory function that creates all four nodes as closures over the shared state:</p>
<pre><code class="language-typescript">// packages/langchain-pipeline/src/graph/nodes.ts
export function createNodes(
  callbacks: PipelineCallbacks,
  acc: { inputTokens: number; outputTokens: number }
) {
  const tavilyClient = tavily({ apiKey: process.env.TAVILY_API_KEY! });

  async function researchNode(state: PipelineStateType): Promise&lt;Partial&lt;PipelineStateType&gt;&gt; {
    const stepId = await callbacks.step.onStepStart("research", 1, state.topic);
    const results = await tavilyClient.search(state.topic, { maxResults: 5, searchDepth: "basic" });
    const research = results.results
      .map((r, i) =&gt; `[\({i + 1}] \){r.title}\nURL: \({r.url}\nContent: \){r.content}`)
      .join("\n\n");
    await callbacks.step.onStepComplete(stepId, {
      output: research,
      promptSent: state.topic,
      timeMs: elapsed,
      inputTokens: 0,      // research step uses Tavily, not an LLM
      outputTokens: 0,
      model: "tavily-search",
      tavilyQuery: state.topic,
      tavilyResults: JSON.stringify(results.results),
    });
    return { research };
  }

  // analysisNode, writeNode, criticNode follow the same pattern

  return { researchNode, analysisNode, writeNode, criticNode };
}
</code></pre>
<p>Notice the research node returns 0 tokens because it calls Tavily directly without any LLM involvement, which is one of the key differences that shows up in the benchmark data. Each subsequent node accumulates tokens directly into the shared <code>acc</code> object:</p>
<pre><code class="language-typescript">const inputTokens = response.usage_metadata?.input_tokens ?? 0;
const outputTokens = response.usage_metadata?.output_tokens ?? 0;
acc.inputTokens += inputTokens;
acc.outputTokens += outputTokens;
</code></pre>
<p>LangChain's <code>ChatAnthropic</code> puts usage on <code>response.usage_metadata</code>, which is cleanly typed and requires no casting.</p>
<h3 id="heading-the-graph-and-the-node-naming-collision">The Graph and the Node Naming Collision</h3>
<p>One thing LangGraph enforces that's easy to miss: node names can't conflict with state annotation keys. Naming a node <code>"research"</code> throws a runtime error because <code>state.research</code> already exists as a state channel, and the error message doesn't explain why. Renaming to <code>"researcher"</code> and <code>"analyzer"</code> fixes it:</p>
<pre><code class="language-typescript">export const pipeline = new StateGraph(PipelineState)
  .addNode("researcher", researchNode)   // NOT "research": conflicts with state.research
  .addNode("analyzer", analysisNode)     // NOT "analysis": conflicts with state.analysis
  .addNode("write", writeNode)
  .addNode("critic", criticNode)
  .addEdge(START, "researcher")
  .addEdge("researcher", "analyzer")
  .addEdge("analyzer", "write")
  .addEdge("write", "critic")
  .addConditionalEdges("critic", shouldRevise, {
    revise: "write",
    end: END,
  })
  .compile();
</code></pre>
<p>The revision loop in LangGraph is expressed as a conditional edge with a routing function:</p>
<pre><code class="language-typescript">function shouldRevise(state: PipelineStateType): string {
  if (state.score &gt;= 7 || state.iterations &gt;= 3) return "end";
  return "revise";
}
</code></pre>
<p>After every critic execution, <code>shouldRevise</code> runs and returns either <code>"revise"</code> to loop back to the write node or <code>"end"</code> to exit the graph. That's the state machine equivalent of Mastra's <code>.dowhile()</code>: the same conditional logic expressed as graph routing rather than as a named loop construct.</p>
<h3 id="heading-the-retry-wrapper">The Retry Wrapper</h3>
<p>Both frameworks hit intermittent TLS session reuse errors when making concurrent HTTPS requests. The error look like this: <code>SSL routines:tls_get_more_records:decryption failed or bad record mac</code>. A retry wrapper with linear backoff handles it:</p>
<pre><code class="language-typescript">async function retryOnFetch&lt;T&gt;(fn: () =&gt; Promise&lt;T&gt;, retries = 3): Promise&lt;T&gt; {
  for (let i = 0; i &lt;= retries; i++) {
    try {
      return await fn();
    } catch (e: any) {
      const shouldRetry =
        e?.message?.includes("fetch") ||
        e?.message === "fetch failed" ||
        e?.message?.includes("SSL") ||
        e?.message?.includes("ECONNRESET") ||
        e?.message?.includes("other side closed") ||
        e?.cause?.code === "ECONNRESET";
      if (i &lt; retries &amp;&amp; shouldRetry) {
        await new Promise((r) =&gt; setTimeout(r, 1000 * (i + 1)));
        continue;
      }
      throw e;
    }
  }
  throw new Error("unreachable");
}
</code></pre>
<p>Every <code>llm.invoke()</code> call in the LangChain nodes is wrapped in this. In the web app's API route, there's an equivalent <code>retryMutation</code> wrapper around every Convex call for the same reason.</p>
<h2 id="heading-the-critic-that-gave-everything-a-7-out-of-10">The Critic That Gave Everything a 7 out of 10</h2>
<p>With both pipelines running, I tested a few topics. Every score came back 7 out of 10, regardless of topic, framework, or iteration.</p>
<p>This is actually a well-documented failure mode called LLM-as-judge bias. When you ask a language model to assign a score from 1 to 10 without giving it structured criteria and explicit anchors for each score level, it gravitates toward 7. It's the socially safe answer: high enough to signal quality, low enough to seem fair, and it requires no real justification. The model has no incentive to discriminate because nothing in the prompt forces it to.</p>
<p>My original critic was this:</p>
<pre><code class="language-plaintext">You are a critical editor. Score the draft 1-10 on accuracy,
clarity, and depth. Return { score, feedback }.
</code></pre>
<p>That single sentence was the entire prompt, so obviously it gave everything a 7.</p>
<h3 id="heading-what-production-grade-evaluation-actually-looks-like">What Production-Grade Evaluation Actually Looks Like</h3>
<p>The solution I used comes from the <a href="https://arxiv.org/abs/2303.16634">G-Eval paper</a>, which is also the approach behind tools like DeepEval and RAGAS. The key insight is that you need three things working together: the judge must reason step-by-step before assigning any score, the dimensions being scored must be independent of each other, and each score level must have an explicit description of what it means, not just "1 is bad, 10 is perfect."</p>
<p>So, I rebuilt the critic around six mandatory steps that must all complete before a number is produced:</p>
<ol>
<li><p><strong>Claim audit</strong>: every factual claim in the report gets classified as GROUNDED (supported by a specific search result), INFERRED (reasonable extension of the research), UNSUPPORTED (no basis in the results), or HALLUCINATED (contradicts the results).</p>
</li>
<li><p><strong>Specificity audit</strong>: every generic sentence and every forbidden phrase gets flagged explicitly.</p>
</li>
<li><p><strong>Insight audit</strong>: checks whether the conclusion actually adds something beyond restating the introduction.</p>
</li>
<li><p><strong>Counterfactual check</strong>: the judge must name at least one specific belief a reader would hold after reading this that they wouldn't hold from just the topic title alone. If it can't identify one, the insight score can't exceed 6.</p>
</li>
<li><p><strong>Dimension scoring</strong>: three independent scores with explicit anchors for each level.</p>
</li>
<li><p><strong>Floor rule</strong>: if any single dimension scores 4 or below, the final score can't exceed 6 regardless of the other dimensions.</p>
</li>
</ol>
<p>The floor rule deserves a specific explanation because it addresses a real failure mode: without it, a report that hallucinates facts could score 2 on source fidelity but still end up with a passing score on the weighted average if specificity and insight are high enough. A critical failure in one dimension should disqualify the report, not get diluted.</p>
<p>This is the full critic prompt, which is shared between Mastra and LangChain via a constant in <code>nodes.ts</code>:</p>
<pre><code class="language-typescript">const CRITIC_INSTRUCTIONS = `You are a senior research editor.
Catch the specific ways AI-generated reports fail.

STEP 1: CLAIM AUDIT
Classify every claim: [GROUNDED] [INFERRED] [UNSUPPORTED] [HALLUCINATED]

STEP 2: SPECIFICITY AUDIT
List sentences that are generic, use forbidden phrases, or make no
falsifiable claims. Forbidden phrases: "it is important to note",
"organizations must consider", "rapidly evolving", "as we look to the future"

STEP 3: INSIGHT AUDIT
Does the conclusion add anything not already in the introduction?

STEP 3.5: COUNTERFACTUAL CHECK
Name one specific belief a reader holds after reading this that they
would not hold from just the topic title. If you cannot identify one,
insight cannot exceed 6.

STEP 4: SCORE EACH DIMENSION

SOURCE FIDELITY (40% weight):
5-6: Claims accurate but traced to general topic knowledge, not these specific results
7:   Most claims traceable, at least one source cited by name
8:   All major claims grounded, two or more named sources with specific details
9-10: Every claim traces to a named source, at least one statistic used

SPECIFICITY (30% weight):
5-6: Some specific claims but generic analysis between paragraphs
7:   Mostly specific, minor filler remains
8:   Every paragraph falsifiable, named entities throughout
9-10: Zero sentences survive if you swap the topic

INSIGHT (30% weight):
5-6: Some synthesis but conclusion could have been written before reading
7:   Conclusion makes a recommendation that follows from the evidence
8:   Identifies a tradeoff the reader has not considered
9-10: A senior engineer would reconsider an architectural decision after reading this

STEP 5: FLOOR RULE
If any dimension scores 4 or below, the final score cannot exceed 6.

STEP 6: CALCULATE
finalScore = round((fidelity * 0.40) + (specificity * 0.30) + (insight * 0.30))

Respond ONLY with this JSON:
{
  "fidelity": &lt;1-10&gt;,
  "fidelityReasoning": "&lt;one sentence&gt;",
  "specificity": &lt;1-10&gt;,
  "specificityReasoning": "&lt;one sentence&gt;",
  "insight": &lt;1-10&gt;,
  "insightReasoning": "&lt;one sentence&gt;",
  "score": &lt;weighted final&gt;,
  "feedback": "&lt;surgical: quote the specific sentence that caused the
  lowest-scoring dimension to fail, then state exactly what needs to change&gt;"
}`;
</code></pre>
<h3 id="heading-extracting-json-from-chain-of-thought-output">Extracting JSON from Chain-of-Thought Output</h3>
<p>Because the critic now writes several paragraphs of reasoning before producing the JSON, <code>JSON.parse(result.text)</code> throws because the response isn't pure JSON anymore. Before I caught this and fixed it, the fallback value of <code>4</code> was returned silently on every parse failure, which meant every loop ran the full three iterations on every topic.</p>
<p>The fix scans the text for the last valid JSON object, working backwards through any matches because the JSON block always appears at the end after the reasoning:</p>
<pre><code class="language-typescript">function extractJson(text: string): any {
  try { return JSON.parse(text.trim()); } catch {}

  const matches = text.match(/\{[\s\S]*\}/g);
  if (matches) {
    for (let i = matches.length - 1; i &gt;= 0; i--) {
      try { return JSON.parse(matches[i]); } catch {}
    }
  }

  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (fenced) {
    try { return JSON.parse(fenced[1].trim()); } catch {}
  }

  return null;
}
</code></pre>
<h2 id="heading-the-evaluation-bias-i-almost-shipped">The Evaluation Bias I Almost Shipped</h2>
<p>After the critic rebuild, things were working properly: first drafts scoring 4-6, the revision loop triggering, revisions actually improving on the previous attempt.</p>
<p>But a clear pattern emerged across technology topics: Mastra consistently scoring 8-9, and LangChain consistently scoring 6-7, on every single topic.</p>
<p>Looking at what the critic was actually rewarding revealed the problem. Source Fidelity carries 40% of the final score, and it rewards reports that cite specific named sources from the Tavily results. Mastra's reports were full of phrases like "according to Kore.ai's analysis" and "the ArXiv paper on orchestrated multi-agent systems identifies." LangChain's reports made the same points but without attributing them to specific sources.</p>
<p>The cause was how context flowed through each pipeline. Mastra's Agent class carries the full Tavily content (titles, URLs, content snippets) in its conversation history through the tool loop. By the time the writer agent runs, all of that source material is available in context.</p>
<p>The LangChain write node, on the other hand, only received <code>state.analysis</code>, which is the structured JSON extracted from the research: key findings, themes, and a central argument. By the time that JSON was produced, the specific source details had already been abstracted away. The writer had the conclusions but not the citations.</p>
<p>Both pipelines were correctly implemented according to each framework's idioms, but I had given them unequal inputs without realising it. The evaluation system was rewarding one framework for having more context rather than for producing a better report, and the consistent score gap across every technology topic was the signal: a genuine quality difference would vary by topic and draft, but a structural gap shows up the same way every time.</p>
<p>The fix was one change in the LangChain write node: pass <code>state.research</code> (the raw Tavily results) alongside <code>state.analysis</code>:</p>
<pre><code class="language-typescript">async function writeNode(state: PipelineStateType): Promise&lt;Partial&lt;PipelineStateType&gt;&gt; {
  const prompt = `You are a research analyst writing for a technical audience.

RESEARCH (raw search results -- cite specific sources by name):
${state.research}

ANALYSIS:
${state.analysis}
\({state.feedback ? `\nCRITIC FEEDBACK FROM PREVIOUS DRAFT:\n\){state.feedback}` : ""}

${WRITER_INSTRUCTIONS}

Return ONLY the report text.`;

  const response = await retryOnFetch(() =&gt; llm.invoke(prompt));
  return { draft: response.content as string, iterations: (state.iterations ?? 0) + 1 };
}
</code></pre>
<p>With both writers receiving identical source material, quality scores now reflect actual writing quality. If your evaluation system consistently favours one option across many runs, the first thing to check is whether both options have equal inputs. A structural gap produces consistent results, while a genuine quality difference varies by topic and draft quality.</p>
<h2 id="heading-the-real-time-dashboard">The Real-Time Dashboard</h2>
<p>Running pipelines in the terminal works for your own comparisons, but it doesn't scale to a benchmark that other people can use. The dashboard needed both pipelines running in parallel, every step visible as it executes, the full prompt and response expandable per step, Tavily results with relevance score bars, token counts, a live scrolling log, and everything saved and browsable by category.</p>
<h3 id="heading-the-convex-schema">The Convex Schema</h3>
<p>Convex was chosen specifically for real-time capabilities: its <code>useQuery</code> hook in React subscribes to database queries and automatically re-renders when the underlying data changes, without any polling or websocket management on your end.</p>
<p>The schema stores every run at three levels of granularity:</p>
<pre><code class="language-typescript">steps: defineTable({
  runId: v.id("runs"),
  pipelineResultId: v.id("pipelineResults"),
  framework: v.union(v.literal("mastra"), v.literal("langchain")),
  stepName: v.union(
    v.literal("research"), v.literal("analysis"),
    v.literal("write"), v.literal("critic")
  ),
  iterationNumber: v.number(),
  status: v.union(v.literal("running"), v.literal("complete"), v.literal("error")),
  promptSent: v.optional(v.string()),
  output: v.optional(v.string()),
  timeMs: v.optional(v.number()),
  inputTokens: v.optional(v.number()),
  outputTokens: v.optional(v.number()),
  model: v.optional(v.string()),
  tavilyQuery: v.optional(v.string()),
  tavilyResults: v.optional(v.string()),
  criticScore: v.optional(v.number()),
  criticFeedback: v.optional(v.string()),
  criticDimensions: v.optional(v.object({
    fidelity: v.number(),
    specificity: v.number(),
    insight: v.number(),
    fidelityReasoning: v.string(),
    specificityReasoning: v.string(),
    insightReasoning: v.string(),
  })),
}).index("by_pipeline_result", ["pipelineResultId"]),
</code></pre>
<p>The <code>criticDimensions</code> field stores the full G-Eval breakdown so the dashboard can render individual dimension scores with colored bars and the per-dimension reasoning text.</p>
<h3 id="heading-the-fire-and-forget-pattern">The Fire-and-Forget Pattern</h3>
<p>The most important decision in the Next.js API route is returning the <code>runId</code> before either pipeline finishes. If you await both pipelines first, the browser sits waiting 30-60 seconds before it can even navigate to the run page, and the whole point of real-time updates is gone.</p>
<pre><code class="language-typescript">const activeTasks = new Map&lt;string, Promise&lt;void&gt;&gt;();

export async function POST(req: NextRequest) {
  const { topic, category } = await req.json();

  // Create the Convex records synchronously (these are fast)
  const runId = await retryMutation(() =&gt;
    fetchMutation(api.runs.createRun, { topic, category, status: "running" })
  );
  const mastraResultId = await retryMutation(() =&gt;
    fetchMutation(api.pipelineResults.createPipelineResult, {
      runId, framework: "mastra", status: "running", iterations: 0,
    })
  );
  const langchainResultId = await retryMutation(() =&gt;
    fetchMutation(api.pipelineResults.createPipelineResult, {
      runId, framework: "langchain", status: "running", iterations: 0,
    })
  );

  // Start both pipelines without awaiting them
  const task = Promise.allSettled([
    withRetry(() =&gt; runMastraPipeline(topic, buildCallbacks(runId, mastraResultId, "mastra"))),
    withRetry(() =&gt; runLangChainPipeline(topic, buildCallbacks(runId, langchainResultId, "langchain"))),
  ]).then(async () =&gt; {
    await retryMutation(() =&gt;
      fetchMutation(api.runs.updateRunStatus, { runId, status: "complete" })
    );
    activeTasks.delete(runId as string);
  });

  // Hold a reference in the Map so Node.js doesn't garbage-collect the promise
  activeTasks.set(runId as string, task);
  return NextResponse.json({ runId });   // returns immediately
}
</code></pre>
<p>On Vercel, this pattern still fails because serverless functions terminate when the route handler returns, killing any background promises. The fix is using <code>waitUntil</code> from <code>@vercel/functions</code>, which tells Vercel to keep the execution context alive until the promise resolves:</p>
<pre><code class="language-typescript">import { waitUntil } from "@vercel/functions";

waitUntil(task);
return NextResponse.json({ runId });
</code></pre>
<h3 id="heading-subscribing-to-live-updates">Subscribing to Live Updates</h3>
<p>On the run page, three Convex queries run simultaneously: the run itself, the pipeline results, and the steps for each pipeline result.</p>
<p>The <code>"skip"</code> sentinel is important here: it tells Convex to hold the subscription open without executing the query until a real argument is available. This prevents a race condition where the steps query fires before the pipeline result records have been created:</p>
<pre><code class="language-typescript">const mastraSteps = useQuery(
  api.steps.getStepsForPipelineResult,
  mastraResult ? { pipelineResultId: mastraResult._id } : "skip"
);
</code></pre>
<h3 id="heading-deduplicating-steps-after-retries">Deduplicating Steps After Retries</h3>
<p>When a pipeline fails due to a TLS error and retries from the beginning, the failed attempt's step records stay in Convex alongside the successful attempt's records. The UI would render both, creating a visible gap between the research card and the rest of the steps.</p>
<p>The fix groups steps by <code>stepName + iterationNumber</code> and keeps the best version of each:</p>
<pre><code class="language-typescript">const stepMap = new Map&lt;string, Step&gt;();
[...steps]
  .sort((a, b) =&gt; (a._creationTime ?? 0) - (b._creationTime ?? 0))
  .forEach((s) =&gt; {
    const key = `\({s.stepName}-\){s.iterationNumber}`;
    const existing = stepMap.get(key);
    if (!existing) { stepMap.set(key, s); return; }
    if (s.status === "complete") { stepMap.set(key, s); return; }
    if (existing.status !== "complete") { stepMap.set(key, s); }
  });
</code></pre>
<h3 id="heading-the-live-log-auto-scroll">The Live Log Auto-Scroll</h3>
<p>Log entries are appended to the pipeline result document in Convex as an array, and the panel auto-scrolls as new entries arrive using a ref attached to an empty div at the bottom:</p>
<pre><code class="language-typescript">function LiveLogPanel({ logs }: { logs?: LogEntry[] }) {
  const endRef = useRef&lt;HTMLDivElement&gt;(null);

  useEffect(() =&gt; {
    endRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [logs?.length]);

  return (
    &lt;div className="max-h-52 overflow-y-auto font-mono text-xs"&gt;
      {logs?.map((entry, i) =&gt; (
        &lt;div key={i} className="flex gap-2"&gt;
          &lt;span className="text-[#484f58]"&gt;[{fmtTs(entry.timestamp)}]&lt;/span&gt;
          &lt;span className={`font-bold w-14 ${tagColor(entry.tag)}`}&gt;{entry.tag}&lt;/span&gt;
          &lt;span className="text-[#c9d1d9]"&gt;{entry.message}&lt;/span&gt;
        &lt;/div&gt;
      ))}
      &lt;div ref={endRef} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The effect dependency is <code>logs?.length</code>, so the scroll triggers every time a new log entry arrives from Convex.</p>
<h2 id="heading-what-the-data-actually-shows">What the Data Actually Shows</h2>
<p><strong>Speed:</strong> LangChain is 25-45% faster in every run. On shorter topics the gap narrows to 7-8 seconds, but it never reverses.</p>
<p>I think the reason for this is structural. Mastra's Agent class initialises its tool loop manager on every step, even when no tools are called. That means internal conversation history, tool schemas, and retry infrastructure are all set up as overhead before the actual model call happens.</p>
<p>Across a four-step pipeline, those 2-5 seconds per step accumulate. LangGraph nodes are plain async functions, so your code runs directly, with no framework initialisation between you and the model.</p>
<p><strong>Tokens:</strong> Mastra uses 1.5-2.5x more tokens. The research step alone accounted for most of that gap because LangChain's research node calls Tavily directly without invoking an LLM at all.</p>
<p>On more typical topics, Mastra runs around 6,200 tokens and LangChain around 3,900. The gap scales with how much content Tavily returns, because that content flows into Mastra's agent conversation history on every subsequent step.</p>
<p><strong>Quality:</strong> After fixing the evaluation bias, scores vary meaningfully by topic rather than by framework. Both produce high-scoring reports when the Tavily results are specific and rich. Both struggle on vague or biographical topics where the search results are generic.</p>
<p>A first draft scoring 7 or 8 means the research was strong and the writer made specific grounded claims. A 4 or 5 means the research returned thin results and the writer defaulted to generic observations, and the revision loop runs until either the draft improves or the iteration limit is hit.</p>
<p><strong>The tradeoff:</strong> Mastra handles orchestration complexity in the framework so you don't have to. You write <code>.dowhile()</code> instead of a conditional edge, typed step schemas instead of a shared mutable state object, and the framework manages conversation history and tool execution. The cost is a consistent token and latency overhead on every step.</p>
<p>LangChain gives you the graph execution engine and leaves everything else to you: more explicit wiring to write, but leaner execution and precise control over every token that enters each model call.</p>
<h2 id="heading-try-it-yourself">Try it Yourself</h2>
<p>The live demo is at <a href="https://mastra-vs-langchain.vercel.app">mastra-vs-langchain.vercel.app</a> and the complete source code for this comparison is at <a href="https://github.com/sholajegede/mastra-vs-langchain">github.com/sholajegede/mastra-vs-langchain</a>. If it helped you, consider giving it a star.</p>
<pre><code class="language-bash">git clone https://github.com/sholajegede/mastra-vs-langchain.git
cd mastra-vs-langchain
npm install
cp .env.example .env
# Add ANTHROPIC_API_KEY and TAVILY_API_KEY
npx convex dev   # Terminal 1
npm run web      # Terminal 2
</code></pre>
<p>Open <code>localhost:3000</code>, enter a topic, pick a category, and run both. Every step is visible as it happens, every token is counted, and the history page stores all previous runs by category.</p>
<p>If you want to take this comparison further by adding CrewAI, CopilotKit, or any other framework to the benchmark, the <code>PipelineCallbacks</code> interface in <code>packages/shared</code> is the only contract you need to implement.</p>
<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>
        
            <item>
                <title>
                    <![CDATA[ How to Build and Deploy Tetris Inside ChatGPT: A Complete Guide to the Vercel ChatGPT Apps SDK with TypeScript, Convex, and Kinde OAuth ]]>
                </title>
                <description>
                    <![CDATA[ Imagine playing a fully functional game of Tetris without leaving your ChatGPT conversation: rotating pieces, clearing lines, competing on leaderboards – all within the chat interface you already use  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-and-deploy-tetris-inside-chatgpt/</link>
                <guid isPermaLink="false">69a5cfdbe8e1f9df72cd846f</guid>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatgpt ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Sat, 17 Jan 2026 09:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/d0bb371a-34b3-4f8e-9bf1-3579f3e4b639.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine playing a fully functional game of Tetris without leaving your ChatGPT conversation: rotating pieces, clearing lines, competing on leaderboards – all within the chat interface you already use every day.</p>
<p>With the Vercel ChatGPT Apps SDK and the Model Context Protocol (MCP), you can embed rich, interactive applications directly into ChatGPT.</p>
<p>In this tutorial, you'll build a production-ready Tetris game that lives inside ChatGPT, complete with user authentication, real-time leaderboards, and a replay system.</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-why-this-matters">Why This Matters</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-tech-stack-overview">Tech Stack Overview</a></p>
</li>
<li><p><a href="#heading-understanding-the-architecture">Understanding the Architecture</a></p>
</li>
<li><p><a href="#heading-key-concepts">Key Concepts</a></p>
</li>
<li><p><a href="#heading-project-setup-amp-initial-configuration">Project Setup &amp; Initial Configuration</a></p>
</li>
<li><p><a href="#heading-setting-up-the-convex-backend">Setting Up the Convex Backend</a></p>
</li>
<li><p><a href="#heading-building-the-tetris-game-engine">Building the Tetris Game Engine</a></p>
</li>
<li><p><a href="#heading-implementing-kinde-oauth-authentication">Implementing Kinde OAuth Authentication</a></p>
</li>
<li><p><a href="#heading-building-the-mcp-integration">Building the MCP Integration</a></p>
</li>
<li><p><a href="#heading-building-the-supporting-features">Building the Supporting Features</a></p>
</li>
<li><p><a href="#heading-deploying-to-vercel">Deploying to Vercel</a></p>
</li>
<li><p><a href="#heading-registering-with-chatgpt">Registering with ChatGPT</a></p>
</li>
<li><p><a href="#heading-finishing-up">Finishing Up</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-final-data-flow">Final Data Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end of this tutorial, you'll have a full-stack application with:</p>
<ol>
<li><p><strong>Core gameplay:</strong> A fully playable Tetris game with smooth animations, keyboard and touch controls, real-time scoring, and level progression.</p>
</li>
<li><p><strong>User Features:</strong> OAuth authentication via Kinde, persistent user profiles, public and private game modes, and a replay system that records every move.</p>
</li>
<li><p><strong>Social and competitive feel:</strong> A global leaderboard, replay viewer, and ChatGPT integration that lets you start games, check scores, and review replays through natural conversation.</p>
</li>
<li><p><strong>Technical architecture:</strong> Next.js 16 frontend with React 19 and Shadcn UI, Convex real-time database, MCP integration for ChatGPT tool registration, and production deployment on Vercel.</p>
</li>
</ol>
<p>The user experience looks like this: say "Start a new Tetris game" in ChatGPT, an embedded game widget appears, and you play using arrow keys or on-screen controls. When the game ends, ChatGPT updates your score. Then you can ask "Show me the top 10 players" and the leaderboard appears — all in a single conversational flow.</p>
<h2 id="heading-why-this-matters">Why This Matters</h2>
<p>The ChatGPT Apps SDK represents a fundamental shift in how we think about AI applications. Instead of building separate interfaces or forcing users to navigate between ChatGPT and your app, you bring your application into the conversation.</p>
<p>This means zero learning curve (users already know ChatGPT), contextual AI intelligence, reduced friction (no app downloads or extra accounts), and access to 800M weekly active users.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<h3 id="heading-required-skills">Required skills</h3>
<p>To follow along, you'll need JavaScript/TypeScript fundamentals (ES6+, async/await), React basics (components, hooks, props), basic Next.js familiarity, and command-line comfort.</p>
<h3 id="heading-required-accounts-and-tools">Required accounts and tools</h3>
<ul>
<li><p>Node.js 20+ and pnpm 10+</p>
</li>
<li><p>ChatGPT Plus subscription ($20/month as of January 2026)</p>
</li>
<li><p>Vercel account (free tier works)</p>
</li>
<li><p>Convex account (free tier: 1GB storage, 1M function calls/month)</p>
</li>
<li><p>Kinde account (free tier: 10,500 monthly active users)</p>
</li>
</ul>
<h2 id="heading-tech-stack-overview">Tech Stack Overview</h2>
<p>Let's quickly go over the tools we'll be using, and why we'll be using them, so you're familiar with our tech stack.</p>
<h3 id="heading-frontend-tools">Frontend Tools</h3>
<p><strong>Next.js 16 + React 19 + Shadcn UI</strong>: App Router, serverless API routes, optimized builds via Turbopack, and React 19's improved rendering.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771370133925/6e9aa2d5-4003-4d6a-8a10-52fa63fabfee.png" alt="Tetris Gameplay in ChatGPT" style="display:block;margin:0 auto" width="3422" height="1820" loading="lazy">

<h3 id="heading-backend-tools">Backend Tools</h3>
<p><strong>Convex</strong>: Replaces traditional databases and ORMs with a TypeScript-native real-time database. Here's why it matters:</p>
<pre><code class="language-typescript">// Traditional approach:
const game = await db.query("SELECT * FROM games WHERE id = ?", [gameId]);
game.score += 100;
await db.query("UPDATE games SET score = ? WHERE id = ?", [game.score, gameId]);
// Manually notify clients via WebSockets...

// Convex approach:
export const updateScore = mutation({
  args: { gameId: v.id("games"), points: v.number() },
  handler: async (ctx, args) =&gt; {
    await ctx.db.patch(args.gameId, { score: args.score + args.points });
    // All subscribed clients automatically receive the update
  }
});
</code></pre>
<p><strong>Authentication – Kinde OAuth 2.0</strong>: Handles secure JWT token generation, user profile management, and multiple providers. Simpler than Auth0, with a more generous free tier (10,500 MAU vs. 7,500).</p>
<p><strong>Deployment – Vercel</strong>: Zero-config Next.js deployment, automatic HTTPS, global CDN, and preview deployments for every PR.</p>
<p><strong>ChatGPT Integration – MCP (Model Context Protocol)</strong>: The bridge between ChatGPT and your application. Defines tools ChatGPT can call, resources it can read, and suggested phrases for users. The <code>mcp-handler</code> npm package handles protocol negotiation, message parsing, OAuth token extraction, and CORS headers:</p>
<pre><code class="language-typescript">import { createMCPHandler } from 'mcp-handler';

export const { GET, POST } = createMCPHandler({
  name: "Tetris Game Server",
  version: "1.0.0",
  setupServer: async (server) =&gt; {
    // Register all your tools here
  }
});
</code></pre>
<h2 id="heading-understanding-the-architecture">Understanding the Architecture</h2>
<p>Before writing a line of code, you need a mental model of how all the pieces fit together. This section moves from high-level system design down to specific component interactions.</p>
<h3 id="heading-the-vercel-chatgpt-app-template-foundation">The Vercel ChatGPT App Template Foundation</h3>
<p>The Vercel ChatGPT Apps template solves several hard problems out of the box: a pre-configured Next.js 16 setup with Turbopack, an integrated MCP protocol handler, OAuth discovery endpoints, CORS configuration for ChatGPT's iframe security model, and a widget rendering system.</p>
<p>The most important configuration file is <code>next.config.ts</code>. ChatGPT renders your app in an iframe from a different origin, so without permissive CORS headers, browsers block the cross-origin requests your game needs:</p>
<pre><code class="language-typescript">// next.config.ts
const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          { key: "Access-Control-Allow-Origin", value: "*" },
          { key: "Access-Control-Allow-Methods", value: "GET,POST,PUT,DELETE,OPTIONS" },
          { key: "Access-Control-Allow-Headers", value: "*" },
        ],
      },
    ];
  },
};
</code></pre>
<p>The template also provides a <code>baseURL</code> helper that's critical for OAuth redirects:</p>
<pre><code class="language-typescript">// lib/baseURL.ts
export const baseURL =
  process.env.NODE_ENV === "development"
    ? "http://localhost:3000"
    : "https://" +
      (process.env.VERCEL_ENV === "production"
        ? process.env.VERCEL_PROJECT_PRODUCTION_URL
        : process.env.VERCEL_BRANCH_URL || process.env.VERCEL_URL);
</code></pre>
<p>This ensures OAuth callbacks work in local development, preview deployments, and production without hardcoding URLs.</p>
<h3 id="heading-three-tier-architecture">Three-Tier Architecture</h3>
<p>Our Tetris application uses a classic three-tier architecture with modern serverless components:</p>
<pre><code class="language-markdown">┌──────────────────────────────────────────────────────┐
│                 TIER 1: PRESENTATION                 │
│  Chat Interface  ◄────────  Widget (iframe)          │
│  (natural language)         (React events)           │
└────────────────────────┬─────────────────────────────┘
                         │ MCP (JSON-RPC) / HTTP
┌────────────────────────▼─────────────────────────────┐
│                TIER 2: APPLICATION                   │
│  MCP Route     │    API Routes    │   React Pages    │
│  /app/mcp      │    /app/api/*    │   /app/tetris/*  │
└────────────────────────┬─────────────────────────────┘
                         │ Convex SDK
┌────────────────────────▼─────────────────────────────┐
│                   TIER 3: DATA                       │
│  Users │ Games │ Replays │ Leaderboard               │
│  Convex Functions (mutations &amp; queries)              │
└──────────────────────────────────────────────────────┘
</code></pre>
<p>Authentication flows horizontally across all tiers. The key insight: ChatGPT handles steps 1–5 of the OAuth flow automatically (redirect to Kinde, user authenticates, redirect back with code, exchange for token). Your Next.js app only handles steps 6–10: validation and user management.</p>
<h3 id="heading-component-relationships-and-data-flow">Component Relationships and Data Flow</h3>
<p>Here's the file structure you'll build:</p>
<pre><code class="language-markdown">/app
  /mcp/.well-known/oauth-protected-resource/route.ts
  /mcp/route.ts                    # MCP server + tool registration
  /api/create-game/route.ts        # HTTP endpoint for game creation
  /lib/kinde.ts                    # Kinde token validation
  /lib/mcpAuth.ts                  # MCP-specific auth helpers
  /tetris/play/page.tsx            # Main game interface
  /tetris/leaderboard/page.tsx     # Top scores table
  /tetris/replays/page.tsx         # Replay browser

/components/tetris/
  GameBoard.tsx                    # Core game logic + rendering
  Leaderboard.tsx                  # Leaderboard table component
  ReplayViewer.tsx                 # Replay playback component

/convex/
  schema.ts                        # Database table definitions
  games.ts                         # Game CRUD operations
  users.ts                         # User management
  replays.ts                       # Replay storage + retrieval
  leaderboards.ts                  # Top scores queries
</code></pre>
<p>The most important relationship is <code>GameBoard.tsx</code> to Convex. When a game starts, the MCP route creates a game in Convex and returns a widget URI. ChatGPT renders <code>GameBoard.tsx</code> in an iframe, which loads game state from Convex via <code>useQuery</code>.</p>
<p>The user plays locally (no round-trips for each move), actions are recorded in a ref, and when the game ends, everything syncs to Convex in a single mutation.</p>
<p>Leaderboard real-time updates demonstrate why Convex shines:</p>
<pre><code class="language-typescript">// components/tetris/Leaderboard.tsx
export default function Leaderboard() {
  // Re-runs automatically whenever leaderboard data changes
  const entries = useQuery(api.leaderboards.listTop, { limit: 20 }) || [];
  
  const userIds = entries.map((e: any) =&gt; e.userId).filter(Boolean);
  const users = useQuery(
    api.users.getMultipleById, 
    userIds.length &gt; 0 ? { userIds } : "skip"
  );

  const userMap = new Map();
  if (users) {
    users.forEach((user: any) =&gt; {
      if (user) userMap.set(user._id, user);
    });
  }

  return (
    &lt;div className="max-w-2xl mx-auto p-4"&gt;
      &lt;h2 className="text-2xl font-bold mb-4"&gt;Leaderboard&lt;/h2&gt;
      &lt;ol className="list-decimal pl-6 space-y-2"&gt;
        {entries.map((e: any, idx: number) =&gt; {
          const user = userMap.get(e.userId);
          const displayName = user 
            ? (user.displayName || `\({user.firstName || ''} \){user.lastName || ''}`.trim() || user.email)
            : 'Anonymous';
          
          return (
            &lt;li key={e._id} className="flex justify-between"&gt;
              &lt;div&gt;{displayName}&lt;/div&gt;
              &lt;div&gt;{e.score}&lt;/div&gt;
            &lt;/li&gt;
          );
        })}
      &lt;/ol&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>When someone finishes a game elsewhere, their <code>GameBoard</code> calls <code>finishGame</code>, Convex updates the leaderboards table, and your <code>Leaderboard</code> component re-renders automatically with new data. No polling, no WebSockets, no manual refresh code.</p>
<h2 id="heading-key-concepts">Key Concepts</h2>
<h4 id="heading-mcp-model-context-protocol"><strong>MCP (Model Context Protocol)</strong></h4>
<p>This is a JSON-RPC protocol that defines how AI assistants interact with external applications. It has three primitives:</p>
<ul>
<li><p><strong>Tools</strong>: Functions the AI can call (for example, <code>start_game</code>, <code>finish_game</code>)</p>
</li>
<li><p><strong>Resources</strong>: Data the AI can read (for example, leaderboard entries as widgets)</p>
</li>
<li><p><strong>Prompts</strong>: Suggested phrases for users</p>
</li>
</ul>
<p>When you register your app, ChatGPT fetches your MCP manifest and discovers available tools. When a user types "start a game," ChatGPT's language model matches the intent to <code>start_game</code> and calls it automatically.</p>
<p>The complete tool execution lifecycle is:</p>
<ol>
<li><p>The user types natural language</p>
</li>
<li><p>ChatGPT calls your MCP endpoint via POST with the tool name and arguments</p>
</li>
<li><p>Your handler validates OAuth, creates a game in Convex, and returns the widget HTML</p>
</li>
<li><p>ChatGPT renders the widget in the chat interface</p>
</li>
<li><p>The user plays, and the widget calls <code>finish_game</code> when done</p>
</li>
<li><p>ChatGPT displays the results in chat</p>
</li>
</ol>
<h3 id="heading-widgets"><strong>Widgets</strong></h3>
<p>These are full HTML documents rendered in sandboxed iframes. Your Next.js pages become widgets via the <code>getAppsSdkCompatibleHtml</code> helper. The <code>text/html+skybridge</code> MIME type tells ChatGPT to render the content as an interactive widget rather than plain text.</p>
<h3 id="heading-tool-registration"><strong>Tool registration</strong></h3>
<p>This uses Zod schemas for input validation and security schemes to control authentication requirements.</p>
<p><code>registerTool</code> takes three things: the tool name, a configuration object describing the tool's inputs, security requirements, and widget metadata, and an async handler function that runs when ChatGPT calls the tool.</p>
<p>The <code>inputSchema</code> uses Zod to define and validate what arguments ChatGPT can pass in. The <code>securitySchemes</code> array controls whether authentication is required. The <code>_meta</code> field tells ChatGPT to render the tool's response as an interactive widget rather than plain text.</p>
<pre><code class="language-typescript">s.registerTool(
    "start_game",
    {
      securitySchemes: [
        { type: "noauth" }, // Allow anonymous play
        { type: "oauth2", scopes: ["profile"] },
      ],
      title: "Start Game",
      description:
        "Start a new Tetris game (creates a game record and opens the play widget). User will be associated if authenticated.",
      inputSchema: {
        public: z
          .boolean()
          .optional()
          .describe("Whether the game is public for spectating"),
        seed: z
          .number()
          .optional()
          .describe("Optional seed for deterministic play"),
      },
      _meta: widgetMeta(playWidget),
    },
  async (args: any, context: any) =&gt; { /* handler */ }
);
</code></pre>
<p>Multiple security schemes mean OR logic and users can play anonymously OR authenticated. A single scheme makes authentication required.</p>
<h3 id="heading-a-complete-request-flow">A Complete Request Flow</h3>
<p>To make the architecture concrete, here's a full trace when an authenticated user starts a public game:</p>
<ol>
<li><p>The user says "Start a public Tetris game"</p>
</li>
<li><p>ChatGPT parses intent, checks for existing OAuth token</p>
</li>
<li><p>ChatGPT POSTs to <code>/mcp</code> with <code>Authorization: Bearer &lt;token&gt;</code> and <code>{ "method": "tools/call", "params": { "name": "start_game", "arguments": { "public": true } } }</code></p>
</li>
<li><p>The Next.js MCP route extracts and validates the token via Kinde JWKS</p>
</li>
<li><p>Next.js calls <code>api.users.upsertLinkedAccount</code> in Convex, receives a <code>userId</code></p>
</li>
<li><p>Next.js calls <code>api.games.createGame</code> in Convex with the <code>userId</code>, receives a <code>gameId</code></p>
</li>
<li><p>Next.js renders widget HTML for <code>/tetris/play?gameId=&lt;gameId&gt;</code></p>
</li>
<li><p>The Widget HTML is returned to ChatGPT in MCP response</p>
</li>
<li><p>ChatGPT renders the widget and <code>GameBoard.tsx</code> mounts and fetches game state</p>
</li>
<li><p>The user plays, the score updates locally, and actions are recorded in a ref</p>
</li>
<li><p>The game ends. <code>GameBoard</code> calls <code>api.games.finishGame</code> which atomically creates replay, updates leaderboard, updates user stats.</p>
</li>
<li><p>Convex reactivity pushes leaderboard updates to all subscribed clients</p>
</li>
<li><p><code>GameBoard</code> calls <code>useSendMessage</code> to post results back to the chat thread</p>
</li>
</ol>
<h2 id="heading-project-setup-amp-initial-configuration">Project Setup &amp; Initial Configuration</h2>
<p>By the end of this section, you'll have a running Next.js app, a connected Convex database, Kinde OAuth configured, and all environment variables in place.</p>
<h3 id="heading-install-required-tools">Install Required Tools</h3>
<p>Verify your Node.js version first:</p>
<pre><code class="language-bash">node --version   # Should be v24.x or higher
pnpm --version   # Should be v10.x or higher
</code></pre>
<p>If pnpm isn't installed:</p>
<pre><code class="language-bash">npm install -g pnpm
</code></pre>
<h3 id="heading-clone-the-vercel-chatgpt-apps-template">Clone the Vercel ChatGPT Apps Template</h3>
<pre><code class="language-bash">pnpm create next-app@latest tetris-chatgpt-app \
  --example https://github.com/vercel-labs/chatgpt-apps-sdk-nextjs-starter

cd tetris-chatgpt-app
pnpm install
</code></pre>
<h3 id="heading-install-project-dependencies">Install Project Dependencies</h3>
<p>Add all packages needed for the complete application:</p>
<pre><code class="language-bash"># Real-time database
pnpm add convex@^1.29.3

# MCP handler
pnpm add mcp-handler@^1.0.2

# Kinde OAuth token validation
pnpm add jose@^6.1.3

# Radix UI primitives (used by Shadcn)
pnpm add @radix-ui/react-dialog@^1.1.15 \
         @radix-ui/react-label@^2.1.8 \
         @radix-ui/react-select@^2.2.6 \
         @radix-ui/react-slot@^1.2.4 \
         @radix-ui/react-tabs@^1.1.13

# Utilities and UI
pnpm add class-variance-authority@^0.7.1 \
         clsx@^2.1.1 \
         lucide-react@^0.555.0 \
         sonner@^2.0.7 \
         tailwind-merge@^3.4.0 \
         zod@3.24.2 \
         next-themes@^0.4.6 \
         @modelcontextprotocol/sdk@^1.20.0

# Dev dependencies
pnpm add -D @tailwindcss/postcss@^4 tailwindcss@^4 tw-animate-css@^1.4.0
</code></pre>
<h3 id="heading-set-up-convex">Set Up Convex</h3>
<pre><code class="language-bash">pnpm add -g convex
pnpm convex dev
</code></pre>
<p>The interactive prompt will ask you to create a new project. After setup, Convex creates a <code>convex/</code> directory, a <code>.env.local</code> file with your deployment URL, and starts watching for schema changes. Keep this terminal running throughout development.</p>
<p>Add the Convex React provider. Create <code>app/provider.tsx</code>:</p>
<pre><code class="language-typescript">"use client";

import { ReactNode } from "react";
import { ConvexProvider, ConvexReactClient } from "convex/react";

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export function ConvexClientProvider({ children }: { children: ReactNode }) {
  return &lt;ConvexProvider client={convex}&gt;{children}&lt;/ConvexProvider&gt;;
}
</code></pre>
<p>Then wrap your app in <code>app/layout.tsx</code>:</p>
<pre><code class="language-typescript">import { ConvexClientProvider } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang="en"&gt;
      &lt;body
        className={`\({geistSans.variable} \){geistMono.variable} antialiased`}
      &gt;
        &lt;ConvexClientProvider&gt;{children}&lt;/ConvexClientProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<h3 id="heading-set-up-kinde-oauth">Set Up Kinde OAuth</h3>
<p>Go to <a href="https://kinde.com?utm_source=fcc&amp;utm_medium=content&amp;utm_campaign=shola&amp;campaignid=chatgptapp&amp;network=&amp;adgroup=&amp;keyword=&amp;matchtype=&amp;creative=3&amp;device=&amp;adposition=">kinde.com</a>, create an account, then create a new back-end web application named "Tetris ChatGPT App."</p>
<p>In your application's settings, add these allowed callback URLs:</p>
<pre><code class="language-markdown">https://localhost:3000/api/auth/callback
https://chatgpt.com/connector_platform_oauth_redirect
</code></pre>
<p>And these allowed logout redirect URLs:</p>
<pre><code class="language-markdown">https://tetris-chatgpt-app.vercel.app
https://chatgpt.com
</code></pre>
<p>Copy your Domain, Client ID, and Client Secret from the application details page.</p>
<p>Now create <code>app/lib/kinde.ts</code>:</p>
<pre><code class="language-typescript">import { createRemoteJWKSet, jwtVerify } from 'jose';

const KINDE_ISSUER = process.env.KINDE_ISSUER!;
const MCP_AUDIENCE = process.env.MCP_AUDIENCE!;

let cachedJWKS: ReturnType&lt;typeof createRemoteJWKSet&gt; | null = null;

async function getJWKS() {
  if (!cachedJWKS) {
    cachedJWKS = createRemoteJWKSet(
      new URL(`${KINDE_ISSUER}/.well-known/jwks.json`)
    );
  }
  return cachedJWKS;
}

export async function validateKindeToken(token: string) {
  if (!token) throw new Error('No token provided');

  const JWKS = await getJWKS();
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: KINDE_ISSUER,
    audience: MCP_AUDIENCE,
  });

  return payload as {
    sub: string;
    email?: string;
    given_name?: string;
    family_name?: string;
    picture?: string;
    exp: number;
    iat: number;
  };
}

export async function getKindeUserProfile(token: string) {
  const payload = await validateKindeToken(token);
  return {
    id: payload.sub,
    email: payload.email,
    name: [payload.given_name, payload.family_name].filter(Boolean).join(' ') || 'Anonymous',
    picture: payload.picture,
  };
}
</code></pre>
<p>Create the OAuth discovery endpoint at <code>app/mcp/.well-known/oauth-protected-resource/route.ts</code>:</p>
<pre><code class="language-typescript">import { NextResponse } from 'next/server';

export async function GET() {
  return NextResponse.json({
    resource: process.env.MCP_RESOURCE!,
    authorization_servers: [process.env.KINDE_ISSUER!],
    scopes_supported: ['openid', 'profile', 'email'],
    bearer_methods_supported: ['header'],
  });
}
</code></pre>
<p>This endpoint tells ChatGPT where to send users to authenticate. Without it, ChatGPT can't discover your OAuth configuration.</p>
<p>Create <code>app/lib/utils.ts</code>:</p>
<pre><code class="language-typescript">import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
</code></pre>
<h3 id="heading-environment-variables">Environment Variables</h3>
<p>Your complete <code>.env.local</code> should look like this:</p>
<pre><code class="language-bash"># Convex (auto-generated by pnpm convex dev)
CONVEX_DEPLOYMENT=dev:your-deployment-name
NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud

# Kinde OAuth
KINDE_ISSUER=https://yourcompany.kinde.com
KINDE_CLIENT_ID=your-client-id
KINDE_CLIENT_SECRET=your-client-secret

# MCP settings (update after deploying to Vercel)
MCP_AUDIENCE=http://localhost:3000/mcp
MCP_RESOURCE=http://localhost:3000
MCP_DOC_URL=http://localhost:3000/mcp-docs
</code></pre>
<p>Make sure <code>.env.local</code> is in your <code>.gitignore</code> and it should be by default in the template.</p>
<h3 id="heading-verify-everything-works">Verify Everything Works</h3>
<p>Open three terminals:</p>
<pre><code class="language-bash"># Terminal 1
pnpm convex dev

# Terminal 2
pnpm dev

# Terminal 3 — test the OAuth discovery endpoint
curl http://localhost:3000/mcp/.well-known/oauth-protected-resource
# Expected: { "resource": "...", "authorization_servers": [...], ... }
</code></pre>
<p>Open <code>http://localhost:3000</code> in your browser. If it loads without console errors and the OAuth endpoint returns JSON, your setup is complete.</p>
<p><strong>Common issues:</strong></p>
<ul>
<li><p><code>"Cannot find module 'convex/react'"</code> – run <code>pnpm install</code> and restart the dev server</p>
</li>
<li><p><code>"NEXT_PUBLIC_CONVEX_URL is not defined"</code> – run <code>pnpm convex dev</code> to regenerate <code>.env.local</code></p>
</li>
<li><p><code>"Failed to verify token signature"</code> – ensure <code>KINDE_ISSUER</code> has no trailing slash</p>
</li>
</ul>
<h3 id="heading-set-up-vercel">Set Up Vercel</h3>
<p>Install the CLI and link your project so deployment is one command away later:</p>
<pre><code class="language-bash">pnpm add -g vercel
vercel login
vercel link
</code></pre>
<p>Add your environment variables to Vercel now:</p>
<pre><code class="language-bash">vercel env add NEXT_PUBLIC_CONVEX_URL
vercel env add KINDE_ISSUER
vercel env add KINDE_CLIENT_ID
vercel env add KINDE_CLIENT_SECRET
</code></pre>
<p>You'll update the MCP-specific variables after your first deployment once you have a production URL.</p>
<h2 id="heading-setting-up-the-convex-backend">Setting Up the Convex Backend</h2>
<p>This section builds the complete data layer: schema, mutations, queries, and real-time reactivity. By the end, you'll have a fully functional backend that automatically pushes updates to every connected client the moment data changes — no polling, no manual refresh logic.</p>
<h3 id="heading-database-schema-design">Database Schema Design</h3>
<p>The schema is the contract for everything your app stores. Open <code>convex/schema.ts</code> and replace its contents with the full schema below. Take a moment to read through the table definitions before pasting — understanding what each table stores and why will make the mutation code much easier to follow.</p>
<p>Open <code>convex/schema.ts</code> and replace its contents with the full schema:</p>
<pre><code class="language-typescript">import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    email: v.string(),
    displayName: v.optional(v.string()),
    firstName: v.optional(v.string()),
    lastName: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
    imageStorageId: v.optional(v.id("_storage")),
    updatedAt: v.number(),
  }).index("by_email", ["email"]),

  linkedAccounts: defineTable({
    provider: v.string(),
    subject: v.string(),
    userId: v.id("users"),
    profile: v.optional(v.object({})),
    updatedAt: v.number(),
  }).index("by_provider_subject", ["provider", "subject"]).index("by_user", ["userId"]),

  games: defineTable({
    userId: v.optional(v.id("users")),
    status: v.union(
      v.literal("active"),
      v.literal("paused"),
      v.literal("finished"),
      v.literal("abandoned")
    ),
    score: v.number(),
    level: v.number(),
    linesCleared: v.number(),
    board: v.array(v.number()),
    currentPiece: v.optional(
      v.object({ type: v.string(), rotation: v.number(), x: v.number(), y: v.number() })
    ),
    nextQueue: v.optional(v.array(v.string())),
    holdPiece: v.optional(v.string()),
    seed: v.optional(v.number()),
    replayId: v.optional(v.id("replays")),
    public: v.optional(v.boolean()),
    updatedAt: v.number(),
  })
    .index("by_user", ["userId"])
    .index("by_status", ["status"])
    .index("by_score", ["score"]),

  replays: defineTable({
    gameId: v.id("games"),
    userId: v.optional(v.id("users")),
    actions: v.array(v.object({ t: v.number(), a: v.string(), p: v.optional(v.object({})) })),
    durationMs: v.number(),
  }).index("by_game", ["gameId"]),

  leaderboards: defineTable({
    userId: v.id("users"),
    score: v.number(),
    level: v.number(),
    linesCleared: v.number(),
  }).index("by_score", ["score"]),
});
</code></pre>
<p>A few design decisions worth calling out:</p>
<p><code>linkedAccounts</code> <strong>is a separate table</strong>, so one user can authenticate via multiple OAuth providers without duplicating their profile. The <code>provider</code> + <code>subject</code> pair (for example, <code>"kinde"</code> + <code>"kinde|2151678548"</code>) uniquely identifies an OAuth identity. The <code>userId</code> field is a foreign key pointing to the single <code>users</code> row that represents the human behind potentially many auth accounts.</p>
<p><strong>The</strong> <code>replays.actions</code> <strong>array uses a compact format</strong> — <code>{ t, a }</code> stands for timestamp and action code — so an entire game fits in roughly 50KB of JSON rather than megabytes of full board snapshots taken at every tick.</p>
<p><strong>Indexes are not optional.</strong> Without <code>by_email</code> on <code>users</code>, finding a returning user requires a full table scan that grows linearly with your user count. With the index, lookups are O(log n) regardless of scale. Every table that will be queried by a specific field needs an index on that field.</p>
<h3 id="heading-user-management">User Management</h3>
<p>Create <code>convex/users.ts</code>. This file is the backbone of your identity system — it handles looking users up, creating new ones, and most importantly, linking OAuth provider identities to your own user records.</p>
<pre><code class="language-typescript">import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const getByEmail = query({
  args: { email: v.string() },
  handler: async (ctx, { email }) =&gt; {
    return await ctx.db
      .query("users")
      .withIndex("by_email", (q) =&gt; q.eq("email", email))
      .first();
  },
});

export const getById = query({
  args: { userId: v.id("users") },
  handler: async (ctx, { userId }) =&gt; {
    return await ctx.db.get(userId);
  },
});

export const getMultipleById = query({
  args: { userIds: v.array(v.id("users")) },
  handler: async (ctx, { userIds }) =&gt; {
    const users = await Promise.all(
      userIds.map(id =&gt; ctx.db.get(id))
    );
    return users;
  },
});

export const createOrUpdate = mutation({
  args: {
    email: v.string(),
    displayName: v.optional(v.string()),
    firstName: v.optional(v.string()),
    lastName: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
  },
  handler: async (ctx, args) =&gt; {
    const now = Date.now();

    const existingUser = await ctx.db
      .query("users")
      .withIndex("by_email", (q) =&gt; q.eq("email", args.email))
      .first();

    if (existingUser) {
      // Update existing user — only overwrite fields that are actually provided,
      // so a partial update won't clear data you didn't intend to touch.
      return await ctx.db.patch(existingUser._id, {
        displayName: args.displayName ?? existingUser.displayName,
        firstName: args.firstName ?? existingUser.firstName,
        lastName: args.lastName ?? existingUser.lastName,
        imageUrl: args.imageUrl ?? existingUser.imageUrl,
        updatedAt: now,
      });
    }

    // First time we've seen this email — create a new user record
    return await ctx.db.insert("users", {
      email: args.email,
      displayName: args.displayName,
      firstName: args.firstName,
      lastName: args.lastName,
      imageUrl: args.imageUrl,
      updatedAt: now,
    });
  },
});

export const patchProfile = mutation({
  args: {
    userId: v.id("users"),
    displayName: v.optional(v.string()),
    firstName: v.optional(v.string()),
    lastName: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
    imageStorageId: v.optional(v.id("_storage")),
  },
  handler: async (ctx, args) =&gt; {
    // Build the patch object dynamically so we only write fields that were passed in
    const patch: Record&lt;string, any&gt; = { updatedAt: Date.now() };
    if (args.displayName !== undefined) patch.displayName = args.displayName;
    if (args.firstName !== undefined) patch.firstName = args.firstName;
    if (args.lastName !== undefined) patch.lastName = args.lastName;
    if (args.imageUrl !== undefined) patch.imageUrl = args.imageUrl;
    if (args.imageStorageId !== undefined) patch.imageStorageId = args.imageStorageId;

    return await ctx.db.patch(args.userId, patch);
  },
});

export const upsertLinkedAccount = mutation({
  args: {
    provider: v.string(),
    subject: v.string(),
    profile: v.optional(v.object({})),
  },
  handler: async (ctx, { provider, subject, profile }) =&gt; {
    const now = Date.now();

    // Step 1: Check if we've seen this exact OAuth identity before
    const linked = await ctx.db
      .query("linkedAccounts")
      .withIndex("by_provider_subject", (q) =&gt; q.eq("provider", provider).eq("subject", subject))
      .first();

    if (linked) {
      // Already linked — just refresh the cached profile data and return the existing userId
      await ctx.db.patch(linked._id, { profile: profile ?? linked.profile, updatedAt: now });
      return linked.userId;
    }

    // Step 2: New OAuth identity — try to find an existing Convex user by email
    // so we don't create a duplicate account if this person signed up a different way
    let user = null;
    const email = profile &amp;&amp; (profile as any).email;
    if (email) {
      user = await ctx.db
        .query("users")
        .withIndex("by_email", (q) =&gt; q.eq("email", email))
        .first();
    }

    // Step 3: If no match by email, create a brand new user record
    if (!user) {
      const created = await ctx.db.insert("users", {
        email: email ?? `\({provider}:\){subject}`,
        displayName: profile &amp;&amp; (profile as any).name,
        imageUrl: profile &amp;&amp; (profile as any).picture,
        updatedAt: now,
      });
      user = created;
    }
    
    const userId = typeof user === "string" ? user : user._id;

    // Step 4: Record the link between this OAuth identity and the Convex user
    await ctx.db.insert("linkedAccounts", {
      provider,
      subject,
      userId: userId,
      profile: profile ?? {},
      updatedAt: now,
    });

    return userId;
  },
});
</code></pre>
<p><code>upsertLinkedAccount</code> is the most important mutation in this file — it's the OAuth entry point for your entire app. Every time a user authenticates via Kinde, you call this function and it runs through four steps:</p>
<ol>
<li><p>Look up the OAuth identity (<code>provider</code> + <code>subject</code>) in <code>linkedAccounts</code></p>
</li>
<li><p>If found, update the cached profile and return the existing <code>userId</code> — same user, no new records</p>
</li>
<li><p>If not found, try to match by email in case this user already signed up a different way</p>
</li>
<li><p>If still no match, create a new user and link the OAuth identity to it</p>
</li>
</ol>
<p>This design means a user who authenticates with Google and later with GitHub ends up with one <code>users</code> record and two <code>linkedAccounts</code> rows, both pointing to the same Convex user ID.</p>
<h3 id="heading-game-management">Game Management</h3>
<p>Create <code>convex/games.ts</code>. This file manages the full lifecycle of a game — creation, incremental state updates, and the final atomic write when a game ends.</p>
<pre><code class="language-typescript">import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const createGame = mutation({
  args: {
    userId: v.optional(v.id("users")),
    public: v.optional(v.boolean()),
    seed: v.optional(v.number()),
    board: v.optional(v.array(v.number())),
    currentPiece: v.optional(v.object({ type: v.string(), rotation: v.number(), x: v.number(), y: v.number() })),
    nextQueue: v.optional(v.array(v.string())),
    holdPiece: v.optional(v.string()),
  },
  handler: async (ctx, args) =&gt; {
    const now = Date.now();
    const inserted = await ctx.db.insert("games", {
      userId: args.userId,
      status: "active",
      score: 0,
      level: 1,
      linesCleared: 0,
      board: args.board ?? [],
      currentPiece: args.currentPiece,
      nextQueue: args.nextQueue ?? [],
      holdPiece: args.holdPiece,
      seed: args.seed,
      replayId: undefined,
      public: args.public ?? false,
      updatedAt: now,
    });

    return inserted;
  },
});

export const getGame = query({
  args: { gameId: v.id("games") },
  handler: async (ctx, { gameId }) =&gt; {
    return await ctx.db.get(gameId);
  },
});

export const listByUser = query({
  args: { userId: v.id("users"), status: v.optional(v.string()) },
  handler: async (ctx, { userId, status }) =&gt; {
    const q = ctx.db.query("games").withIndex("by_user", (q) =&gt; q.eq("userId", userId));
    const all = await q.collect();
    if (status) return all.filter((g: any) =&gt; g.status === status);
    return all;
  },
});

export const patchGame = mutation({
  args: {
    gameId: v.id("games"),
    status: v.optional(v.union(v.literal("active"), v.literal("paused"), v.literal("finished"), v.literal("abandoned"))),
    score: v.optional(v.number()),
    level: v.optional(v.number()),
    linesCleared: v.optional(v.number()),
    board: v.optional(v.array(v.number())),
    currentPiece: v.optional(v.object({ type: v.string(), rotation: v.optional(v.number()), x: v.number(), y: v.number() })),
    nextQueue: v.optional(v.array(v.string())),
    holdPiece: v.optional(v.string()),
    seed: v.optional(v.number()),
    replayId: v.optional(v.id("replays")),
    public: v.optional(v.boolean()),
  },
  handler: async (ctx, args) =&gt; {
    // Build a patch object with only the fields that were explicitly provided.
    // This prevents an accidental undefined from overwriting real data.
    const patch: Record&lt;string, any&gt; = { updatedAt: Date.now() };
    if (args.status !== undefined) patch.status = args.status;
    if (args.score !== undefined) patch.score = args.score;
    if (args.level !== undefined) patch.level = args.level;
    if (args.linesCleared !== undefined) patch.linesCleared = args.linesCleared;
    if (args.board !== undefined) patch.board = args.board;
    if (args.currentPiece !== undefined) patch.currentPiece = args.currentPiece;
    if (args.nextQueue !== undefined) patch.nextQueue = args.nextQueue;
    if (args.holdPiece !== undefined) patch.holdPiece = args.holdPiece;
    if (args.seed !== undefined) patch.seed = args.seed;
    if (args.replayId !== undefined) patch.replayId = args.replayId;
    if (args.public !== undefined) patch.public = args.public;

    return await ctx.db.patch(args.gameId, patch);
  },
});

export const setStatus = mutation({
  args: { gameId: v.id("games"), status: v.union(v.literal("active"), v.literal("paused"), v.literal("finished"), v.literal("abandoned")) },
  handler: async (ctx, { gameId, status }) =&gt; {
    return await ctx.db.patch(gameId, { status, updatedAt: Date.now() });
  },
});

export const finishGame = mutation({
  args: {
    gameId: v.id("games"),
    score: v.number(),
    level: v.number(),
    linesCleared: v.number(),
    replayActions: v.optional(v.array(v.object({ t: v.number(), a: v.string(), p: v.optional(v.object({})) }))),
    durationMs: v.optional(v.number()),
    userId: v.optional(v.id("users")),
  },
  handler: async (ctx, { gameId, score, level, linesCleared, replayActions, durationMs, userId }) =&gt; {
    const now = Date.now();
    const game = await ctx.db.get(gameId);
    if (!game) throw new Error("Game not found");

    // Use the userId passed in, or fall back to the one stored on the game record
    const finalUserId = userId ?? game.userId;

    let replayId = game.replayId ?? undefined;

    // Save the replay if any actions were recorded during the game
    if (replayActions &amp;&amp; replayActions.length &gt; 0) {
      const insertedReplay = await ctx.db.insert("replays", {
        gameId,
        userId: finalUserId,
        actions: replayActions,
        durationMs: durationMs ?? 0,
      });
      replayId = insertedReplay;
    }

    // Mark the game finished with final stats
    await ctx.db.patch(gameId, {
      userId: finalUserId,
      status: "finished",
      score,
      level,
      linesCleared,
      replayId,
      updatedAt: now,
    });

    // Only add a leaderboard entry for authenticated users — anonymous games
    // are saved but don't appear in the public rankings
    if (finalUserId) {
      await ctx.db.insert("leaderboards", {
        userId: finalUserId,
        score,
        level,
        linesCleared,
      });
    }

    return await ctx.db.get(gameId);
  },
});

export const deleteGame = mutation({
  args: { gameId: v.id("games") },
  handler: async (ctx, { gameId }) =&gt; {
    return await ctx.db.delete(gameId);
  },
});

export const listPublicFinishedGames = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    const finished = await ctx.db.query("games").withIndex("by_status", (q) =&gt; q.eq("status", "finished")).collect();
    const publicOnes = (finished as any[]).filter((g) =&gt; g.public === true);
    if (limit) return publicOnes.slice(0, limit);
    return publicOnes;
  },
});

export const getTopLeaderboard = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    const all = await ctx.db.query("leaderboards").withIndex("by_score", (q) =&gt; q).collect();
    const sorted = (all as any[]).sort((a, b) =&gt; b.score - a.score);
    if (limit) return sorted.slice(0, limit);
    return sorted;
  },
});
</code></pre>
<p><code>finishGame</code> is the most important mutation in the entire app. All of these writes — creating the replay, updating the game's status, and inserting the leaderboard entry — happen inside a single Convex mutation, which means they run in a single transaction.</p>
<p>Either all of them succeed or none of them commit. You'll never end up with a finished game that has no leaderboard entry, or a leaderboard entry pointing to a game that was never marked finished.</p>
<h3 id="heading-replay-and-leaderboard-functions">Replay and Leaderboard Functions</h3>
<p>Create <code>convex/replays.ts</code>. The key query to understand here is <code>getRecentReplaysWithDetails</code>, which joins replay records with their related game and user data in one call. Convex doesn't have SQL-style JOINs, so the idiomatic pattern is to fetch the related IDs in one query and then resolve them with <code>Promise.all</code>.</p>
<pre><code class="language-typescript">import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const createReplay = mutation({
  args: {
    gameId: v.id("games"),
    userId: v.optional(v.id("users")),
    actions: v.array(v.object({ t: v.number(), a: v.string(), p: v.optional(v.object({})) })),
    durationMs: v.number(),
  },
  handler: async (ctx, { gameId, userId, actions, durationMs }) =&gt; {
    return await ctx.db.insert("replays", {
      gameId,
      userId,
      actions,
      durationMs,
    });
  },
});

export const getReplay = query({
  args: { replayId: v.id("replays") },
  handler: async (ctx, { replayId }) =&gt; {
    const replay = await ctx.db.get(replayId);
    if (!replay) return null;
    // Eagerly load the related game and user so the viewer component
    // gets everything it needs in one round trip
    const game = await ctx.db.get(replay.gameId);
    const user = replay.userId ? await ctx.db.get(replay.userId) : null;
    return { ...replay, game, user };
  },
});

export const listByGame = query({
  args: { gameId: v.id("games") },
  handler: async (ctx, { gameId }) =&gt; {
    return await ctx.db
      .query("replays")
      .withIndex("by_game", (q) =&gt; q.eq("gameId", gameId))
      .collect();
  },
});

export const listByUser = query({
  args: { userId: v.id("users") },
  handler: async (ctx, { userId }) =&gt; {
    return await ctx.db
      .query("replays")
      .filter((q) =&gt; q.eq(q.field("userId"), userId))
      .collect();
  },
});

export const patchReplay = mutation({
  args: {
    replayId: v.id("replays"),
    actions: v.optional(v.array(v.object({ t: v.number(), a: v.string(), p: v.optional(v.object({})) }))),
    durationMs: v.optional(v.number()),
  },
  handler: async (ctx, { replayId, actions, durationMs }) =&gt; {
    const patch: Record&lt;string, any&gt; = {};
    if (actions !== undefined) patch.actions = actions;
    if (durationMs !== undefined) patch.durationMs = durationMs;
    return await ctx.db.patch(replayId, patch);
  },
});

export const deleteReplay = mutation({
  args: { replayId: v.id("replays") },
  handler: async (ctx, { replayId }) =&gt; {
    return await ctx.db.delete(replayId);
  },
});

export const getRecentReplays = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    return await ctx.db
      .query("replays")
      .order("desc")
      .take(limit ?? 10);
  },
});

export const getRecentReplaysWithDetails = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    const replays = await ctx.db
      .query("replays")
      .order("desc")
      .take(limit ?? 10);
    
    // For each replay, fetch the related game and user records in parallel.
    // This is Convex's pattern for relational data — Promise.all keeps it
    // efficient by firing all lookups concurrently rather than one at a time.
    const withDetails = await Promise.all(
      replays.map(async (replay) =&gt; {
        const game = replay.gameId ? await ctx.db.get(replay.gameId) : null;
        const user = replay.userId ? await ctx.db.get(replay.userId) : null;

        return {
          ...replay,
          game,
          user,
        };
      })
    );

    return withDetails;
  },
});

export const getTopReplays = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    // Fetch all replays, then look up each game's score for sorting.
    // For large datasets you'd want a denormalized score field on the replay
    // itself, but at this scale this approach is simple and readable.
    const replays = await ctx.db.query("replays").collect();

    const withScores = await Promise.all(
      replays.map(async (replay) =&gt; {
        const game = await ctx.db.get(replay.gameId);
        return {
          ...replay,
          game,
          score: game?.score ?? 0,
        };
      })
    );

    const sorted = withScores.sort((a, b) =&gt; b.score - a.score);

    return limit ? sorted.slice(0, limit) : sorted;
  },
});
</code></pre>
<p>Now create <code>convex/leaderboards.ts</code>. A leaderboard entry is a denormalized snapshot — a point-in-time record of one game result. We don't update entries in place; every finished game creates a new row. That keeps writes simple and makes querying the historical record straightforward.</p>
<pre><code class="language-typescript">import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const insertScore = mutation({
  args: {
    userId: v.id("users"),
    score: v.number(),
    level: v.number(),
    linesCleared: v.number(),
  },
  handler: async (ctx, { userId, score, level, linesCleared }) =&gt; {
    const now = Date.now();
    return await ctx.db.insert("leaderboards", {
      userId,
      score,
      level,
      linesCleared,
    });
  },
});

export const getEntry = query({
  args: { entryId: v.id("leaderboards") },
  handler: async (ctx, { entryId }) =&gt; {
    return await ctx.db.get(entryId);
  },
});

export const listTop = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) =&gt; {
    // Convex doesn't yet support ORDER BY DESC on indexed queries, so we fetch
    // all entries and sort in application code. For very large tables you'd
    // want to cap the initial query, but for a game leaderboard this is fine.
    const all = await ctx.db.query("leaderboards").withIndex("by_score").collect();
    const sorted = (all as any[]).sort((a, b) =&gt; b.score - a.score);
    if (limit) return sorted.slice(0, limit);
    return sorted;
  },
});

export const listByUser = query({
  args: { userId: v.id("users") },
  handler: async (ctx, { userId }) =&gt; {
    return await ctx.db.query("leaderboards").filter((q) =&gt; q.eq(q.field("userId"), userId)).collect();
  },
});

export const patchEntry = mutation({
  args: {
    entryId: v.id("leaderboards"),
    score: v.optional(v.number()),
    level: v.optional(v.number()),
    linesCleared: v.optional(v.number()),
  },
  handler: async (ctx, { entryId, score, level, linesCleared }) =&gt; {
    const patch: Record&lt;string, any&gt; = {};
    if (score !== undefined) patch.score = score;
    if (level !== undefined) patch.level = level;
    if (linesCleared !== undefined) patch.linesCleared = linesCleared;
    return await ctx.db.patch(entryId, patch);
  },
});

export const deleteEntry = mutation({
  args: { entryId: v.id("leaderboards") },
  handler: async (ctx, { entryId }) =&gt; {
    return await ctx.db.delete(entryId);
  },
});

export const pruneOldEntries = mutation({
  args: { maxAgeMs: v.number() },
  handler: async (ctx, { maxAgeMs }) =&gt; {
    const cutoff = Date.now() - maxAgeMs;
    const all = await ctx.db.query("leaderboards").collect();
    // Delete entries whose _creationTime falls before the cutoff
    const toDelete = (all as any[]).filter((e) =&gt; (e._creationTime || 0) &lt; cutoff);
    for (const e of toDelete) await ctx.db.delete(e._id);
    return toDelete.length;
  },
});
</code></pre>
<h3 id="heading-understanding-convex-reactivity">Understanding Convex Reactivity</h3>
<p>Create a quick test in <code>app/page.tsx</code> to see real-time updates in action:</p>
<pre><code class="language-typescript">"use client";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

export default function Home() {
  const leaderboard = useQuery(api.leaderboards.getTop, { limit: 5 });

  return (
    &lt;ul&gt;
      {leaderboard?.map((entry, idx) =&gt; (
        &lt;li key={entry._id}&gt;
          #{idx + 1} {entry.userName} — {entry.score.toLocaleString()}
        &lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
}
</code></pre>
<p>Open this page in two browser windows. When you add a new score via the Convex dashboard, both windows update within 50ms without any polling code. This is how your leaderboard will work in production.</p>
<p>When you call <code>useQuery</code>, Convex executes the query function on the server, returns the current results to your component, and automatically subscribes the client to any future changes in the tables that query touched.</p>
<p>When a mutation writes to one of those tables, Convex re-runs the query, diffs the results against what the client already has, and pushes only what changed. You write zero synchronization code — the subscription is established automatically and cleaned up when the component unmounts.</p>
<h2 id="heading-building-the-tetris-game-engine">Building the Tetris Game Engine</h2>
<p>The game engine lives entirely in <code>components/tetris/GameBoard.tsx</code>. This section walks through every part of it: constants, utility functions, state management, the game loop, and MCP integration.</p>
<h3 id="heading-constants-and-piece-definitions">Constants and Piece Definitions</h3>
<pre><code class="language-typescript">const WIDTH = 10;
const HEIGHT = 20;

const PIECES: Record&lt;string, number[][]&gt; = {
  I: [[1, 1, 1, 1]],
  O: [[1, 1], [1, 1]],
  T: [[0, 1, 0], [1, 1, 1]],
  S: [[0, 1, 1], [1, 1, 0]],
  Z: [[1, 1, 0], [0, 1, 1]],
  J: [[1, 0, 0], [1, 1, 1]],
  L: [[0, 0, 1], [1, 1, 1]],
};

const PIECE_COLORS: Record&lt;string, string&gt; = {
  I: "#00f0f0",
  O: "#f0f000",
  T: "#a000f0",
  S: "#00f000",
  Z: "#f00000",
  J: "#0000f0",
  L: "#f0a000",
};

const PIECE_TYPES = Object.keys(PIECES);
</code></pre>
<p>Each piece is stored as the smallest bounding box containing its shape, where 1 is a filled cell and 0 is transparent. This keeps rotation math simple and collision detection fast. The colors follow The Tetris Company's official guidelines, making the game instantly recognizable.</p>
<h3 id="heading-core-utility-functions">Core Utility Functions</h3>
<p>These three functions are used throughout the engine. They are separated here because each one is independently testable and reused in multiple places.</p>
<h4 id="heading-empty-board">Empty board</h4>
<pre><code class="language-typescript">function emptyBoard() {
  return Array.from({ length: HEIGHT }, () =&gt;
    Array.from({ length: WIDTH }, () =&gt; 0)
  );
}
</code></pre>
<h4 id="heading-rotation-90-degrees-clockwise">Rotation (90 degrees clockwise)</h4>
<pre><code class="language-typescript">function rotate(shape: number[][]) {
  const h = shape.length;
  const w = shape[0].length;
  const out = Array.from({ length: w }, () =&gt;
    Array.from({ length: h }, () =&gt; 0)
  );
  for (let r = 0; r &lt; h; r++) {
    for (let c = 0; c &lt; w; c++) {
      out[c][h - 1 - r] = shape[r][c];
    }
  }
  return out;
}
</code></pre>
<p>The transformation <code>out[c][h - 1 - r] = shape[r][c]</code> transposes the matrix and reverses rows in one pass. For the T-piece, <code>[0,1,0] / [1,1,1]</code> becomes <code>[1,0] / [1,1] / [1,0]</code>.</p>
<h4 id="heading-collision-detection">Collision detection</h4>
<p>The most critical function, runs up to 60 times per second:</p>
<pre><code class="language-typescript">function canPlace(board: number[][], shape: number[][], x: number, y: number) {
  for (let r = 0; r &lt; shape.length; r++) {
    for (let c = 0; c &lt; shape[0].length; c++) {
      if (!shape[r][c]) continue;          // Skip empty cells immediately
      const br = y + r;
      const bc = x + c;
      if (bc &lt; 0 || bc &gt;= WIDTH || br &lt; 0 || br &gt;= HEIGHT) return false;
      if (board[br][bc]) return false;
    }
  }
  return true;
}
</code></pre>
<p>The early <code>continue</code> on empty cells is the key optimization. Most cells in a piece's bounding box are empty, so this skips the majority of iterations.</p>
<h3 id="heading-react-state-management">React State Management</h3>
<pre><code class="language-typescript">export default function GameBoard() {
  const [board, setBoard] = useState&lt;number[][]&gt;(emptyBoard());
  const [current, setCurrent] = useState&lt;{
    type: string; shape: number[][]; x: number; y: number;
  } | null&gt;(null);
  const [next, setNext] = useState&lt;string&gt;(
    () =&gt; PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)]
  );
  const [running, setRunning] = useState(false);
  const [score, setScore] = useState(0);
  const [lines, setLines] = useState(0);
  const [level, setLevel] = useState(1);
  const [musicEnabled, setMusicEnabled] = useState(true);
  const [clearingRows, setClearingRows] = useState&lt;number[]&gt;([]);

  // Refs for values that shouldn't trigger re-renders
  const actionsRef = useRef&lt;any[]&gt;([]);
  const tickRef = useRef&lt;number | null&gt;(null);
  const bgMusicRef = useRef&lt;HTMLAudioElement | null&gt;(null);
  const clearSoundRef = useRef&lt;HTMLAudioElement | null&gt;(null);

  // MCP integration hooks from Vercel template
  const callTool = useCallTool();
  const sendMessage = useSendMessage();
  const [gameId, setGameId] = useState&lt;Id&lt;"games"&gt; | null&gt;(null);
  const startTimeRef = useRef&lt;number | null&gt;(null);
}
</code></pre>
<p>The distinction between <code>useState</code> and <code>useRef</code> matters for performance. <code>actionsRef</code> grows with every keypress and if it were state, every move would trigger a re-render and cause lag. <code>tickRef</code> holds the interval ID, which only needs to exist for cleanup. <code>bgMusicRef</code> holds an Audio element that never appears in the UI. None of these need to cause re-renders, so all three are refs.</p>
<h3 id="heading-audio-system">Audio System</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  bgMusicRef.current = new Audio(
    "https://cdn.freesound.org/previews/612/612091_3283808-lq.mp3"
  );
  bgMusicRef.current.loop = true;
  bgMusicRef.current.volume = 0.3;

  clearSoundRef.current = new Audio(
    "https://cdn.freesound.org/previews/341/341695_5858296-lq.mp3"
  );
  clearSoundRef.current.volume = 0.5;

  return () =&gt; {
    bgMusicRef.current?.pause();
    bgMusicRef.current = null;
    clearSoundRef.current = null;
  };
}, []);

useEffect(() =&gt; {
  if (running &amp;&amp; musicEnabled &amp;&amp; bgMusicRef.current?.paused) {
    bgMusicRef.current.currentTime = 0;
    bgMusicRef.current.play().catch((e) =&gt; console.log("Music play failed:", e));
  } else if (!running || !musicEnabled) {
    bgMusicRef.current?.pause();
  }
}, [running, musicEnabled]);
</code></pre>
<p>The <code>.catch()</code> on <code>.play()</code> is essential. Browsers block autoplay audio until the user has interacted with the page and without it, you'd get uncaught promise rejections on every game start.</p>
<h3 id="heading-game-loop">Game Loop</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  if (running) {
    const interval = Math.max(200, 1000 - (level - 1) * 100);
    tickRef.current = window.setInterval(() =&gt; {
      if (current) move(0, 1);
    }, interval);
    return () =&gt; { if (tickRef.current) clearInterval(tickRef.current); };
  } else {
    if (tickRef.current) clearInterval(tickRef.current);
  }
}, [running, current, level]);
</code></pre>
<p>The gravity speed formula produces this curve:</p>
<pre><code class="language-plaintext">Level 1:  1000ms per drop  (relaxed)
Level 5:   600ms per drop
Level 10:  200ms per drop  (capped, very fast)
</code></pre>
<p>The dependency array includes <code>current</code> because the interval's callback closes over it. When a new piece spawns, <code>current</code> changes, the effect re-runs, and the interval restarts with the correct piece reference. Without this, the interval would hold a stale closure and pieces would behave incorrectly.</p>
<h3 id="heading-keyboard-controls">Keyboard Controls</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const handler = (e: KeyboardEvent) =&gt; {
    if (!running) return;
    if (e.key === "ArrowLeft")  { e.preventDefault(); move(-1, 0, "L"); }
    if (e.key === "ArrowRight") { e.preventDefault(); move(1, 0, "R"); }
    if (e.key === "ArrowDown")  { e.preventDefault(); move(0, 1, "D"); }
    if (e.key === " " || e.key === "ArrowUp") {
      e.preventDefault();
      rotateCurrent("ROT");
    }
  };
  window.addEventListener("keydown", handler);
  return () =&gt; window.removeEventListener("keydown", handler);
}, [running, current]);
</code></pre>
<p><code>e.preventDefault()</code> on arrow keys prevents the browser from scrolling the page while the game is active.</p>
<h3 id="heading-piece-spawning">Piece Spawning</h3>
<pre><code class="language-typescript">function spawnNext(boardParam?: number[][]) {
  const currentBoard = boardParam ?? board;
  const type = next;
  const shape = PIECES[type].map((r) =&gt; [...r]);  // Deep copy
  const x = Math.floor((WIDTH - shape[0].length) / 2);
  const y = 0;

  if (!canPlace(currentBoard, shape, x, y)) {
    finish();  // Top of board is blocked — game over
    return;
  }

  setCurrent({ type, shape, x, y });
  setNext(PIECE_TYPES[Math.floor(Math.random() * PIECE_TYPES.length)]);
}
</code></pre>
<p>The <code>boardParam</code> parameter exists because of a React state timing issue. After clearing lines, <code>setBoard(newBoard)</code> schedules a state update, but the next render hasn't happened yet. If you called <code>spawnNext()</code> without the parameter, it would read the stale board state and spawn the piece on the old board. Passing <code>newBoard</code> directly bypasses this:</p>
<pre><code class="language-typescript">setTimeout(() =&gt; {
  setBoard(newBoard);
  setCurrent(null);
  setClearingRows([]);
  setTimeout(() =&gt; spawnNext(newBoard), 50);  // Pass new board explicitly
}, 300);
</code></pre>
<h3 id="heading-merging-piece-with-board">Merging Piece with Board</h3>
<pre><code class="language-typescript">function mergeCurrentToBoard(brd: number[][], cur: any) {
  const copy = brd.map((r) =&gt; [...r]);
  if (!cur) return copy;

  for (let r = 0; r &lt; cur.shape.length; r++) {
    for (let c = 0; c &lt; cur.shape[0].length; c++) {
      if (cur.shape[r][c]) {
        const rr = cur.y + r;
        const cc = cur.x + c;
        if (rr &gt;= 0 &amp;&amp; rr &lt; HEIGHT &amp;&amp; cc &gt;= 0 &amp;&amp; cc &lt; WIDTH) {
          copy[rr][cc] = PIECE_TYPES.indexOf(cur.type) + 10;
        }
      }
    }
  }
  return copy;
}
</code></pre>
<p>The <code>+10</code> offset encodes falling pieces differently from locked pieces:</p>
<pre><code class="language-plaintext">0      = empty cell
1-7    = locked piece (I=1, O=2, T=3, ...)
10-16  = currently falling piece (I=10, O=11, T=12, ...)
</code></pre>
<p>This lets the renderer apply different styles to falling vs. locked pieces, so you could add opacity, glow, or borders to the active piece without touching locked cells.</p>
<h3 id="heading-line-clearing">Line Clearing</h3>
<pre><code class="language-typescript">function clearLines(brd: number[][]) {
  let cleared = 0;
  const out: number[][] = [];

  for (let r = 0; r &lt; HEIGHT; r++) {
    if (brd[r].every((v) =&gt; v !== 0)) {
      cleared++;
    } else {
      out.push(brd[r]);
    }
  }

  while (out.length &lt; HEIGHT)
    out.unshift(Array.from({ length: WIDTH }, () =&gt; 0));

  return { board: out, cleared };
}
</code></pre>
<p>Full rows are filtered out, then empty rows are added at the top with <code>unshift</code> (not <code>push</code>), because gravity pulls pieces down, so new empty space must appear at the top.</p>
<h3 id="heading-the-movement-function">The Movement Function</h3>
<p>This is where collision detection, locking, line clearing, and spawning all connect:</p>
<pre><code class="language-typescript">function move(dx: number, dy: number, actionCode?: string) {
  if (!current) return;

  const nx = current.x + dx;
  const ny = current.y + dy;

  if (canPlace(board, current.shape, nx, ny)) {
    setCurrent({ ...current, x: nx, y: ny });
    if (actionCode) actionsRef.current.push({ t: Date.now(), a: actionCode });
  } else if (dy === 1) {
    // Downward move failed — piece has landed
    const merged = mergeCurrentToBoard(board, current);
    const normalized = merged.map((r) =&gt;
      r.map((v) =&gt; (v &gt;= 10 ? v - 9 : v))  // Convert falling values to locked
    );

    const { board: newBoard, cleared } = clearLines(normalized);

    if (cleared &gt; 0) {
      // Play sound
      if (clearSoundRef.current &amp;&amp; musicEnabled) {
        clearSoundRef.current.currentTime = 0;
        clearSoundRef.current.play().catch(console.log);
      }

      // Identify which rows flash
      const clearingRowIndices: number[] = [];
      for (let r = 0; r &lt; HEIGHT; r++) {
        if (normalized[r].every((v) =&gt; v !== 0)) clearingRowIndices.push(r);
      }
      setClearingRows(clearingRowIndices);

      // Update score and level
      setScore((s) =&gt; s + cleared * 100);
      setLines((prev) =&gt; {
        const newLines = prev + cleared;
        setLevel(Math.floor(newLines / 10) + 1);
        return newLines;
      });

      // Animate, then update board
      setTimeout(() =&gt; {
        setBoard(newBoard);
        setCurrent(null);
        setClearingRows([]);
        setTimeout(() =&gt; spawnNext(newBoard), 50);
      }, 300);
    } else {
      setBoard(newBoard);
      setCurrent(null);
      setTimeout(() =&gt; spawnNext(newBoard), 50);
    }
  }
  // Horizontal collision: do nothing (piece stays in place)
}
</code></pre>
<p>Only <code>dy === 1</code> failures trigger locking. A failed left or right move simply stops the piece; it doesn't land. The 300ms animation window gives players visual feedback before cleared rows disappear.</p>
<h3 id="heading-rotation">Rotation</h3>
<pre><code class="language-typescript">function rotateCurrent(actionCode?: string) {
  if (!current) return;
  const newShape = rotate(current.shape);
  if (canPlace(board, newShape, current.x, current.y)) {
    setCurrent({ ...current, shape: newShape });
    if (actionCode) actionsRef.current.push({ t: Date.now(), a: actionCode });
  }
}
</code></pre>
<p>If the rotated shape doesn't fit at the current position, nothing happens. A full implementation would add wall kicks (trying x±1, y-1 offsets before giving up), but this simplified version covers the vast majority of cases.</p>
<h3 id="heading-game-start-and-finish">Game Start and Finish</h3>
<pre><code class="language-typescript">async function start() {
  const b = emptyBoard();
  setBoard(b);
  setScore(0); setLines(0); setLevel(1);
  actionsRef.current = [];
  actionsRef.current.push({ t: Date.now(), a: "START" });

  setRunning(true);
  startTimeRef.current = Date.now();

  // Create game record via MCP tool (non-blocking)
  (async () =&gt; {
    try {
      const toolRes = await callTool?.("start_game", {});
      const gameIdToUse = (toolRes as any)?.structuredContent?.gameId;
      if (gameIdToUse) setGameId(gameIdToUse);
    } catch (err) {
      console.error("Failed to create game record:", err);
    }
  })();

  setTimeout(() =&gt; spawnNext(b), 10);
}
</code></pre>
<p>The MCP tool call is wrapped in a self-invoking async function so it doesn't block the game from starting. The board resets and the first piece spawns immediately; the game ID arrives asynchronously and is stored for use when the game ends.</p>
<pre><code class="language-typescript">async function finish() {
  setRunning(false);

  const durationMs = startTimeRef.current
    ? Date.now() - startTimeRef.current
    : undefined;
  const replayActions = actionsRef.current.slice();

  if (gameId &amp;&amp; callTool) {
    try {
      const result = await callTool("finish_game", {
        gameId, score, level,
        linesCleared: lines,
        replayActions,
        durationMs,
      });

      const message = (result as any)?.content?.[0]?.text
        || `Game finished! Score: \({score}, Level: \){level}, Lines: ${lines}`;

      await sendMessage?.(message);
    } catch (err) {
      // Graceful fallback — still show results even if save fails
      await sendMessage?.(
        `Game finished locally — Score: \({score}, Level: \){level} ` +
        `(Could not save: ${err instanceof Error ? err.message : String(err)})`
      );
    }
  } else {
    await sendMessage?.(
      `Game finished locally — Score: \({score}, Level: \){level} (No game ID)`
    );
  }

  // Reset all state
  setBoard(emptyBoard());
  setCurrent(null);
  setScore(0); setLines(0); setLevel(1);
  actionsRef.current = [];
  setGameId(null);
  startTimeRef.current = null;
}
</code></pre>
<p>The graceful degradation pattern is important: the game works even if the backend is unreachable. Players always see their score, and saving is a best-effort operation.</p>
<h3 id="heading-board-rendering">Board Rendering</h3>
<pre><code class="language-typescript">const display = mergeCurrentToBoard(board, current);
const cellPx = Math.max(18, Math.min(32, Math.floor(360 / WIDTH)));

function getCellColor(value: number): string {
  if (value === 0) return "#0f172a";
  const typeIndex = value &gt;= 10 ? value - 10 : value - 1;
  return PIECE_COLORS[PIECE_TYPES[typeIndex]];
}

return (
  &lt;div className="grid" style={{ gridTemplateColumns: `repeat(\({WIDTH}, \){cellPx}px)` }}&gt;
    {display.flatMap((row, r) =&gt;
      row.map((cell, c) =&gt; {
        const isClearing = clearingRows.includes(r);
        return (
          &lt;div
            key={`\({r}-\){c}`}
            style={{
              width: cellPx, height: cellPx,
              background: getCellColor(cell),
              border: "1px solid rgba(100,116,139,0.3)",
              opacity: isClearing ? 0.5 : 1,
              transform: isClearing ? "scale(1.05)" : "scale(1)",
              transition: "all 0.2s ease-in-out",
            }}
          /&gt;
        );
      })
    )}
  &lt;/div&gt;
);
</code></pre>
<p>Cell size is clamped between 18px (readable on mobile) and 32px (comfortable on desktop), fitting a 360px container. The clearing animation fades rows to 50% opacity and scales them slightly larger, a subtle pulse effect before they disappear.</p>
<h3 id="heading-control-buttons">Control Buttons</h3>
<pre><code class="language-typescript">&lt;div className="mt-3 flex gap-2"&gt;
  &lt;button onClick={start}&gt;Start&lt;/button&gt;
  &lt;button onClick={() =&gt; setRunning((s) =&gt; !s)}&gt;
    {running ? "Pause" : "Resume"}
  &lt;/button&gt;
  &lt;button onClick={() =&gt; rotateCurrent()}&gt;Rotate&lt;/button&gt;
  &lt;button onClick={() =&gt; move(0, 1, "D")}&gt;Drop&lt;/button&gt;
  &lt;button onClick={() =&gt; finish()}&gt;End&lt;/button&gt;
&lt;/div&gt;
</code></pre>
<p>These mirror the keyboard controls exactly, making the game fully playable on touch devices inside ChatGPT's iframe.</p>
<h3 id="heading-replay-recording">Replay Recording</h3>
<p>Throughout the component, every player action is stamped and stored:</p>
<pre><code class="language-typescript">actionsRef.current.push({ t: Date.now(), a: actionCode });
</code></pre>
<p>A typical game produces a few hundred actions totaling less than 20KB of JSON. Because a ref is used instead of state, recording has zero rendering overhead. When the game ends, <code>actionsRef.current.slice()</code> takes a snapshot of the array and passes it to <code>finish_game</code>, where Convex stores it alongside the final score.</p>
<h2 id="heading-implementing-kinde-oauth-authentication">Implementing Kinde OAuth Authentication</h2>
<p>Authentication in ChatGPT apps works differently from traditional web apps. ChatGPT is the OAuth client: it handles the redirect, code exchange, and token storage. Your app is the resource server. You receive tokens, validate them, and map OAuth identities to your database users.</p>
<h3 id="heading-oauth-architecture-overview">OAuth Architecture Overview</h3>
<pre><code class="language-plaintext">Layer 1: OAuth Discovery
  /.well-known/oauth-protected-resource
  -&gt; Tells ChatGPT where to authenticate

Layer 2: Token Extraction and Validation
  extractTokenFromArgs() -&gt; Find token in MCP context
  validateKindeToken()   -&gt; Verify signature with JWKS
  getKindeUserProfile()  -&gt; Fetch user details from Kinde

Layer 3: User Mapping
  requireAuthForTool()   -&gt; Protect MCP tools
  upsertLinkedAccount()  -&gt; Create/update Convex user
</code></pre>
<h3 id="heading-oauth-discovery-endpoint">OAuth Discovery Endpoint</h3>
<p>You created this in Section 3. Here is the full implementation with proper fallbacks:</p>
<pre><code class="language-typescript">// app/mcp/.well-known/oauth-protected-resource/route.ts
import { NextResponse } from 'next/server';

const MCP_SERVER_URL =
  process.env.MCP_AUDIENCE ||
  process.env.MCP_SERVER_URL ||
  `https://${process.env.VERCEL_URL || 'localhost'}`;

const DEFAULT_KINDE_ISSUER = 'https://devrelstudio.kinde.com';
const KINDE_ISSUER_URL =
  process.env.KINDE_ISSUER_URL ||
  process.env.KINDE_ISSUER ||
  DEFAULT_KINDE_ISSUER;

export async function GET() {
  const authServers = [KINDE_ISSUER_URL];
  console.log('oauth-protected-resource using authorization_servers:', authServers);

  return NextResponse.json({
    resource: MCP_SERVER_URL,
    authorization_servers: authServers,
    scopes_supported: ['openid', 'profile', 'email'],
    bearer_methods_supported: ['header'],
    resource_documentation: `${MCP_SERVER_URL}/docs`,
  });
}
</code></pre>
<p>When ChatGPT encounters a tool requiring authentication, it GETs this endpoint, reads <code>authorization_servers</code>, and redirects the user to Kinde. Without this endpoint, ChatGPT cannot discover your OAuth configuration and the entire auth flow breaks silently.</p>
<h3 id="heading-token-validation">Token Validation</h3>
<p>Update <code>app/lib/kinde.ts</code> with production-ready validation:</p>
<pre><code class="language-typescript">import { createRemoteJWKSet, jwtVerify } from 'jose';

const KINDE_ISSUER_URL = process.env.KINDE_ISSUER_URL || process.env.KINDE_ISSUER;
const MCP_AUDIENCE =
  process.env.MCP_AUDIENCE ||
  process.env.MCP_SERVER_URL ||
  process.env.NEXT_PUBLIC_MCP_AUDIENCE;

// getJwks is extracted into its own function so that createRemoteJWKSet is
// called once and reused across requests. createRemoteJWKSet handles HTTP
// caching internally, meaning Kinde's public keys are only fetched when the
// cache expires, not on every token validation. Creating a new instance per
// request would bypass this and add unnecessary latency.
function getJwks() {
  if (!KINDE_ISSUER_URL) {
    throw new Error('KINDE_ISSUER_URL (or KINDE_ISSUER) environment variable is not set');
  }
  return createRemoteJWKSet(new URL(`${KINDE_ISSUER_URL}/.well-known/jwks`));
}

export async function validateKindeToken(token: string) {
  if (!token) throw new Error('No token provided');
  if (!KINDE_ISSUER_URL) throw new Error('KINDE_ISSUER_URL not configured');
  if (!MCP_AUDIENCE) throw new Error('MCP_AUDIENCE (or MCP_SERVER_URL) not configured');

  const JWKS = getJwks();

  // jwtVerify does several things in one call:
  // 1. Decodes the JWT header and payload
  // 2. Fetches Kinde's public keys from the JWKS endpoint (using cached keys when available)
  // 3. Finds the matching key using the token's `kid` header field
  // 4. Verifies the cryptographic signature
  // 5. Checks that `iss` matches your Kinde domain and `aud` matches your MCP URL
  //
  // If the token was tampered with, expired, or issued by a different service,
  // jwtVerify throws and your handler never runs.
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: KINDE_ISSUER_URL,
    audience: MCP_AUDIENCE,
  } as any);

  return payload as Record&lt;string, any&gt;;
}

export async function getKindeUserProfile(token: string) {
  if (!token) throw new Error('No token provided');
  if (!KINDE_ISSUER_URL) throw new Error('KINDE_ISSUER_URL not configured');

  const url = `${KINDE_ISSUER_URL}/oauth2/v2/user_profile`;
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
  });

  if (!res.ok) {
    const txt = await res.text();
    throw new Error(`Failed to fetch user profile: \({res.status} \){txt}`);
  }

  return (await res.json()) as Record&lt;string, any&gt;;
}
</code></pre>
<p>The JWKS endpoint (<code>/.well-known/jwks</code>) returns Kinde's current public keys:</p>
<pre><code class="language-json">{
  "keys": [
    { "kty": "RSA", "use": "sig", "kid": "abc123", "n": "0vx7...", "e": "AQAB" }
  ]
}
</code></pre>
<p>Kinde signs tokens with its private key and you verify with the matching public key. Even if an attacker intercepts a token, they cannot forge new ones without the private key.</p>
<h3 id="heading-token-extraction-from-mcp-context">Token Extraction from MCP Context</h3>
<p>Before looking at the code, it is worth understanding why token extraction needs this much logic. The MCP protocol does not mandate a single canonical location for the Bearer token. Depending on the ChatGPT version, MCP transport (HTTP vs SSE), and how the SDK processes the request, the token can arrive in several different places: nested in the MCP context object under <code>requestInfo.headers</code>, on a raw <code>Request</code> object, flattened directly on <code>context.headers</code>, or not forwarded into the context at all because it was pre-registered against a request ID in an earlier middleware step.</p>
<p>The function below tries every known location in priority order, from most reliable to least, so your tool handlers work regardless of exactly how the token arrives.</p>
<p>Create <code>app/lib/mcpAuth.ts</code>:</p>
<pre><code class="language-typescript">import { getLastAuthToken } from './mcpRequestState';

export async function extractTokenFromArgs(args: any, context?: any) {
  // Strategy 1: context.requestInfo.headers
  // The most common location in MCP v1.0+ over HTTP. The header name may be
  // lowercase or Title-Case depending on which HTTP layer normalized it.
  if (context?.requestInfo?.headers) {
    const authHeader =
      context.requestInfo.headers.authorization ||
      context.requestInfo.headers.Authorization;
    if (authHeader?.startsWith('Bearer ')) {
      return authHeader.substring(7);
    }
  }

  // Strategy 2: context.request.headers
  // Used when the MCP handler passes a raw Fetch API Request object through
  // the context. Headers here are accessed with .get(), not dot notation.
  if (context?.request?.headers) {
    const authHeader =
      context.request.headers.get?.('Authorization') ||
      context.request.headers.get?.('authorization');
    if (authHeader?.startsWith('Bearer ')) return authHeader.substring(7);
  }

  // Strategy 3: context.headers directly
  // Some MCP transports flatten headers onto the context object itself.
  // We try both the Fetch API .get() method and plain property access to
  // cover both cases.
  if (context?.headers) {
    const authHeader =
      context.headers.get?.('Authorization') ||
      context.headers.get?.('authorization') ||
      context.headers.Authorization ||
      context.headers.authorization;
    if (authHeader &amp;&amp; typeof authHeader === 'string' &amp;&amp; authHeader.startsWith('Bearer ')) {
      return authHeader.substring(7);
    }
  }

  // Strategy 4: Request ID mapping
  // In some SSE-based transports, the Authorization header cannot be forwarded
  // through the MCP context. The POST handler middleware (see the POST export in
  // mcp/route.ts) pre-registers the token against every request ID found in the
  // body. Here we check whether any of the IDs in this context have a match.
  const possibleIds = [
    context?.requestId,
    context?.requestInfo?.requestId,
    context?.requestInfo?.id,
    context?.id,
    context?.sessionId,
    context?.requestInfo?.sessionId,
  ].filter(Boolean);

  for (const id of possibleIds) {
    const token = (await import('./mcpRequestMap')).getTokenForRequestId(id);
    if (token) return token;
  }

  // Strategy 5: Last known token (single-user fallback)
  // If all strategies above fail, return the most recently seen token for this
  // server process. This is safe in local development where only one user is
  // active, but should NOT be relied upon in a multi-user production deployment
  // where requests can interleave.
  const last = getLastAuthToken();
  if (last) return last;

  return null;
}
</code></pre>
<p>The two supporting files that Strategies 4 and 5 depend on are in-memory maps that live for the duration of the server process:</p>
<pre><code class="language-typescript">// app/lib/mcpRequestMap.ts
// Maps MCP request IDs to the Bearer token that arrived with them.
// Entries are written by the POST handler middleware before the MCP handler
// runs, ensuring the token is findable even after the original Request object
// is gone.
const tokenMap = new Map&lt;string, string&gt;();

export function setTokenForRequestId(id: string, token: string) {
  tokenMap.set(id, token);
}

export function getTokenForRequestId(id: string): string | null {
  return tokenMap.get(id) ?? null;
}

export function clearTokenForRequestId(id: string) {
  tokenMap.delete(id);
}

export function clearAll() {
  tokenMap.clear();
}
</code></pre>
<pre><code class="language-typescript">// app/lib/mcpRequestState.ts
// Tracks the most recently seen token as a last-resort fallback.
// Only appropriate for single-user or development scenarios.
let lastAuthToken: string | null = null;

export function setLastAuthToken(token: string | null) {
  lastAuthToken = token;
}

export function getLastAuthToken() {
  return lastAuthToken;
}
</code></pre>
<h3 id="heading-requiring-auth-in-mcp-tools">Requiring Auth in MCP Tools</h3>
<p>With token extraction handled, you can now write <code>requireAuthForTool</code>: the single function you call at the top of any protected tool handler. It extracts the token, validates it against Kinde's JWKS endpoint, and returns either the authenticated user's profile or a structured MCP error response that ChatGPT knows how to act on.</p>
<pre><code class="language-typescript">// app/lib/mcpAuth.ts (continued)
import { validateKindeToken, getKindeUserProfile } from './kinde';

const MCP_SERVER_URL =
  process.env.MCP_SERVER_URL ||
  process.env.MCP_AUDIENCE ||
  `https://${process.env.VERCEL_URL || 'localhost'}`;

// Builds the WWW-Authenticate challenge value pointing to your OAuth discovery
// endpoint. ChatGPT reads this value and uses it to initiate the Kinde login
// flow for the user.
export function makeAuthenticateMeta(message = 'Sign in required') {
  return [
    `Bearer resource_metadata="${MCP_SERVER_URL}/mcp/.well-known/oauth-protected-resource", ` +
    `error="insufficient_scope", error_description="${message}"`,
  ];
}

export async function requireAuthForTool(args: any, context?: any) {
  const token = await extractTokenFromArgs(args, context);

  if (!token) {
    // No token found anywhere. Return an MCP error with the WWW-Authenticate
    // challenge. ChatGPT reads the mcp/www_authenticate metadata field, fetches
    // your discovery endpoint, and redirects the user to Kinde to sign in.
    return {
      isError: true,
      content: [{ type: 'text', text: 'Please sign in to continue.' }],
      _meta: { 'mcp/www_authenticate': makeAuthenticateMeta() },
    };
  }

  try {
    const payload = await validateKindeToken(token).catch(() =&gt; null);
    const profile = await getKindeUserProfile(token).catch(() =&gt; null);

    if (!payload) {
      // Token arrived but failed cryptographic validation. Likely expired or tampered.
      return {
        isError: true,
        content: [{ type: 'text', text: 'Invalid token. Please sign in again.' }],
        _meta: { 'mcp/www_authenticate': makeAuthenticateMeta('Invalid token') },
      };
    }

    // Both checks passed. Return the validated identity to the caller.
    return { ok: true, token, profile, payload };
  } catch (err: any) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Authentication failed. Please sign in again.' }],
      _meta: { 'mcp/www_authenticate': makeAuthenticateMeta('Authentication failed') },
    };
  }
}
</code></pre>
<p>The <code>_meta['mcp/www_authenticate']</code> field follows RFC 6750 (Bearer Token Usage). When ChatGPT receives a tool response containing this field, it treats the response as an authentication challenge: it fetches your discovery endpoint, reads <code>authorization_servers</code>, redirects the user to Kinde, and re-calls the tool with the resulting token.</p>
<p>Your tool handler never needs to know whether a call was an initial attempt or a post-authentication retry. It calls <code>requireAuthForTool</code> at the top and proceeds if <code>ok</code> is true:</p>
<pre><code class="language-typescript">async (args, context) =&gt; {
  const auth = await requireAuthForTool(args, context);

  if ((auth as any).isError) {
    return auth; // Returns the auth challenge to ChatGPT
  }

  const { profile, payload } = auth as any;
  // User is authenticated — proceed with tool logic
}
</code></pre>
<h3 id="heading-linking-oauth-identities-to-convex-users">Linking OAuth Identities to Convex Users</h3>
<p>When a user authenticates, you map their Kinde identity to a Convex user. The <code>upsertLinkedAccount</code> mutation from Section 4 handles this. In your MCP tool handlers, call it like this:</p>
<pre><code class="language-typescript">const linkedUserId = await callConvexMutation(
  api.users.upsertLinkedAccount,
  {
    provider: 'kinde',
    providerUserId: String(payload.sub),  // e.g. "kinde|2151678548"
    email: profile?.email,
    displayName:
      `\({profile?.given_name || ''} \){profile?.family_name || ''}`.trim() ||
      profile?.email ||
      'Anonymous',
    avatarUrl: profile?.picture,
  }
);

const userId = String(linkedUserId);
</code></pre>
<p>The flow is: Kinde JWT arrives with <code>sub: "kinde|2151678548"</code> -&gt; check <code>linkedAccounts</code> for that provider/subject pair -&gt; if found, return the existing <code>userId</code> and update <code>lastSeenAt</code> -&gt; if not found, create a new user and linked account -&gt; return the new <code>userId</code>.</p>
<p>This means a player authenticating for the first time gets a new Convex user created automatically. The same player returning gets their existing account, preserving all their scores and replays.</p>
<h3 id="heading-token-extraction-in-the-post-handler">Token Extraction in the POST Handler</h3>
<p>The strategies in <code>extractTokenFromArgs</code> handle finding a token once the MCP handler is already running. But some transports consume the request body before the handler sees it, meaning the token in the <code>Authorization</code> header has no corresponding context to land in. This middleware solves that by reading the token and the request IDs from the raw body before the handler runs, storing each pairing in <code>mcpRequestMap</code> so Strategy 4 can find them later.</p>
<p>Add this to <code>mcp/route.ts</code> before the handler export:</p>
<pre><code class="language-typescript">export async function POST(req: Request) {
  let clonedReq = req;

  try {
    const authHeader =
      req.headers.get('Authorization') || req.headers.get('authorization');

    if (authHeader &amp;&amp; typeof authHeader === 'string' &amp;&amp; authHeader.startsWith('Bearer ')) {
      const token = authHeader.substring(7);
      const bodyText = await req.text();

      if (bodyText) {
        let parsed: any = null;
        try {
          parsed = JSON.parse(bodyText);
        } catch (e) {
          // Body is not JSON — skip ID collection
        }

        const idsToCheck: string[] = [];

        // collectIds walks the parsed body recursively because request IDs can
        // appear at different nesting depths depending on the MCP transport and
        // the JSON-RPC batch format. A shallow check would miss IDs nested inside
        // params or method objects.
        function collectIds(obj: any) {
          if (!obj || typeof obj !== 'object') return;
          for (const k of Object.keys(obj)) {
            if (k === 'requestId' || k === 'sessionId' || k === 'request_id' || k === 'id') {
              const v = obj[k];
              if (typeof v === 'string') idsToCheck.push(v);
            } else if (typeof obj[k] === 'object') {
              collectIds(obj[k]);
            }
          }
        }

        collectIds(parsed);

        for (const id of idsToCheck) {
          setTokenForRequestId(id, token);
        }

        // The request body can only be read once. Clone the request with the
        // already-read body text so the MCP handler can read it again normally.
        clonedReq = new Request(req.url, {
          method: req.method,
          headers: req.headers,
          body: bodyText,
        });
      }
    }

    return await handler(clonedReq);
  } catch (error) {
    throw error;
  }
}
</code></pre>
<h3 id="heading-validation-endpoint-for-debugging">Validation Endpoint for Debugging</h3>
<p>Create <code>app/api/mcp/validate-token/route.ts</code> to test token validation manually during development:</p>
<pre><code class="language-typescript">import { NextResponse } from 'next/server';
import { validateKindeToken, getKindeUserProfile } from '@/app/lib/kinde';

export async function POST(req: Request) {
  try {
    const authHeader = req.headers.get('authorization') || '';
    const tokenFromHeader = authHeader.startsWith('Bearer ')
      ? authHeader.replace('Bearer ', '')
      : undefined;
    const body = await req.json().catch(() =&gt; ({}));
    const token = tokenFromHeader || body.token;

    if (!token) {
      return NextResponse.json({ error: 'No token provided' }, { status: 401 });
    }

    const payload = await validateKindeToken(token);

    let profile = null;
    try {
      profile = await getKindeUserProfile(token);
    } catch (e) {
      // Non-fatal. The payload alone is enough to confirm the token is valid.
    }

    return NextResponse.json({ ok: true, payload, profile });
  } catch (err: any) {
    return NextResponse.json({ error: err?.message ?? String(err) }, { status: 401 });
  }
}
</code></pre>
<p>Test it with a token obtained from Kinde's OAuth Playground or a manual authorization flow:</p>
<pre><code class="language-shell">curl -X POST http://localhost:3000/api/mcp/validate-token \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
  -H "Content-Type: application/json"

# Success:
# { "ok": true, "payload": { "sub": "kinde|...", "email": "..." }, "profile": {...} }

# Expired token:
# { "error": "Token has expired" }
</code></pre>
<h3 id="heading-security-checklist">Security Checklist</h3>
<p>Before shipping, verify these practices are in place:</p>
<ol>
<li><p><strong>Server-side validation only.</strong> Never trust user-provided identity claims. Always call <code>validateKindeToken</code> on the server.</p>
</li>
<li><p><strong>Verify both issuer and audience.</strong> Without audience checking, a token issued for a different app would pass validation.</p>
</li>
<li><p><strong>JWKS caching.</strong> <code>createRemoteJWKSet</code> handles HTTP caching internally. Do not create a new instance per request.</p>
</li>
<li><p><strong>Fail fast on missing config.</strong> Throw at startup if <code>KINDE_ISSUER_URL</code> or <code>MCP_AUDIENCE</code> are missing rather than failing silently on the first real request.</p>
</li>
<li><p><strong>Graceful error responses.</strong> Return <code>isError: true</code> with a user-friendly message rather than exposing stack traces or token details.</p>
</li>
</ol>
<h2 id="heading-building-the-mcp-integration">Building the MCP Integration</h2>
<p>The MCP route is the core of your ChatGPT integration. It registers tools, handles authentication, calls Convex, and returns widgets. Everything from Sections 3 through 6 comes together here.</p>
<h3 id="heading-convex-http-client">Convex HTTP Client</h3>
<p>MCP tool handlers run in Next.js server context, not React. You cannot use <code>useQuery</code> or <code>useMutation</code>. Instead, use the Convex HTTP client directly. Create <code>app/lib/convex.ts</code>:</p>
<pre><code class="language-typescript">import { ConvexHttpClient } from "convex/browser";

// Singleton: ConvexHttpClient maintains a connection pool internally.
// Creating a new instance per request wastes those connections and
// adds latency to cold starts. One client shared across all requests
// is the correct pattern.
let client: ConvexHttpClient | null = null;

export function getConvexClient() {
  if (!client) {
    const url = process.env.NEXT_PUBLIC_CONVEX_URL;
    if (!url) throw new Error("NEXT_PUBLIC_CONVEX_URL is not set");
    client = new ConvexHttpClient(url);
  }
  return client;
}

export async function callConvexMutation&lt;T&gt;(
  fn: any,
  args: Record&lt;string, any&gt;
): Promise&lt;T&gt; {
  return getConvexClient().mutation(fn, args) as Promise&lt;T&gt;;
}

export async function callConvexQuery&lt;T&gt;(
  fn: any,
  args: Record&lt;string, any&gt;
): Promise&lt;T&gt; {
  return getConvexClient().query(fn, args) as Promise&lt;T&gt;;
}
</code></pre>
<h3 id="heading-widget-html-generation">Widget HTML Generation</h3>
<p>Before registering tools, you need a helper to render widget HTML. The imports below pull in everything the MCP route depends on. <code>zodToJsonSchema</code> is included here because the MCP SDK expects tool input schemas in JSON Schema format, not Zod format. <code>zodToJsonSchema</code> converts your Zod definitions at registration time so you get Zod's type safety when writing schemas and valid JSON Schema in the manifest ChatGPT reads.</p>
<pre><code class="language-typescript">import {
  createMcpHandler,
  experimental_withMcpAuth,
  getAppsSdkCompatibleHtml,
} from "mcp-handler";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
import { callConvexMutation, callConvexQuery } from "@/app/lib/convex";
import {
  extractTokenFromArgs,
  requireAuthForTool,
  setTokenForRequestId,
} from "@/app/lib/mcpAuth";
import { setLastAuthToken } from "@/app/lib/mcpRequestState";
import { baseURL } from "@/lib/baseURL";

// getAppsSdkCompatibleHtml wraps a URL in the HTML structure ChatGPT expects
// for interactive widgets. It sets the MIME type to text/html+skybridge, which
// tells ChatGPT to render the response as a sandboxed iframe rather than
// displaying it as plain text.
function makeWidgetHtml(path: string, params: Record&lt;string, string&gt; = {}) {
  const url = new URL(path, baseURL);
  Object.entries(params).forEach(([k, v]) =&gt; url.searchParams.set(k, v));
  return getAppsSdkCompatibleHtml(url.toString());
}
</code></pre>
<h3 id="heading-mcp-server-setup">MCP Server Setup</h3>
<pre><code class="language-typescript">const server = new Server(
  { name: "tetris-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);
</code></pre>
<h3 id="heading-tool-registration-startgame">Tool Registration: <code>start_game</code></h3>
<pre><code class="language-typescript">server.registerTool(
  "start_game",
  {
    description:
      "Start a new Tetris game. Returns an interactive widget for the user to play. " +
      "Authentication is optional — anonymous users can play but won't appear on the leaderboard.",
    inputSchema: zodToJsonSchema(
      z.object({
        public: z.boolean().optional()
          .describe("Whether to show this game on the leaderboard. Default false."),
        seed: z.number().optional()
          .describe("Random seed for reproducible piece sequences."),
      })
    ),
    // Two security schemes implement OR logic: ChatGPT calls this tool with
    // whatever auth state it currently has. If the user is signed in, the token
    // arrives and we link a Convex user. If not, the game starts anonymously.
    // A single scheme would make authentication required and block anonymous play.
    securitySchemes: [
      { type: "noauth" },
      { type: "oauth2", scopes: ["openid", "profile", "email"] },
    ],
  },
  async (args: any, context?: any) =&gt; {
    let userId: string | undefined;

    // Attempt auth but don't require it
    const token = await extractTokenFromArgs(args, context);
    if (token) {
      try {
        const authResult = await requireAuthForTool(args, context);
        if (!(authResult as any).isError) {
          const { profile, payload } = authResult as any;
          const linkedId = await callConvexMutation(
            api.users.upsertLinkedAccount,
            {
              provider: "kinde",
              providerUserId: String(payload.sub),
              email: profile?.email,
              displayName:
                `\({profile?.given_name || ""} \){profile?.family_name || ""}`.trim() ||
                profile?.email ||
                "Anonymous",
              avatarUrl: profile?.picture,
            }
          );
          userId = String(linkedId);
        }
      } catch (err) {
        console.error("[start_game] Auth error (proceeding anonymously):", err);
      }
    }

    const gameId = await callConvexMutation(api.games.createGame, {
      userId: userId ? (userId as Id&lt;"users"&gt;) : undefined,
      public: args.public ?? false,
      seed: args.seed,
    });

    const widget = makeWidgetHtml("/tetris/play", {
      gameId: String(gameId),
    });

    return {
      content: [{ type: "text", text: widget }],
      // structuredContent is returned alongside the widget HTML so that
      // GameBoard.tsx can extract the gameId from the callTool response
      // without parsing the HTML. See the start() function in GameBoard.tsx.
      structuredContent: { gameId: String(gameId) },
    };
  }
);
</code></pre>
<h3 id="heading-tool-registration-finishgame">Tool Registration: <code>finish_game</code></h3>
<pre><code class="language-typescript">server.registerTool(
  "finish_game",
  {
    description:
      "Record the final score for a completed Tetris game and save the replay. " +
      "Call this when the player's game ends.",
    inputSchema: zodToJsonSchema(
      z.object({
        gameId: z.string().describe("The game ID returned by start_game."),
        score: z.number().describe("Final score."),
        level: z.number().describe("Level reached."),
        linesCleared: z.number().describe("Total lines cleared."),
        replayActions: z
          .array(
            z.object({
              t: z.number().describe("Milliseconds since game start."),
              a: z.string().describe("Action code: L, R, D, ROT, HD, START."),
              d: z.any().optional(),
            })
          )
          .optional()
          .describe("Compact action log for replay playback."),
        durationMs: z.number().optional().describe("Total game duration in ms."),
      })
    ),
    securitySchemes: [
      { type: "noauth" },
      { type: "oauth2", scopes: ["openid", "profile", "email"] },
    ],
  },
  async (args: any, context?: any) =&gt; {
    try {
      const result = await callConvexMutation(api.games.finishGame, {
        gameId: args.gameId as Id&lt;"games"&gt;,
        // Number() coercion is defensive: ChatGPT's LLM occasionally serializes
        // numeric values as strings when constructing tool arguments. Coercing
        // explicitly here prevents type errors in the Convex mutation.
        score: Number(args.score),
        level: Number(args.level),
        linesCleared: Number(args.linesCleared),
        replayActions: args.replayActions ?? [],
        durationMs: args.durationMs ?? 0,
      });

      const score = Number(args.score);
      const lines = Number(args.linesCleared);
      const level = Number(args.level);

      const summary =
        `Game over! Final score: ${score.toLocaleString()} | ` +
        `Level: \({level} | Lines cleared: \){lines}. ` +
        (args.gameId ? `Replay saved (ID: ${String(result?.replayId ?? "").slice(0, 8)}...). ` : "") +
        (score &gt; 10000 ? "Excellent game!" : score &gt; 5000 ? "Nice run!" : "Keep practicing!");

      return {
        content: [{ type: "text", text: summary }],
        structuredContent: result,
      };
    } catch (err: any) {
      // Graceful fallback: always return a readable message rather than
      // letting the error surface as a raw exception in ChatGPT's UI.
      return {
        content: [
          {
            type: "text",
            text: `Score recorded locally: \({args.score}. (Save failed: \){err?.message ?? String(err)})`,
          },
        ],
      };
    }
  }
);
</code></pre>
<h3 id="heading-tool-registration-getleaderboard">Tool Registration: <code>get_leaderboard</code></h3>
<pre><code class="language-typescript">server.registerTool(
  "get_leaderboard",
  {
    description: "Get the current Tetris leaderboard widget showing top scores.",
    inputSchema: zodToJsonSchema(
      z.object({
        limit: z.number().optional()
          .describe("Number of entries to show. Default 10, max 25."),
      })
    ),
    // Read-only, public data. No auth needed and requiring it would add
    // unnecessary friction for a tool that reveals nothing sensitive.
    securitySchemes: [{ type: "noauth" }],
  },
  async (args: any, _context?: any) =&gt; {
    const limit = Math.min(Number(args.limit ?? 10), 25);

    try {
      const topScores = await callConvexQuery(api.leaderboards.getTop, { limit });

      const widget = makeWidgetHtml("/tetris/leaderboard", {
        limit: String(limit),
      });

      return {
        content: [{ type: "text", text: widget }],
        structuredContent: { topScores },
      };
    } catch (err: any) {
      return {
        content: [{ type: "text", text: `Failed to fetch leaderboard: ${err?.message}` }],
      };
    }
  }
);
</code></pre>
<h3 id="heading-tool-registration-viewreplay">Tool Registration: <code>view_replay</code></h3>
<pre><code class="language-typescript">server.registerTool(
  "view_replay",
  {
    description: "Watch a recorded Tetris game replay.",
    inputSchema: zodToJsonSchema(
      z.object({
        replayId: z.string().describe("The replay ID to watch."),
      })
    ),
    securitySchemes: [{ type: "noauth" }],
  },
  async (args: any, _context?: any) =&gt; {
    try {
      const replay = await callConvexQuery(api.replays.getReplay, {
        replayId: args.replayId as Id&lt;"replays"&gt;,
      });

      if (!replay) {
        return { content: [{ type: "text", text: "Replay not found." }] };
      }

      const widget = makeWidgetHtml("/tetris/replay", {
        replayId: args.replayId,
      });

      return {
        content: [{ type: "text", text: widget }],
        structuredContent: {
          replayId: args.replayId,
          score: replay.finalScore,
          level: replay.finalLevel,
          duration: replay.durationMs,
        },
      };
    } catch (err: any) {
      return {
        content: [{ type: "text", text: `Failed to load replay: ${err?.message}` }],
      };
    }
  }
);
</code></pre>
<h3 id="heading-wiring-up-the-handler-and-exports">Wiring Up the Handler and Exports</h3>
<p>With all four tools registered, the final step is exporting the route handler that Next.js calls for every incoming request. There is one problem to solve first: the <code>Authorization</code> header that ChatGPT sends with authenticated requests needs to reach your tool handlers, but by the time those handlers execute, the original <code>Request</code> object has already been consumed by the MCP SDK's request parsing. The header is gone.</p>
<p>The solution is a thin middleware layer inside the <code>POST</code> export. Before the request reaches the MCP handler, this middleware reads the <code>Authorization</code> header, walks the JSON body to find every ID field, and registers the token against each ID in <code>mcpRequestMap</code>. When <code>extractTokenFromArgs</code> runs inside your tool handler, Strategy 4 finds the token via the matching request ID.</p>
<pre><code class="language-typescript">const handler = createMcpHandler(server);

export async function POST(req: Request) {
  let clonedReq = req;

  try {
    const authHeader =
      req.headers.get("Authorization") || req.headers.get("authorization");

    if (authHeader?.startsWith("Bearer ")) {
      const token = authHeader.substring(7);

      // Store as the most recently seen token for the last-resort fallback
      // (Strategy 5 in extractTokenFromArgs)
      setLastAuthToken(token);

      // req.body is a ReadableStream that can only be consumed once.
      // We read it here, before the MCP handler sees it, so we can extract
      // request IDs. The request is then reconstructed below with the same
      // body text so the handler can read it normally.
      const bodyText = await req.text();

      if (bodyText) {
        let parsed: any = null;
        try { parsed = JSON.parse(bodyText); } catch (e) {}

        // Walk the parsed body recursively. Request IDs can appear at different
        // nesting depths depending on the MCP transport and JSON-RPC batch
        // format. A shallow check would miss IDs nested inside params objects.
        // We cast a wide net across all known ID field names because different
        // MCP versions use different conventions.
        function collectIds(obj: any) {
          if (!obj || typeof obj !== "object") return;
          for (const k of Object.keys(obj)) {
            if (["requestId", "sessionId", "request_id", "id"].includes(k)) {
              if (typeof obj[k] === "string") setTokenForRequestId(obj[k], token);
            } else if (typeof obj[k] === "object") {
              collectIds(obj[k]);
            }
          }
        }
        collectIds(parsed);

        // Reconstruct a fresh Request with the already-read body text.
        // Without this, the MCP handler receives a Request with an exhausted
        // body stream and fails to parse the incoming tool call.
        clonedReq = new Request(req.url, {
          method: req.method,
          headers: req.headers,
          body: bodyText,
        });
      }
    }

    return await handler(clonedReq);
  } catch (error) {
    throw error;
  }
}

// GET handles MCP capability discovery. When you register the connector in
// ChatGPT, it makes a GET request to your MCP endpoint to fetch the tool
// manifest: the list of available tools, their descriptions, and their
// input schemas. The same handler serves both purposes.
export const GET = handler;
</code></pre>
<h3 id="heading-mcp-documentation-endpoint">MCP Documentation Endpoint</h3>
<p>Create <code>app/mcp-docs/page.tsx</code>. This page is referenced in your environment variables and appears when users ask ChatGPT to explain your app's capabilities:</p>
<pre><code class="language-typescript">export default function McpDocsPage() {
  return (
    &lt;main style={{ fontFamily: "monospace", padding: "2rem", maxWidth: "600px" }}&gt;
      &lt;h1&gt;Tetris ChatGPT App — MCP Documentation&lt;/h1&gt;
      &lt;h2&gt;Available Tools&lt;/h2&gt;
      &lt;ul&gt;
        &lt;li&gt;
          &lt;strong&gt;start_game&lt;/strong&gt; — Starts a new Tetris game and returns a
          playable widget. Optional: &lt;code&gt;public&lt;/code&gt; (leaderboard), &lt;code&gt;seed&lt;/code&gt;{" "}
          (reproducible sequence).
        &lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;finish_game&lt;/strong&gt; — Records the final score and saves the
          replay. Requires &lt;code&gt;gameId&lt;/code&gt;, &lt;code&gt;score&lt;/code&gt;,{" "}
          &lt;code&gt;level&lt;/code&gt;, &lt;code&gt;linesCleared&lt;/code&gt;.
        &lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;get_leaderboard&lt;/strong&gt; — Returns the top scores widget.
          Optional: &lt;code&gt;limit&lt;/code&gt; (default 10).
        &lt;/li&gt;
        &lt;li&gt;
          &lt;strong&gt;view_replay&lt;/strong&gt; — Renders a recorded game replay.
          Requires &lt;code&gt;replayId&lt;/code&gt;.
        &lt;/li&gt;
      &lt;/ul&gt;
      &lt;h2&gt;Authentication&lt;/h2&gt;
      &lt;p&gt;
        All tools support anonymous access. Signing in via Kinde enables leaderboard
        entries and replay attribution.
      &lt;/p&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<h3 id="heading-tool-design-principles">Tool Design Principles</h3>
<p>A few patterns from this implementation worth keeping in mind for any MCP tool you build.</p>
<p><strong>Defensive argument coercion.</strong> Use <code>Number(args.score)</code> and <code>String(args.gameId)</code> rather than trusting the types ChatGPT sends. The LLM occasionally serializes numeric values as strings when constructing tool arguments, and a type mismatch in a Convex mutation will throw rather than coerce silently.</p>
<p><strong>Structured content alongside widget HTML.</strong> Return <code>structuredContent</code> with key values even when the primary content is a widget. This lets <code>GameBoard.tsx</code> extract <code>gameId</code> directly from the <code>callTool</code> response and lets callers inspect results programmatically without parsing HTML.</p>
<p><strong>Graceful degradation in every handler.</strong> Wrap Convex calls in <code>try/catch</code> and return a meaningful error message rather than throwing. ChatGPT surfaces unhandled tool errors poorly; a friendly fallback keeps the user experience smooth even when the backend is unreachable.</p>
<p><strong>Minimal auth in read-only tools.</strong> For <code>get_leaderboard</code> and <code>view_replay</code>, skip auth entirely rather than attempting token extraction. These tools are read-only and public; adding auth would introduce friction and failure modes with no security benefit.</p>
<h3 id="heading-verifying-the-mcp-route">Verifying the MCP Route</h3>
<p>Test the tool listing endpoint locally:</p>
<pre><code class="language-shell">curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
</code></pre>
<p>Expected response:</p>
<pre><code class="language-json">{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      { "name": "start_game", "description": "Start a new Tetris game...", "inputSchema": {...} },
      { "name": "finish_game", "description": "Record the final score...", "inputSchema": {...} },
      { "name": "get_leaderboard", "description": "Get the current Tetris leaderboard...", "inputSchema": {...} },
      { "name": "view_replay", "description": "Watch a recorded Tetris game replay.", "inputSchema": {...} }
    ]
  }
}
</code></pre>
<p>If you see all four tools, the MCP server is configured correctly. If you see an empty array, check that all <code>server.registerTool</code> calls come before <code>createMcpHandler(server)</code>.</p>
<h2 id="heading-building-the-supporting-features">Building the Supporting Features</h2>
<p>With the game engine and MCP integration complete, this section builds the pages users actually see: the landing page, game interface, leaderboard, and replay viewer.</p>
<h3 id="heading-landing-page">Landing Page</h3>
<p>Create <code>app/page.tsx</code>. The landing page does two things: it renders navigation cards to the three main features, and it reads any tool output passed by ChatGPT to personalise the greeting. <code>useWidgetProps</code> is a hook from the Vercel ChatGPT Apps SDK that gives you access to the structured output from the MCP tool that opened this widget. If the user is signed in and <code>start_game</code> returned a <code>name</code> field in its <code>structuredContent</code>, the greeting will address them by name rather than showing the default copy.</p>
<pre><code class="language-typescript">"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Play, Film, Trophy } from "lucide-react";
import { useWidgetProps } from "./hooks";

export default function Home() {
  const router = useRouter();

  // useWidgetProps reads the structured output that the MCP tool passed when
  // it opened this widget. If start_game returned a name in structuredContent,
  // we use it here to personalise the greeting. If not, we fall back to the
  // default tagline.
  const toolOutput = useWidgetProps&lt;{
    name?: string;
    result?: { structuredContent?: { name?: string } };
  }&gt;();

  const name = toolOutput?.result?.structuredContent?.name || toolOutput?.name;

  return (
    &lt;div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-800"&gt;
      &lt;main className="container mx-auto px-4 py-12 max-w-4xl"&gt;
        &lt;div className="text-center mb-8"&gt;
          &lt;h1 className="text-5xl font-extrabold text-slate-900 dark:text-white mb-3"&gt;Tetris&lt;/h1&gt;
          &lt;p className="text-lg text-slate-600 dark:text-slate-300"&gt;
            {name
              ? `Hi ${name}, ready to play?`
              : "Play classic Tetris in your browser — save replays and climb the leaderboard."}
          &lt;/p&gt;
        &lt;/div&gt;

        &lt;div className="grid md:grid-cols-3 gap-6 mb-12"&gt;
          &lt;Card className="hover:shadow-lg transition-shadow"&gt;
            &lt;CardHeader&gt;
              &lt;div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/50 mb-4"&gt;
                &lt;Play className="w-6 h-6 text-blue-600 dark:text-blue-400" /&gt;
              &lt;/div&gt;
              &lt;CardTitle&gt;Play&lt;/CardTitle&gt;
              &lt;CardDescription&gt;Start a new Tetris game and save replays when you finish.&lt;/CardDescription&gt;
            &lt;/CardHeader&gt;
          &lt;/Card&gt;

          &lt;Card className="hover:shadow-lg transition-shadow"&gt;
            &lt;CardHeader&gt;
              &lt;div className="flex items-center justify-center w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/50 mb-4"&gt;
                &lt;Film className="w-6 h-6 text-green-600 dark:text-green-400" /&gt;
              &lt;/div&gt;
              &lt;CardTitle&gt;Replays&lt;/CardTitle&gt;
              &lt;CardDescription&gt;View recent replays and replay your best runs.&lt;/CardDescription&gt;
            &lt;/CardHeader&gt;
          &lt;/Card&gt;

          &lt;Card className="hover:shadow-lg transition-shadow"&gt;
            &lt;CardHeader&gt;
              &lt;div className="flex items-center justify-center w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-900/50 mb-4"&gt;
                &lt;Trophy className="w-6 h-6 text-purple-600 dark:text-purple-400" /&gt;
              &lt;/div&gt;
              &lt;CardTitle&gt;Leaderboard&lt;/CardTitle&gt;
              &lt;CardDescription&gt;See the top scores and compete for the highest rank.&lt;/CardDescription&gt;
            &lt;/CardHeader&gt;
          &lt;/Card&gt;
        &lt;/div&gt;

        &lt;div className="text-center space-y-6"&gt;
          &lt;div className="flex justify-center gap-4"&gt;
            &lt;Link href="/tetris/play"&gt;
              &lt;Button size="lg" className="gap-2"&gt;
                &lt;Play className="w-5 h-5" /&gt;
                Play Now
              &lt;/Button&gt;
            &lt;/Link&gt;
            &lt;Link href="/tetris/replays"&gt;
              &lt;Button size="lg" variant="outline" className="gap-2"&gt;
                &lt;Film className="w-5 h-5" /&gt;
                Replays
              &lt;/Button&gt;
            &lt;/Link&gt;
            &lt;Link href="/tetris/leaderboard"&gt;
              &lt;Button size="lg" variant="ghost" className="gap-2"&gt;
                &lt;Trophy className="w-5 h-5" /&gt;
                Leaderboard
              &lt;/Button&gt;
            &lt;/Link&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/main&gt;

      &lt;footer className="mt-16 py-6 border-t border-slate-200 dark:border-slate-800"&gt;
        &lt;div className="container mx-auto px-4 text-center text-slate-500 dark:text-slate-400"&gt;
          &lt;p&gt;Play Tetris — save replays and compete on the leaderboard&lt;/p&gt;
        &lt;/div&gt;
      &lt;/footer&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-game-page">Game Page</h3>
<p>Create <code>app/tetris/play/page.tsx</code>. This is a thin wrapper that mounts the <code>GameBoard</code> component. All game logic lives in the component itself; the page just provides the route and a heading.</p>
<pre><code class="language-typescript">"use client";

import React from "react";
import GameBoard from "@/components/tetris/GameBoard";

export default function PlayPage() {
  return (
    &lt;main className="p-6"&gt;
      &lt;h1 className="text-2xl font-bold mb-4"&gt;Play Tetris in ChatGPT&lt;/h1&gt;
      &lt;GameBoard /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<h3 id="heading-leaderboard-component">Leaderboard Component</h3>
<p>Create <code>components/tetris/Leaderboard.tsx</code>. Because Convex does not perform relational joins natively, this component uses two separate queries: one for the leaderboard entries and one to look up the user records for each entry. The results are joined client-side using a <code>Map</code>. Both queries are live subscriptions, so the table refreshes automatically for everyone viewing it the moment any player finishes a game.</p>
<pre><code class="language-typescript">"use client";

import React from 'react';
import { useQuery } from 'convex/react';
import { api } from '@/convex/_generated/api';

export default function Leaderboard() {
  const entries = useQuery(api.leaderboards.listTop, { limit: 20 }) || [];

  const userIds = entries.map((e: any) =&gt; e.userId).filter(Boolean);

  // "skip" is a Convex sentinel value that tells useQuery not to run the query
  // at all. Without it, passing an empty userIds array would fire a query that
  // returns nothing useful and produces a loading state on every initial render.
  const users = useQuery(
    api.users.getMultipleById,
    userIds.length &gt; 0 ? { userIds } : "skip"
  );

  // Build a lookup map so each entry can find its user in O(1) rather than
  // scanning the users array on every render.
  const userMap = new Map();
  if (users) {
    users.forEach((user: any) =&gt; {
      if (user) userMap.set(user._id, user);
    });
  }

  return (
    &lt;div className="max-w-2xl mx-auto p-4"&gt;
      &lt;h2 className="text-2xl font-bold mb-4"&gt;Leaderboard&lt;/h2&gt;
      &lt;ol className="list-decimal pl-6 space-y-2"&gt;
        {entries.map((e: any, idx: number) =&gt; {
          const user = userMap.get(e.userId);
          const displayName = user
            ? (user.displayName || `\({user.firstName || ''} \){user.lastName || ''}`.trim() || user.email)
            : 'Anonymous';

          return (
            &lt;li key={e._id} className="flex justify-between"&gt;
              &lt;div&gt;{displayName}&lt;/div&gt;
              &lt;div&gt;{e.score}&lt;/div&gt;
            &lt;/li&gt;
          );
        })}
      &lt;/ol&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-leaderboard-page">Leaderboard Page</h3>
<p>Create <code>app/tetris/leaderboard/page.tsx</code>. This is a server component that mounts the <code>Leaderboard</code> component at the <code>/tetris/leaderboard</code> route.</p>
<pre><code class="language-typescript">import React from "react";
import Leaderboard from "@/components/tetris/Leaderboard";

export default function LeaderboardPage() {
  return (
    &lt;main className="p-6"&gt;
      &lt;h1 className="text-2xl font-bold mb-4"&gt;Leaderboard&lt;/h1&gt;
      &lt;Leaderboard /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<h3 id="heading-replay-viewer-component">Replay Viewer Component</h3>
<p>Create <code>components/tetris/ReplayViewer.tsx</code>. This file contains two components: <code>ReplayPlayer</code>, which replays a single game from its action log, and <code>ReplayViewer</code>, which handles loading and selection. The key insight in replay playback is that rather than storing 20,000 board snapshots, you store a few hundred action codes and replay them at their original timestamps divided by the speed multiplier. A two-minute game replays in one minute at 2x speed simply by halving each inter-action delay.</p>
<h4 id="heading-replayplayer">ReplayPlayer</h4>
<p><code>ReplayPlayer</code> re-executes the stored action log against a fresh copy of the game engine, advancing state action by action at the original timings. A few design decisions are worth understanding before reading the code.</p>
<p><code>makeRng</code> implements a seeded pseudo-random number generator (PRNG) using a Mulberry32 algorithm. <code>Math.random()</code> cannot be seeded, so there is no way to reproduce the same piece sequence across two independent runs. By seeding the PRNG with the same value that was used during the original game, the replay generates the exact same piece order the player experienced.</p>
<p>The <code>boardRef</code>, <code>currentRef</code>, and <code>scoreRef</code> pattern mirrors the <code>useRef</code> approach in <code>GameBoard</code>. State updates trigger re-renders, but the <code>applyAction</code> function is called rapidly inside <code>scheduleNext</code> and needs to read the current board and piece synchronously between calls. Refs give it that direct access without waiting for a render cycle.</p>
<p><code>scheduleNext</code> is a recursive timeout function rather than a <code>setInterval</code>. Each call reads the gap between the current action's timestamp and the next one and schedules itself for exactly that delay divided by the playback speed. This reproduces the original timing precisely, including fast sequences and natural pauses, which a fixed interval cannot do.</p>
<pre><code class="language-typescript">"use client";

import React, { useEffect, useRef, useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '@/convex/_generated/api';
import { Id } from '@/convex/_generated/dataModel';

const WIDTH = 10;
const HEIGHT = 20;
const PIECES: Record&lt;string, number[][]&gt; = {
  I: [[1, 1, 1, 1]],
  O: [[1, 1], [1, 1]],
  T: [[0, 1, 0], [1, 1, 1]],
  S: [[0, 1, 1], [1, 1, 0]],
  Z: [[1, 1, 0], [0, 1, 1]],
  J: [[1, 0, 0], [1, 1, 1]],
  L: [[0, 0, 1], [1, 1, 1]],
};
const PIECE_COLORS: Record&lt;string, string&gt; = {
  I: "#00f0f0", O: "#f0f000", T: "#a000f0",
  S: "#00f000", Z: "#f00000", J: "#0000f0", L: "#f0a000",
};
const PIECE_TYPES = Object.keys(PIECES);

// Mulberry32 seeded PRNG. Math.random() cannot be seeded, so it cannot
// reproduce a specific piece sequence. This function returns a callable
// that produces the same sequence every time it is initialized with the
// same seed — matching the sequence the player saw during the original game.
function makeRng(seed: number) {
  let s = seed;
  return function () {
    s |= 0; s = s + 0x6D2B79F5 | 0;
    let t = Math.imul(s ^ s &gt;&gt;&gt; 15, 1 | s);
    t = t + Math.imul(t ^ t &gt;&gt;&gt; 7, 61 | t) ^ t;
    return ((t ^ t &gt;&gt;&gt; 14) &gt;&gt;&gt; 0) / 4294967296;
  };
}

function emptyBoard() {
  return Array.from({ length: HEIGHT }, () =&gt; Array.from({ length: WIDTH }, () =&gt; 0));
}

function rotate(shape: number[][]) {
  const h = shape.length, w = shape[0].length;
  const out = Array.from({ length: w }, () =&gt; Array.from({ length: h }, () =&gt; 0));
  for (let r = 0; r &lt; h; r++)
    for (let c = 0; c &lt; w; c++)
      out[c][h - 1 - r] = shape[r][c];
  return out;
}

function canPlace(board: number[][], shape: number[][], x: number, y: number) {
  for (let r = 0; r &lt; shape.length; r++)
    for (let c = 0; c &lt; shape[0].length; c++) {
      if (!shape[r][c]) continue;
      const br = y + r, bc = x + c;
      if (bc &lt; 0 || bc &gt;= WIDTH || br &lt; 0 || br &gt;= HEIGHT || board[br][bc]) return false;
    }
  return true;
}

function ReplayPlayer({ replay }: { replay: any }) {
  const seed = replay.game?.seed ?? 0;

  const [board, setBoard] = useState(emptyBoard());
  const [current, setCurrent] = useState&lt;any&gt;(null);
  const [score, setScore] = useState(0);
  const [level, setLevel] = useState(1);
  const [isPlaying, setIsPlaying] = useState(false);
  const [playbackSpeed, setPlaybackSpeed] = useState(1);

  // Refs hold the mutable game state that applyAction reads and writes
  // between render cycles. Using state here would cause applyAction to
  // close over stale values during rapid action sequences.
  const boardRef = useRef(emptyBoard());
  const currentRef = useRef&lt;any&gt;(null);
  const scoreRef = useRef(0);
  const actionIndexRef = useRef(0);
  const playbackRef = useRef&lt;ReturnType&lt;typeof setTimeout&gt; | null&gt;(null);
  const rngRef = useRef(makeRng(seed));

  function spawnPiece(brd: number[][]) {
    const type = PIECE_TYPES[Math.floor(rngRef.current() * PIECE_TYPES.length)];
    const shape = PIECES[type].map((r) =&gt; [...r]);
    const x = Math.floor((WIDTH - shape[0].length) / 2);
    if (!canPlace(brd, shape, x, 0)) return;
    const piece = { type, shape, x, y: 0 };
    currentRef.current = piece;
    setCurrent(piece);
  }

  function applyAction(action: { t: number; a: string; p?: any }) {
    const brd = boardRef.current;
    let cur = currentRef.current;

    if (action.a === "START") {
      const newBoard = emptyBoard();
      boardRef.current = newBoard;
      scoreRef.current = 0;
      rngRef.current = makeRng(seed);
      setBoard(newBoard);
      setScore(0);
      setLevel(1);
      spawnPiece(newBoard);
      return;
    }

    if (!cur) return;

    if (action.a === "L" &amp;&amp; canPlace(brd, cur.shape, cur.x - 1, cur.y)) {
      cur = { ...cur, x: cur.x - 1 };
    } else if (action.a === "R" &amp;&amp; canPlace(brd, cur.shape, cur.x + 1, cur.y)) {
      cur = { ...cur, x: cur.x + 1 };
    } else if (action.a === "D" &amp;&amp; canPlace(brd, cur.shape, cur.x, cur.y + 1)) {
      cur = { ...cur, y: cur.y + 1 };
    } else if (action.a === "ROT") {
      const rotated = rotate(cur.shape);
      if (canPlace(brd, rotated, cur.x, cur.y)) cur = { ...cur, shape: rotated };
    } else if (action.a === "HD") {
      // Hard drop: find the lowest valid y position by incrementing until
      // canPlace fails, then lock the piece there immediately. Unlike a
      // soft drop (action "D"), hard drop merges the piece into the board
      // in a single step, clears any completed lines, and spawns the next piece.
      let dropY = cur.y;
      while (canPlace(brd, cur.shape, cur.x, dropY + 1)) dropY++;
      cur = { ...cur, y: dropY };

      const copy = brd.map((r) =&gt; [...r]);
      for (let r = 0; r &lt; cur.shape.length; r++)
        for (let c = 0; c &lt; cur.shape[0].length; c++)
          if (cur.shape[r][c]) copy[cur.y + r][cur.x + c] = PIECE_TYPES.indexOf(cur.type) + 1;

      const out: number[][] = [];
      let cleared = 0;
      for (let r = 0; r &lt; HEIGHT; r++) {
        if (copy[r].every((v) =&gt; v !== 0)) cleared++;
        else out.push(copy[r]);
      }
      while (out.length &lt; HEIGHT) out.unshift(Array.from({ length: WIDTH }, () =&gt; 0));

      if (cleared &gt; 0) {
        scoreRef.current += cleared * 100;
        setScore(scoreRef.current);
        setLevel(Math.floor(scoreRef.current / 1000) + 1);
      }

      boardRef.current = out;
      setBoard([...out]);
      currentRef.current = null;
      setCurrent(null);
      spawnPiece(out);
      return;
    }

    currentRef.current = cur;
    setCurrent({ ...cur });
  }

  function startPlayback() {
    if (!replay?.actions?.length) return;
    actionIndexRef.current = 0;
    boardRef.current = emptyBoard();
    rngRef.current = makeRng(seed);
    setBoard(emptyBoard());
    setCurrent(null);
    setScore(0);
    setLevel(1);
    setIsPlaying(true);

    // scheduleNext is a recursive timeout rather than a setInterval because
    // each action has a different delay: the gap between its timestamp and
    // the next action's timestamp, divided by playback speed. A fixed interval
    // would not reproduce natural timing variations in the original game.
    function scheduleNext() {
      const actions = replay.actions;
      if (actionIndexRef.current &gt;= actions.length) {
        setIsPlaying(false);
        return;
      }
      const curr = actions[actionIndexRef.current];
      const next = actions[actionIndexRef.current + 1];
      const delay = next ? (next.t - curr.t) / playbackSpeed : 500 / playbackSpeed;
      applyAction(curr);
      actionIndexRef.current++;
      playbackRef.current = setTimeout(scheduleNext, Math.max(16, delay));
    }

    scheduleNext();
  }

  function stopPlayback() {
    if (playbackRef.current) clearTimeout(playbackRef.current);
    setIsPlaying(false);
  }

  useEffect(() =&gt; () =&gt; { if (playbackRef.current) clearTimeout(playbackRef.current); }, []);

  const display = board.map((row, r) =&gt;
    row.map((cell, c) =&gt; {
      if (current &amp;&amp; r &gt;= current.y &amp;&amp; r &lt; current.y + current.shape.length) {
        const sr = r - current.y;
        const sc = c - current.x;
        if (sc &gt;= 0 &amp;&amp; sc &lt; current.shape[0].length &amp;&amp; current.shape[sr]?.[sc])
          return PIECE_TYPES.indexOf(current.type) + 10;
      }
      return cell;
    })
  );

  const cellPx = 20;
  const displayName = replay.user?.displayName ?? replay.user?.firstName ?? replay.user?.email ?? "Anonymous";

  return (
    &lt;div className="flex flex-col items-center gap-4 p-4 bg-slate-900 text-white"&gt;
      &lt;div className="text-lg font-bold text-cyan-400"&gt;{displayName}&lt;/div&gt;
      &lt;div className="text-slate-400 text-sm flex gap-4"&gt;
        &lt;span&gt;Score: {replay.game?.score?.toLocaleString() ?? "?"}&lt;/span&gt;
        &lt;span&gt;Level: {replay.game?.level ?? "?"}&lt;/span&gt;
        &lt;span&gt;Lines: {replay.game?.linesCleared ?? "?"}&lt;/span&gt;
        &lt;span&gt;Duration: {Math.round((replay.durationMs ?? 0) / 1000)}s&lt;/span&gt;
      &lt;/div&gt;

      &lt;div
        className="grid border border-slate-600"
        style={{ gridTemplateColumns: `repeat(\({WIDTH}, \){cellPx}px)` }}
      &gt;
        {display.flatMap((row, r) =&gt;
          row.map((cell, c) =&gt; {
            const colorIdx = cell &gt;= 10 ? cell - 10 : cell &gt; 0 ? cell - 1 : -1;
            return (
              &lt;div
                key={`\({r}-\){c}`}
                style={{
                  width: cellPx,
                  height: cellPx,
                  background: colorIdx &gt;= 0 ? PIECE_COLORS[PIECE_TYPES[colorIdx]] : "#0f172a",
                  border: "1px solid rgba(100,116,139,0.2)",
                }}
              /&gt;
            );
          })
        )}
      &lt;/div&gt;

      &lt;div className="flex gap-6 text-sm"&gt;
        &lt;div className="text-center"&gt;
          &lt;div className="text-slate-400"&gt;Score&lt;/div&gt;
          &lt;div className="font-bold text-cyan-400"&gt;{score.toLocaleString()}&lt;/div&gt;
        &lt;/div&gt;
        &lt;div className="text-center"&gt;
          &lt;div className="text-slate-400"&gt;Level&lt;/div&gt;
          &lt;div className="font-bold text-purple-400"&gt;{level}&lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div className="flex gap-2 items-center"&gt;
        &lt;button
          onClick={isPlaying ? stopPlayback : startPlayback}
          className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 rounded-lg font-medium transition-colors"
        &gt;
          {isPlaying ? "Stop" : "Play Replay"}
        &lt;/button&gt;
        &lt;select
          title="play-back"
          value={playbackSpeed}
          onChange={(e) =&gt; setPlaybackSpeed(Number(e.target.value))}
          className="px-3 py-2 bg-slate-700 rounded-lg"
        &gt;
          &lt;option value={0.5}&gt;0.5x&lt;/option&gt;
          &lt;option value={1}&gt;1x&lt;/option&gt;
          &lt;option value={2}&gt;2x&lt;/option&gt;
          &lt;option value={4}&gt;4x&lt;/option&gt;
        &lt;/select&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h4 id="heading-replayviewer">ReplayViewer</h4>
<p><code>ReplayViewer</code> handles the outer shell: loading a specific replay by ID when one is provided, fetching the recent replay list when not, and managing which replay is selected for playback. Once a replay is selected, it hands off to <code>ReplayPlayer</code>.</p>
<pre><code class="language-typescript">export default function ReplayViewer({ replayId }: { replayId?: Id&lt;"replays"&gt; | string }) {
  const replay = useQuery(
    api.replays.getReplay,
    replayId ? { replayId: replayId as Id&lt;"replays"&gt; } : "skip"
  );
  const recent = useQuery(api.replays.getRecentReplaysWithDetails, {});

  const [selected, setSelected] = useState&lt;any | null&gt;(null);

  useEffect(() =&gt; {
    if (replay) setSelected(replay);
  }, [replay]);

  if (replayId &amp;&amp; !replay) {
    return &lt;div className="p-4"&gt;Loading replay...&lt;/div&gt;;
  }

  if (!replayId &amp;&amp; !recent) {
    return &lt;div className="p-4"&gt;Loading recent replays...&lt;/div&gt;;
  }

  if (!selected) {
    if (recent?.length === 0) {
      return &lt;div className="p-4"&gt;No recent replays available.&lt;/div&gt;;
    }
    return (
      &lt;div className="max-w-lg mx-auto p-4"&gt;
        &lt;h3 className="font-bold mb-2"&gt;Recent Replays&lt;/h3&gt;
        &lt;ul className="space-y-2"&gt;
          {recent?.map((r) =&gt; {
            const name = r.user?.displayName ?? r.user?.firstName ?? r.user?.email ?? "Anonymous";
            return (
              &lt;li key={r._id}&gt;
                &lt;button
                  className="underline text-blue-600"
                  onClick={() =&gt; setSelected(r)}
                &gt;
                  {name} | score {r.game?.score?.toLocaleString() ?? "?"} · {r.actions?.length ?? 0} actions
                &lt;/button&gt;
              &lt;/li&gt;
            );
          })}
        &lt;/ul&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;div className="min-h-screen bg-slate-900"&gt;
      &lt;div className="text-center pt-4"&gt;
        &lt;button
          className="text-cyan-400 hover:underline text-sm"
          onClick={() =&gt; setSelected(null)}
        &gt;
          Back to replay list
        &lt;/button&gt;
      &lt;/div&gt;
      &lt;ReplayPlayer replay={selected} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-replays-page">Replays Page</h3>
<p>Create <code>app/tetris/replays/page.tsx</code>. This mounts <code>ReplayViewer</code> without a <code>replayId</code>, which causes the component to display the recent replays list rather than jumping straight to a specific game.</p>
<pre><code class="language-typescript">import React from "react";
import ReplayViewer from "@/components/tetris/ReplayViewer";

export default function ReplaysPage() {
  return (
    &lt;main className="p-6"&gt;
      &lt;h1 className="text-2xl font-bold mb-4"&gt;Replays&lt;/h1&gt;
      &lt;ReplayViewer /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<h3 id="heading-verify-all-routes">Verify All Routes</h3>
<p>Start the dev server and confirm each route loads:</p>
<pre><code class="language-shell">pnpm dev
</code></pre>
<p>Then check each URL:</p>
<pre><code class="language-shell">http://localhost:3000                            Landing page
http://localhost:3000/tetris/play                Game (no gameId, anonymous start)
http://localhost:3000/tetris/leaderboard         Live leaderboard
http://localhost:3000/tetris/replays             Replay list
http://localhost:3000/tetris/replays?replayId=x  Viewer (shows "not found" until a real ID exists)
http://localhost:3000/mcp-docs                   MCP documentation
</code></pre>
<p>If all six routes load without errors, the supporting features are wired up correctly.</p>
<h2 id="heading-deploying-to-vercel">Deploying to Vercel</h2>
<p>Local development is working. This section gets everything running in production with the correct environment variables, Convex deployment, and ChatGPT connector registration.</p>
<h3 id="heading-pre-deployment-checklist">Pre-Deployment Checklist</h3>
<p>Before deploying, verify these items locally:</p>
<pre><code class="language-shell"># 1. Build succeeds without errors
pnpm build

# 2. All environment variables are present
cat .env.local

# 3. Convex dev is running and schema is synced
pnpm convex dev

# 4. MCP route responds correctly
curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

# 5. OAuth discovery endpoint is accessible
curl http://localhost:3000/mcp/.well-known/oauth-protected-resource
</code></pre>
<p>Fix any build errors before continuing. Type errors and missing imports will cause the Vercel build to fail even if local dev works.</p>
<h3 id="heading-deploy-convex-to-production">Deploy Convex to Production</h3>
<p>Convex has separate dev and production environments. Your dev deployment runs locally, so you need a separate production deployment for Vercel:</p>
<pre><code class="language-bash">pnpm convex deploy
</code></pre>
<p>This command pushes your schema and all functions to a production Convex deployment. After it completes, copy the production URL from the output. Production URLs use your project name rather than a random animal name, so they look like <code>https://your-project-name.convex.cloud</code> rather than the <code>https://happy-animal-123.convex.cloud</code> format you see in development.</p>
<h3 id="heading-initial-vercel-deployment">Initial Vercel Deployment</h3>
<pre><code class="language-bash">vercel --prod
</code></pre>
<p>Vercel will ask which project to link to. Select the project you linked in Section 3. After deployment completes, copy your production URL, which looks something like <code>https://tetris-chatgpt-app.vercel.app</code>.</p>
<p>Note that this first deployment runs without your production environment variables. You will redeploy after adding them in the next step, which is why two <code>vercel --prod</code> commands appear in this section.</p>
<h3 id="heading-configure-production-environment-variables">Configure Production Environment Variables</h3>
<p>Your production deployment needs different values for several variables. Go to the Vercel dashboard, open your project, navigate to Settings, then Environment Variables, and add all of the following:</p>
<pre><code class="language-bash"># Convex — use production values from pnpm convex deploy output
CONVEX_DEPLOYMENT=prod:happy-animal-123
NEXT_PUBLIC_CONVEX_URL=https://happy-animal-123.convex.cloud
NEXT_PUBLIC_CONVEX_HTTP_URL=https://happy-animal-123.convex.site

# Kinde — same values as local
KINDE_ISSUER=https://yourcompany.kinde.com
KINDE_CLIENT_ID=your-client-id
KINDE_CLIENT_SECRET=your-client-secret

# Vercel — use your production Vercel URL
VERCEL_PROJECT_PRODUCTION_URL=https://tetris-chatgptapp.com
VERCEL_BRANCH_URL=https://tetris-chatgpt-app.vercel.app
VERCEL_URL=https://tetris-chatgpt-app.vercel.app
VERCEL_ENV=production
NODE_ENV=production

# MCP — use your production Vercel URL
MCP_AUDIENCE=https://tetris-chatgpt-app.vercel.app/mcp
MCP_RESOURCE=https://tetris-chatgpt-app.vercel.app
MCP_DOC_URL=https://tetris-chatgpt-app.vercel.app/mcp-docs
</code></pre>
<p>Or set them via CLI:</p>
<pre><code class="language-bash">vercel env add CONVEX_DEPLOYMENT production
vercel env add NEXT_PUBLIC_CONVEX_URL production
vercel env add KINDE_ISSUER production
vercel env add KINDE_CLIENT_ID production
vercel env add KINDE_CLIENT_SECRET production
vercel env add VERCEL_PROJECT_PRODUCTION_URL production
vercel env add VERCEL_BRANCH_URL production
vercel env add VERCEL_URL production
vercel env add VERCEL_ENV production
vercel env add NODE_ENV production
vercel env add MCP_AUDIENCE production
vercel env add MCP_RESOURCE production
vercel env add MCP_DOC_URL production
</code></pre>
<h3 id="heading-update-kinde-callback-urls">Update Kinde Callback URLs</h3>
<p>Go to <a href="https://kinde.com?utm_source=fcc&amp;utm_medium=content&amp;utm_campaign=shola&amp;campaignid=chatgptapp&amp;network=&amp;adgroup=&amp;keyword=&amp;matchtype=&amp;creative=3&amp;device=&amp;adposition=">Kinde</a>, open your application, navigate to Settings, then Allowed callback URLs, and add your production URLs:</p>
<pre><code class="language-plaintext">https://tetris-chatgpt-app.vercel.app/api/auth/callback
https://chatgpt.com/connector_platform_oauth_redirect
</code></pre>
<p>And in Allowed logout redirect URLs:</p>
<pre><code class="language-plaintext">https://tetris-chatgpt-app.vercel.app
https://chatgpt.com
</code></pre>
<p>The <code>chatgpt.com</code> callback URL is what ChatGPT uses after OAuth completes. Without it, Kinde will reject the redirect and authentication will fail silently.</p>
<h3 id="heading-redeploy-with-production-variables">Redeploy with Production Variables</h3>
<p>After setting environment variables, trigger a new deployment so the values take effect:</p>
<pre><code class="language-bash">vercel --prod
</code></pre>
<p>Or push a commit to your main branch if you have connected GitHub.</p>
<h3 id="heading-verify-production-endpoints">Verify Production Endpoints</h3>
<p>Once deployed, test every critical endpoint:</p>
<pre><code class="language-shell">PROD_URL="https://tetris-chatgpt-app.vercel.app"

# MCP tools list
curl -X POST $PROD_URL/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

# OAuth discovery
curl $PROD_URL/mcp/.well-known/oauth-protected-resource

# Landing page loads
curl -I $PROD_URL
</code></pre>
<p>All three should return 200 status codes with the expected content.</p>
<h2 id="heading-registering-with-chatgpt">Registering with ChatGPT</h2>
<p>This is the step where your app becomes playable inside ChatGPT. You will enable Developer Mode, create the connector, and walk through the OAuth flow for the first time.</p>
<h3 id="heading-enable-developer-mode">Enable Developer Mode</h3>
<p>Go to Settings, then Connectors, then Advanced, and enable Developer Mode.</p>
<p><strong>Important:</strong> Enabling Developer Mode automatically disables Memory. If you rely on ChatGPT's Memory feature, note this tradeoff before proceeding. Developer Mode is required for custom MCP connectors and is available on Plus, Pro, and Team plans.</p>
<h3 id="heading-create-the-connector">Create the Connector</h3>
<p>Go to Settings, then Connectors, then Create, and fill in the form:</p>
<pre><code class="language-plaintext">Name:           Tetris
Description:    Play Tetris inside ChatGPT with real-time leaderboards
MCP Server URL: https://tetris-chatgpt-app.vercel.app/mcp
Authentication: OAuth
</code></pre>
<p>Check "I trust this application" and click Create.</p>
<h3 id="heading-how-the-oauth-flow-works">How the OAuth Flow Works</h3>
<p>Because your MCP server exposes the <code>/.well-known/oauth-protected-resource</code> endpoint and your tools declare <code>securitySchemes</code>, ChatGPT handles the OAuth flow automatically when a user invokes an authenticated tool for the first time.</p>
<p>The user gets redirected to your Kinde login page, authenticates, approves the requested scopes, and ChatGPT exchanges the authorization code for a token. From that point on, ChatGPT attaches <code>Authorization: Bearer &lt;token&gt;</code> to every MCP request, which is what your <code>extractTokenFromArgs</code> function reads.</p>
<p>Anonymous tools like <code>get_leaderboard</code> and <code>view_replay</code> work immediately without sign-in. Authenticated tools like <code>start_game</code> and <code>finish_game</code> trigger the sign-in flow on first use if the user is not already linked.</p>
<h3 id="heading-test-it">Test It</h3>
<pre><code class="language-plaintext">You: "Start a Tetris game"
ChatGPT: [OAuth prompt appears if not signed in, sign in, game widget renders]

You: "Show me the leaderboard"
ChatGPT: [calls get_leaderboard, renders leaderboard widget immediately, no sign-in needed]
</code></pre>
<p>If ChatGPT shows an error instead of a widget, check the Vercel function logs: Dashboard, your project, Functions, then click any invocation to see the full request and response.</p>
<p>The two most common issues are the <code>chatgpt.com/connector_platform_oauth_redirect</code> callback URL not being in your Kinde allowlist, and a missing <code>code_challenge_methods_supported: ["S256"]</code> field in your Kinde metadata. The <code>S256</code> value refers to the PKCE (Proof Key for Code Exchange) challenge method, which ChatGPT requires for its OAuth flow. Kinde includes this by default, but if you are using a custom OAuth provider, verify it is present in your <code>/.well-known/openid-configuration</code> response.</p>
<h2 id="heading-finishing-up">Finishing Up</h2>
<h3 id="heading-custom-domain-optional">Custom Domain (Optional)</h3>
<p>If you have a domain, add it in Vercel:</p>
<pre><code class="language-bash">vercel domains add yourdomain.com
</code></pre>
<p>Then update all environment variables and Kinde callback URLs to use the custom domain. The MCP connector registration in ChatGPT will also need updating to the new URL.</p>
<h3 id="heading-environment-variable-reference">Environment Variable Reference</h3>
<p>The complete variable list with descriptions, to help diagnose configuration issues:</p>
<pre><code class="language-shell"># Convex
CONVEX_DEPLOYMENT          # Deployment name (dev:... or prod:...)
NEXT_PUBLIC_CONVEX_URL     # Full Convex URL (must start with https://)

# Kinde — all from your Kinde application settings page
KINDE_ISSUER               # Your Kinde domain (no trailing slash)
KINDE_CLIENT_ID            # Application client ID
KINDE_CLIENT_SECRET        # Application client secret

# MCP — all must use your production URL in production
MCP_AUDIENCE               # Full URL to /mcp route
MCP_RESOURCE               # Root URL of your deployment
MCP_DOC_URL                # URL to /mcp-docs page
</code></pre>
<p>The most common misconfiguration is using <code>localhost</code> values in production. <code>MCP_AUDIENCE</code> must match the <code>resource</code> field in your OAuth discovery endpoint. If these do not match, ChatGPT cannot complete the OAuth flow.</p>
<h3 id="heading-production-vs-preview-deployments">Production vs. Preview Deployments</h3>
<p>Vercel creates a unique URL for every pull request (for example, <code>tetris-chatgpt-app-git-feature-branch.vercel.app</code>). These preview deployments use the same environment variables, but <code>MCP_AUDIENCE</code> is hardcoded to your production URL, so OAuth will not work in preview by default.</p>
<p>For preview deployments that need working auth, use the <code>VERCEL_BRANCH_URL</code> variable, which the <code>baseURL</code> helper in <code>lib/baseURL.ts</code> already handles:</p>
<pre><code class="language-typescript">// lib/baseURL.ts — resolves the correct base URL for each deployment type
export const baseURL =
  process.env.NODE_ENV === "development"
    ? "http://localhost:3000"
    : "https://" +
      (process.env.VERCEL_ENV === "production"
        ? process.env.VERCEL_PROJECT_PRODUCTION_URL
        : process.env.VERCEL_BRANCH_URL || process.env.VERCEL_URL);
</code></pre>
<p>The base URL resolves correctly for each deployment automatically. The remaining issue is that Kinde's allowed callback URLs do not include preview URLs. Add <code>https://*.vercel.app/api/auth/callback</code> as a wildcard in Kinde's settings if you want preview auth to work.</p>
<h3 id="heading-monitoring-and-logs">Monitoring and Logs</h3>
<p>Vercel provides function-level logs accessible in the dashboard.</p>
<p><strong>Build logs</strong> cover compilation errors, missing modules, and type errors. Check these first if a deployment fails.</p>
<p><strong>Function logs</strong> cover runtime errors, timeouts, and unhandled exceptions. Each invocation is listed individually so you can inspect the exact request and response that caused an error.</p>
<p><strong>Edge Network logs</strong> cover CORS issues and header problems. Check these if requests are being blocked before they reach your functions.</p>
<p>For Convex issues, the Convex dashboard at <code>dashboard.convex.dev</code> shows real-time function logs. Every mutation and query is logged with its arguments, return values, and execution time.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Even with everything configured correctly, you will hit issues. Here are the most common ones and how to fix them.</p>
<h3 id="heading-chatgpt-shows-action-not-found-or-doesnt-recognize-your-tools">ChatGPT shows "action not found" or doesn't recognize your tools</h3>
<p>Developer Mode is not enabled. Go to Settings → Connectors → Advanced and enable Developer Mode. This is required for custom MCP connectors and is only available on Plus, Pro, and Team plans.</p>
<p>If Developer Mode is already on, go to Settings → Connectors, find your connector in the list, and click the Refresh icon next to it. ChatGPT caches your MCP manifest and does not automatically discover new tools. A manual refresh is required whenever your tool list changes.</p>
<h3 id="heading-widget-renders-blank-or-shows-a-white-iframe">Widget renders blank or shows a white iframe</h3>
<p>This is almost always a CORS issue. Verify your <code>next.config.ts</code> has the permissive headers configured. The three required headers belong inside the <code>headers()</code> function in your Next.js config:</p>
<pre><code class="language-typescript">// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Access-Control-Allow-Origin", value: "*" },
          { key: "Access-Control-Allow-Methods", value: "GET,POST,PUT,DELETE,OPTIONS" },
          { key: "Access-Control-Allow-Headers", value: "*" },
        ],
      },
    ];
  },
};
</code></pre>
<p>Also check that <code>MCP_RESOURCE</code> in your Vercel environment variables matches the exact URL ChatGPT is using to reach your app, including <code>https://</code> and no trailing slash. A mismatch causes the OAuth discovery endpoint to return a <code>resource</code> URL that does not match the incoming request, which silently breaks widget rendering.</p>
<h3 id="heading-oauth-flow-never-completes-stuck-on-redirect">OAuth flow never completes (stuck on redirect)</h3>
<p>The most common cause is a missing callback URL in Kinde. Go to your <a href="https://kinde.com?utm_source=fcc&amp;utm_medium=content&amp;utm_campaign=shola&amp;campaignid=chatgptapp&amp;network=&amp;adgroup=&amp;keyword=&amp;matchtype=&amp;creative=3&amp;device=&amp;adposition=">Kinde application</a> settings and confirm both of these are in your Allowed Callback URLs:</p>
<pre><code class="language-plaintext">https://your-app.vercel.app/api/auth/callback
https://chatgpt.com/connector_platform_oauth_redirect
</code></pre>
<p>The second URL is what ChatGPT uses to receive the authorization code after the user signs in. Without it, Kinde rejects the redirect and the user sees an error page instead of returning to ChatGPT.</p>
<p>If the callback URLs are correct and the flow still fails, check your <code>MCP_AUDIENCE</code> environment variable. It must exactly match the <code>resource</code> field in your <code>/.well-known/oauth-protected-resource</code> response. ChatGPT echoes this value as the <code>resource</code> parameter throughout the OAuth flow, and Kinde embeds it in the token's <code>aud</code> (audience) claim. When your tool handler calls <code>validateKindeToken</code>, it checks that <code>aud</code> matches <code>MCP_AUDIENCE</code>. If they differ by even a trailing slash, validation fails silently and every authenticated tool call returns an auth error.</p>
<h3 id="heading-score-is-not-saving-finishgame-errors">Score is not saving (<code>finish_game</code> errors)</h3>
<p>This usually means <code>finish_game</code> was called before <code>start_game</code> returned a <code>gameId</code>. To confirm, add a log line to the <code>start()</code> function in <code>GameBoard.tsx</code> after the <code>callTool</code> response arrives:</p>
<pre><code class="language-typescript">const gameIdToUse = (toolRes as any)?.structuredContent?.gameId;
console.log("Game ID captured:", gameIdToUse); // Add this line
if (gameIdToUse) setGameId(gameIdToUse);
</code></pre>
<p>If the log line is missing from the console, <code>start_game</code> either failed or the <code>structuredContent.gameId</code> field was not returned. Check your Vercel function logs for the <code>start_game</code> invocation and confirm your MCP route handler is returning both fields:</p>
<pre><code class="language-typescript">return {
  content: [{ type: "text", text: widget }],
  structuredContent: { gameId: String(gameId) },
};
</code></pre>
<p>Both fields are required. If <code>structuredContent</code> is absent, <code>callTool</code> returns no game ID and <code>finish_game</code> has nothing to save.</p>
<h3 id="heading-replay-plays-wrong-pieces-board-diverges-immediately">Replay plays wrong pieces (board diverges immediately)</h3>
<p>The seeded RNG in <code>ReplayViewer</code> must start from the same position as the original game. Confirm that the <code>START</code> action is the first entry in <code>actionsRef.current</code>. If it is missing, <code>ReplayPlayer</code> never calls <code>spawnPiece</code> with a freshly seeded RNG and the first piece spawns at a different position in the random sequence than the player originally saw. The fix is to ensure <code>actionsRef.current.push({ t: Date.now(), a: "START" })</code> runs at the very beginning of the <code>start()</code> function, before any other actions are recorded.</p>
<h3 id="heading-convex-mutations-throw-argument-validation-failed">Convex mutations throw "argument validation failed"</h3>
<p>Your function arguments do not match the schema defined in <code>convex/schema.ts</code>. Open <code>dashboard.convex.dev</code>, go to Logs, and find the failed mutation. The error message will name the exact field that failed.</p>
<p>The two most common mismatches are passing a plain string where a <code>v.id("games")</code> typed ID is expected, and sending <code>undefined</code> for a required field. Both are fixed by checking the mutation's <code>args</code> definition in the relevant <code>convex/</code> file and ensuring the values you pass match the declared types exactly.</p>
<h3 id="heading-mcp-route-returns-500-on-every-request">MCP route returns 500 on every request</h3>
<p>A 500 on every request almost always means a missing environment variable. A missing <code>NEXT_PUBLIC_CONVEX_URL</code> causes <code>getConvexClient()</code> to throw on every invocation before any tool logic runs.</p>
<p>Go to your Vercel project → Settings → Environment Variables and verify every variable from the deployment checklist is present and scoped to the <strong>Production</strong> environment. Variables added only to Preview or Development do not apply to production deployments and will not appear in your function's <code>process.env</code>.</p>
<h2 id="heading-final-data-flow">Final Data Flow</h2>
<p>Here is how a complete game session flows through the system, from user input to ChatGPT rendering:</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/d8a5c003-419c-42b7-a282-6088da230f56.svg" alt="ChatGPT Tetris App: Final Data Flow" style="display:block;margin:0 auto" width="1137.9609375" height="1832" loading="lazy">

<p>Every step that touches Convex is transactional. If <code>finishGame</code> fails partway through, none of the writes commit and the game stays in <code>active</code> status, no replay is created, and the leaderboard is not updated. This prevents orphaned records and inconsistent state.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built a production Tetris game that runs inside ChatGPT with real-time leaderboards, replay recording, and Kinde OAuth authentication, all delivered through the MCP protocol without the user ever leaving the chat.</p>
<p>The architecture is the important part. The game is just the demonstration. What you actually built is a pattern: an MCP server that authenticates users, stores data in a real-time database, and renders interactive widgets inside ChatGPT.</p>
<p>Swap the game for a task manager, a data dashboard, a booking system, or anything that benefits from a conversational interface and the Convex backend, the Kinde auth flow, and the MCP tool registration all carry over unchanged.</p>
<p>ChatGPT becomes the interface. Your app becomes the capability behind it.</p>
<h3 id="heading-next-steps">Next Steps</h3>
<p>Some ideas for where to take this further:</p>
<ul>
<li><p><strong>Mobile gestures:</strong> add touch event handlers to <code>GameBoard.tsx</code> so the game is playable on ChatGPT's iOS and Android apps, where keyboard input doesn't work. Tap to rotate, swipe left or right to move, swipe down to soft drop.</p>
</li>
<li><p><strong>AI opponent:</strong> implement the Pierre Dellacherie algorithm as a demo mode which is useful for the leaderboard page when no one is actively playing, or as an optional AI assist toggle during a real game.</p>
</li>
<li><p><strong>RBAC:</strong> add admin vs. player roles using Kinde's built-in permission system to let admins moderate the leaderboard, delete replays, or ban users.</p>
</li>
<li><p><strong>Submit to the ChatGPT app directory:</strong> once you have tested with real users, submit your connector so people can discover it without manually entering your MCP URL. See the <a href="https://developers.openai.com/apps-sdk/app-submission-guidelines">submission guidelines</a>.</p>
</li>
<li><p><strong>Multiplayer:</strong> Convex's real-time subscriptions make it well-suited for competitive modes. Two players subscribe to the same game document and see each other's board update live with no WebSocket boilerplate required.</p>
</li>
</ul>
<h3 id="heading-resources">Resources</h3>
<p><strong>Source code</strong></p>
<ul>
<li>Complete source code for this tutorial: <a href="https://github.com/sholajegede/chatgpt-tetris">GitHub repository</a>. If it helped you, consider giving it a star</li>
</ul>
<p><strong>Core documentation</strong></p>
<ul>
<li><p><a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization">MCP Protocol Specification</a>: the full MCP authorization spec ChatGPT implements</p>
</li>
<li><p><a href="https://developers.openai.com/apps-sdk">Vercel ChatGPT Apps SDK</a>: official OpenAI documentation for building apps inside ChatGPT</p>
</li>
<li><p><a href="https://developers.openai.com/apps-sdk/deploy/connect-chatgpt">ChatGPT connector registration</a>: how to connect, test, and publish your app</p>
</li>
<li><p><a href="https://developers.openai.com/apps-sdk/build/auth">Apps SDK authentication guide</a>: OAuth 2.1 flow, security schemes, and token verification</p>
</li>
</ul>
<p><strong>Services used</strong></p>
<ul>
<li><p><a href="https://docs.convex.dev/">Convex documentation</a>:real-time database, schema, mutations, queries</p>
</li>
<li><p><a href="https://docs.kinde.com/">Kinde documentation</a>: OAuth, JWT validation, user management</p>
</li>
<li><p><a href="https://vercel.com/docs">Vercel documentation</a>: deployment, environment variables, function logs</p>
</li>
</ul>
<p><strong>Debugging tools</strong></p>
<ul>
<li><p><a href="https://modelcontextprotocol.io/docs/tools/inspector">MCP Inspector</a>: walk through OAuth steps and inspect live MCP requests locally before deploying</p>
</li>
<li><p><a href="https://dashboard.convex.dev/">Convex dashboard</a>: real-time function logs, data browser, and schema viewer</p>
</li>
<li><p><a href="https://openai.com/chatgpt-connectors.json">OpenAI egress IPs</a>: allowlist these if you want to restrict MCP access to ChatGPT only</p>
</li>
</ul>
<p><strong>Further reading</strong></p>
<ul>
<li><p><a href="https://imake.ninja/el-tetris-an-improvement-on-pierre-dellacheries-algorithm/">Pierre Dellacherie algorithm</a>: the Tetris AI heuristic referenced in Next Steps</p>
</li>
<li><p><a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization">MCP authorization spec</a>: PKCE, dynamic client registration, resource metadata</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>
        
            <item>
                <title>
                    <![CDATA[ How to Build a To-Do List MCP Server Using TypeScript – with Auth, Database, and Billing ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, you’ll build a To-Do list MCP server using TypeScript. You’ll learn how to implement authentication, persistence, and billing, to make the server robust and functional for real users. By the end, you’ll have a working MCP server tha... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-to-do-list-mcp-server-using-typescript/</link>
                <guid isPermaLink="false">68f93792b7f64a597dc407f9</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Wed, 22 Oct 2025 19:59:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761162036666/77972b3f-9dc8-404f-b40d-fb70ee73e2a5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, you’ll build a To-Do list MCP server using TypeScript. You’ll learn how to implement authentication, persistence, and billing, to make the server robust and functional for real users.</p>
<p>By the end, you’ll have a working MCP server that:</p>
<ul>
<li><p>Authenticates users with Kinde.</p>
</li>
<li><p>Stores to-do data in a Neon Postgres database.</p>
</li>
<li><p>Enforces billing limits and supports upgrades.</p>
</li>
<li><p>Exposes all these features as MCP tools inside Cursor.</p>
</li>
</ul>
<p>This article will walk you through each step, helping you understand design decisions that you can adapt for your own projects.</p>
<h2 id="heading-what-youll-learn">What You’ll Learn</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-why-go-beyond-basic-mcp-servers">Why Go Beyond Basic MCP Servers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-youll-build">What You’ll Build</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-database-setup-with-neon-postgresql">Database Setup with Neon PostgreSQL</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-connect-your-neon-database">1. Connect your Neon Database</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-create-your-db-file">2. Create your DB File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-step-by-step-breakdown-of-setup-dbts">3. Step-by-Step Breakdown of setup-db.ts</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-full-setup-dbts-file">4. Full setup-db.ts File</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-authentication-with-kinde">Authentication with Kinde</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-create-a-kinde-application">1. Create a Kinde Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-configure-kinde-settings">2. Configure Kinde Settings</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-environment-variables">3. Environment Variables</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-create-the-kinde-auth-server">4. Create the Kinde Auth Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-5-complete-authentication-flow">5. Complete Authentication Flow</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-6-why-this-matters">6. Why This Matters</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-7-key-connections">7. Key Connections</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-8-full-kinde-auth-serverts-file">8. Full kinde-auth-server.ts File</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-mcp-server-implementation-with-billing-system-integration">MCP Server Implementation (with Billing System Integration)</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-create-your-file">1. Create Your File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-project-setup-and-imports">2. Project Setup and Imports</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-database-connection-and-configuration">3. Database Connection and Configuration</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-authentication-system">4. Authentication System</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-5-core-helper-functions">5. Core Helper Functions</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-6-core-server-implementation">6. Core Server Implementation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-7-register-tools">7. Register Tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-8-tool-handlers">8. Tool Handlers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-9-full-serverts-file">9. Full server.ts File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-10-data-flow-amp-integration">10. Data Flow &amp; Integration</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-11-error-handling-amp-security">11. Error Handling &amp; Security</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-12-testing-amp-deployment">12. Testing &amp; Deployment</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-testing-the-complete-system">Testing the Complete System</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-start-the-services">1. Start the Services</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-configure-cursor-mcp">2. Configure Cursor MCP</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-test-the-complete-flow">3. Test the Complete Flow</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-troubleshooting">Troubleshooting</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-1-mcp-server-not-detected">1. MCP Server Not Detected</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-2-database-connection-issues">2. Database Connection Issues</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-3-kinde-authentication-problems">3. Kinde Authentication Problems</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-4-token-errors">4. Token Errors</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-final-mcp-server-architecture">Final MCP Server Architecture</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-resources">Resources</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-why-go-beyond-basic-mcp-servers">Why Go Beyond Basic MCP Servers?</h2>
<p>If you read this <a target="_blank" href="https://www.freecodecamp.org/news/how-to-build-a-custom-mcp-server-with-typescript-a-handbook-for-developers">freeCodeCamp MCP handbook</a>, you learned how to set up a simple MCP server in TypeScript. That’s useful for learning the protocol, but it doesn’t reflect what you need in production.</p>
<p>A real application requires:</p>
<ul>
<li><p><strong>Authentication</strong> so each user has their own data and permissions.</p>
</li>
<li><p><strong>Persistence</strong> so data is stored in a reliable database.</p>
</li>
<li><p><strong>Billing</strong> so you can enforce limits and monetize usage.</p>
</li>
</ul>
<p>Without these, an MCP server is just a demo.</p>
<h2 id="heading-what-youll-build">What You’ll Build</h2>
<p>In this tutorial, you’ll build a to-do MCP server with TypeScript that includes the essentials of a production-ready backend:</p>
<ul>
<li><p><strong>Authentication</strong> with Kinde</p>
</li>
<li><p><strong>Database persistence</strong> with Neon Postgres</p>
</li>
<li><p><strong>Billing enforcement</strong> with a free tier and upgrade path</p>
</li>
<li><p><strong>MCP tool exposure</strong> so all of this works seamlessly</p>
</li>
</ul>
<p>By the end, you’ll have an MCP server that feels more like the backend of a SaaS app and a template you can extend for your own ideas.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before we start, you'll need:</p>
<p><strong>Accounts &amp; Services (all free to use):</strong></p>
<ul>
<li><p><a target="_blank" href="https://kinde.com">Kinde Account</a> <strong>→</strong> for authentication and billing</p>
</li>
<li><p><a target="_blank" href="https://neon.com">Neon Account</a> <strong>→</strong> for PostgreSQL database</p>
</li>
<li><p>Node.js (v18+) (<a target="_blank" href="https://nodejs.org/en/download">download</a>)</p>
</li>
<li><p>Cursor IDE <strong>→</strong> for MCP integration and tool testing (<a target="_blank" href="https://cursor.com/download">download</a>)</p>
</li>
</ul>
<p><strong>Development Tools:</strong></p>
<ul>
<li><p>Terminal/Command line access</p>
</li>
<li><p>Git (optional, for version control)</p>
</li>
</ul>
<h2 id="heading-project-setup">Project Setup</h2>
<p>First, create a new folder:</p>
<pre><code class="lang-powershell">mkdir todo<span class="hljs-literal">-mcp</span><span class="hljs-literal">-server</span>
<span class="hljs-built_in">cd</span> todo<span class="hljs-literal">-mcp</span><span class="hljs-literal">-server</span>
</code></pre>
<p>Then initialize a Node.js project:</p>
<pre><code class="lang-powershell">npm init <span class="hljs-literal">-y</span>
</code></pre>
<p>Next, install the dependencies your server will need:</p>
<pre><code class="lang-powershell">npm install @modelcontextprotocol/sdk @neondatabase/serverless @kinde<span class="hljs-literal">-oss</span>/kinde<span class="hljs-literal">-typescript</span><span class="hljs-literal">-sdk</span> express jsonwebtoken jwks<span class="hljs-literal">-client</span> express<span class="hljs-literal">-session</span>
</code></pre>
<p>The <code>@modelcontextprotocol/sdk</code> package gives us everything we need to build and expose MCP servers and tools. We’re using <code>@neondatabase/serverless</code> to connect to a Neon Postgres database, and <code>@kinde-oss/kinde-typescript-sdk</code> handles authentication and billing through Kinde.</p>
<p>We’ll also install <code>express</code>, which makes it easy to define routes and handle middleware. To verify user tokens from Kinde, we’ll use <code>jsonwebtoken</code> together with <code>jwks-client</code>. And finally, <code>express-session</code> will take care of managing session state so users can stay logged in across requests.</p>
<p>Next, set up TypeScript and a few type definitions for development:</p>
<pre><code class="lang-powershell">npm install <span class="hljs-literal">-D</span> typescript @types/node @types/express @types/express<span class="hljs-literal">-session</span> tsx
</code></pre>
<p>The <code>typescript</code> package enables TypeScript in your project so you can write strongly typed code. The <code>@types/*</code> packages provide type definitions for Node.js, Express, and the session middleware, giving you better autocomplete and error checking in your editor.</p>
<p>Finally, <code>tsx</code> makes it super easy to run TypeScript files directly without the need to pre-compile them before running your app.</p>
<p>Then create a <code>.env</code> file in your project root and paste these variables:</p>
<pre><code class="lang-json"># Database
DATABASE_URL=postgresql:<span class="hljs-comment">//user:pass@host:port/db</span>

# Kinde Authentication
KINDE_ISSUER_URL=https:<span class="hljs-comment">//your-domain.kinde.com</span>
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret

# Security
JWT_SECRET=your_secret_key

# Environment
NODE_ENV=development
</code></pre>
<p>This stores all the credentials that you’ll be using for this project.</p>
<p>Next, create a <code>tsconfig.json</code> in the project root to tell the TypeScript compiler how to handle your code:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"compilerOptions"</span>: {
    <span class="hljs-attr">"target"</span>: <span class="hljs-string">"ES2022"</span>,
    <span class="hljs-attr">"module"</span>: <span class="hljs-string">"ESNext"</span>,
    <span class="hljs-attr">"moduleResolution"</span>: <span class="hljs-string">"node"</span>,
    <span class="hljs-attr">"strict"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"esModuleInterop"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"skipLibCheck"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"forceConsistentCasingInFileNames"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"outDir"</span>: <span class="hljs-string">"./dist"</span>,
    <span class="hljs-attr">"rootDir"</span>: <span class="hljs-string">"./src"</span>
  },
  <span class="hljs-attr">"include"</span>: [<span class="hljs-string">"src/**/*"</span>],
  <span class="hljs-attr">"exclude"</span>: [<span class="hljs-string">"node_modules"</span>, <span class="hljs-string">"dist"</span>]
}
</code></pre>
<p>Finally, update your <code>package.json</code> scripts</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"scripts"</span>: {
    <span class="hljs-attr">"build"</span>: <span class="hljs-string">"tsc"</span>,
    <span class="hljs-attr">"dev"</span>: <span class="hljs-string">"tsx src/server.ts"</span>,
    <span class="hljs-attr">"start"</span>: <span class="hljs-string">"node dist/server.js"</span>,
    <span class="hljs-attr">"auth-server"</span>: <span class="hljs-string">"tsx src/kinde-auth-server.ts"</span>,
    <span class="hljs-attr">"setup-db"</span>: <span class="hljs-string">"tsx src/setup-db.ts"</span>
  }
}
</code></pre>
<h2 id="heading-database-setup-with-neon-postgresql">Database Setup with Neon PostgreSQL</h2>
<p>To power your to-do MCP server, you’ll use Neon, a serverless PostgreSQL platform. This gives us a fully managed, scalable database without worrying about infrastructure.</p>
<h3 id="heading-1-connect-your-neon-database">1. Connect your Neon Database</h3>
<ul>
<li><p>Sign up or log in to your <a target="_blank" href="https://console.neon.tech">Neon account console</a>.</p>
</li>
<li><p>Create a new project.</p>
</li>
<li><p>Copy the connection string, you’ll need it in your <code>.env</code> file.</p>
</li>
</ul>
<h3 id="heading-2-create-your-db-file">2. Create your DB File</h3>
<p>Inside your project, create a new file in your <code>src/</code> folder and name it <code>setup-db.ts</code>. This file will create the tables, indexes, and schema your app relies on.</p>
<p>Database Architecture Overview:</p>
<pre><code class="lang-markdown">┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   setup-db.ts   │───▶│  Neon Database  │───▶│   PostgreSQL    │
│   (Schema)      │    │   (Serverless)  │    │   (Tables)      │
└─────────────────┘    └─────────────────┘    └─────────────────┘
</code></pre>
<h3 id="heading-3-step-by-step-breakdown-of-setup-dbts">3. Step-by-Step Breakdown of <code>setup-db.ts</code></h3>
<p><strong>Step 1: Imports and Database Connection</strong></p>
<p>Start by importing the packages you’ll need and setting up your database connection:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { neon } <span class="hljs-keyword">from</span> <span class="hljs-string">'@neondatabase/serverless'</span>;
<span class="hljs-keyword">import</span> dotenv <span class="hljs-keyword">from</span> <span class="hljs-string">'dotenv'</span>;

dotenv.config();

<span class="hljs-keyword">const</span> sql = neon(process.env.DATABASE_URL!);
</code></pre>
<p>The <code>dotenv</code> package loads your environment variables from a <code>.env</code> file so you don’t have to hardcode secrets in your code. The <code>neon</code> function connects your app to your Neon Postgres database, and the <code>sql</code> variable gives you a clean, type-safe way to run queries.</p>
<p>At this point, your app has everything it needs to talk to the database.</p>
<p><strong>Step 2: Main Setup Function</strong></p>
<p>Now let’s create a function to handle the database setup process:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">setupDatabase</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Setting up database schema...'</span>);
  <span class="hljs-keyword">try</span> {
    <span class="hljs-comment">// Database operations here</span>
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error setting up database:'</span>, error);
    process.exit(<span class="hljs-number">1</span>);
  }
}
</code></pre>
<p>This function keeps all your schema creation logic in one place, making it easy to manage. It also catches and logs any errors instead of failing silently, so you’ll immediately know if something goes wrong. The console messages give you real-time feedback as the setup runs which is super helpful when you’re debugging or deploying.</p>
<p><strong>Step 3: To-Dos Table</strong></p>
<p>Next, create a table to store all user to-dos:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE TABLE IF NOT EXISTS todos (
    id SERIAL PRIMARY KEY,
    user_id TEXT NOT NULL,
    title TEXT NOT NULL,
    description TEXT,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  )
`</span>;
</code></pre>
<p>This table holds every user’s tasks. The <code>user_id</code> column links each to-do to the user who created it, while the <code>completed</code> field tracks whether a task is done or still pending. The automatic <code>created_at</code> and <code>updated_at</code> timestamps make it easy to sort tasks or track their history over time.</p>
<p><strong>Step 4: Users Table</strong></p>
<p>Now, let’s define a table to manage user accounts and billing details:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    user_id TEXT UNIQUE NOT NULL,
    name TEXT,
    email TEXT,
    subscription_status TEXT DEFAULT 'free' CHECK (subscription_status IN ('free', 'active', 'cancelled')),
    plan TEXT DEFAULT 'free',
    free_todos_used INTEGER DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  )
`</span>;
</code></pre>
<p>This table stores each user’s basic information along with their subscription details. The <code>user_id</code> value comes directly from Kinde during authentication. The <code>subscription_status</code> and <code>free_todos_used</code> columns help you enforce billing tiers and limit how many free tasks a user can create before needing to upgrade.</p>
<p><strong>Step 5: Performance Indexes</strong></p>
<p>Next, let’s add a few indexes to make common database operations faster:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE INDEX IF NOT EXISTS idx_todos_user_id ON todos(user_id)
`</span>;

<span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at)
`</span>;

<span class="hljs-keyword">await</span> sql<span class="hljs-string">`
  CREATE INDEX IF NOT EXISTS idx_users_user_id ON users(user_id)
`</span>;
</code></pre>
<p>These indexes help speed up lookups and sorting. The first one lets the database quickly find to-dos that belong to a specific user. The second makes it faster to sort tasks by their creation date. And the last one allows fast lookups of users based on their Kinde <code>user_id</code>.</p>
<p><strong>Step 6: Success Logging</strong></p>
<p>After everything runs, it’s helpful to log a clear summary of what was created:</p>
<pre><code class="lang-typescript"><span class="hljs-built_in">console</span>.log(<span class="hljs-string">'✅ Database schema created successfully!'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'📋 Tables created:'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'  - todos (id, user_id, title, description, completed, created_at, updated_at)'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'  - users (id, user_id, subscription_status, plan, free_todos_used, created_at, updated_at)'</span>);
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🔍 Indexes created for optimal performance'</span>);
</code></pre>
<p>These logs give you immediate feedback once the setup completes. They show exactly which tables and indexes were created, making it easy to confirm that your database schema is ready to go and everything ran as expected.</p>
<p><strong>Step 7: Error Handling</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">try</span> {
} <span class="hljs-keyword">catch</span> (error) {
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'❌ Error setting up database:'</span>, error);
  process.exit(<span class="hljs-number">1</span>);
}
</code></pre>
<p>This handles any database setup errors gracefully.</p>
<p><strong>Step 8:</strong> Update your <code>.env</code> file in your project root with your Neon database connection string:</p>
<pre><code class="lang-json">DATABASE_URL=postgresql:<span class="hljs-comment">//username:password@host/database?sslmode=require</span>
</code></pre>
<p><strong>Step 9: Function Execution</strong></p>
<p>At the bottom of <code>setup-db.ts</code>, run the function:</p>
<pre><code class="lang-typescript">setupDatabase();
</code></pre>
<p>This immediately executes the database setup when the script runs.</p>
<p>Now, run this command in your CLI:</p>
<pre><code class="lang-powershell">npm run setup<span class="hljs-literal">-db</span>
</code></pre>
<p>Expected output:</p>
<pre><code class="lang-powershell">🚀 Setting up database schema...
✅ Database schema created successfully!
📋 Tables created:
  - todos (id, user_id, title, description, completed, created_at, updated_at)
  - users (id, user_id, subscription_status, plan, free_todos_used, created_at, updated_at)
🔍 Indexes created <span class="hljs-keyword">for</span> optimal performance
</code></pre>
<h3 id="heading-4-full-setup-dbts-file">4. Full <code>setup-db.ts</code> File</h3>
<p>You can view the complete implementation of the <code>setup-db.ts</code> file in the <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server/blob/main/src/setup-db.ts">GitHub repo</a> and copy it directly into your project.</p>
<h2 id="heading-authentication-with-kinde">Authentication with Kinde</h2>
<p>To secure your MCP server, you’ll use <a target="_blank" href="https://kinde.com">Kinde</a>, an authentication provider that makes it easy to handle logins, user sessions, and tokens.</p>
<p>You’ll also connect Kinde to your Neon database so new users are automatically created when they log in.</p>
<h3 id="heading-1-create-a-kinde-application">1. Create a Kinde Application</h3>
<ul>
<li><p>Go to the <a target="_blank" href="https://app.kinde.com/admin">Kinde Dashboard</a>.</p>
</li>
<li><p>Create a new application.</p>
</li>
<li><p>Note down these values (you’ll use them shortly):</p>
<ul>
<li><p><strong>Domain</strong>: <a target="_blank" href="https://your-domain.kinde.com"><code>https://your-domain.kinde.com</code></a></p>
</li>
<li><p><strong>Client ID</strong>: <code>your-client-id</code></p>
</li>
<li><p><strong>Client Secret</strong>: <code>your-client-secret</code></p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-2-configure-kinde-settings">2. Configure Kinde Settings</h3>
<p>In your Kinde dashboard, save these URLs as your:</p>
<ul>
<li><p>Redirect URL → <a target="_blank" href="http://localhost:3000/callback"><code>http://localhost:3000/callback</code></a></p>
</li>
<li><p>Logout URL → <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a></p>
</li>
</ul>
<h3 id="heading-3-environment-variables">3. Environment Variables</h3>
<p>Update the <code>.env</code> file in your project root with the credentials from your Kinde Dashboard:</p>
<pre><code class="lang-json">KINDE_ISSUER_URL=https:<span class="hljs-comment">//your-domain.kinde.com</span>
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret
</code></pre>
<h3 id="heading-4-create-the-kinde-auth-server">4. Create the Kinde Auth Server</h3>
<p>You’ll build an Express server (<code>src/kinde-auth-server.ts</code>) to handle authentication. This server will manage OAuth login and logout with Kinde, store user sessions, and automatically create or update users in your Neon database whenever they sign in.</p>
<p><strong>4.1: Dependencies and Setup</strong></p>
<p>Start by importing the packages you’ll need:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> session <span class="hljs-keyword">from</span> <span class="hljs-string">'express-session'</span>;
<span class="hljs-keyword">import</span> { createKindeServerClient, GrantType, SessionManager } <span class="hljs-keyword">from</span> <span class="hljs-string">'@kinde-oss/kinde-typescript-sdk'</span>;
<span class="hljs-keyword">import</span> jwt <span class="hljs-keyword">from</span> <span class="hljs-string">'jsonwebtoken'</span>;
<span class="hljs-keyword">import</span> dotenv <span class="hljs-keyword">from</span> <span class="hljs-string">'dotenv'</span>;
<span class="hljs-keyword">import</span> { neon } <span class="hljs-keyword">from</span> <span class="hljs-string">'@neondatabase/serverless'</span>;
</code></pre>
<p>The <code>express</code> import powers the web server that will handle authentication routes. <code>express-session</code> manages user sessions so you can persist login state between requests. The <code>@kinde-oss/kinde-typescript-sdk</code> package is the official Kinde SDK, which handles OAuth flows and user authentication.</p>
<p>You’ll use <code>jsonwebtoken</code> to decode and verify user tokens, while <code>dotenv</code> loads environment variables from your <code>.env</code> file. Finally, <code>@neondatabase/serverless</code> connects your server to the Neon Postgres database where user data will be stored.</p>
<p><strong>4.2: Connect to Your Database</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> sql = neon(process.env.DATABASE_URL!);
</code></pre>
<p>This initializes a type-safe SQL client using your <code>DATABASE_URL</code>.</p>
<p><strong>4.3: Extend login Session</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">declare</span> <span class="hljs-keyword">module</span> 'express-session' {
  <span class="hljs-keyword">interface</span> SessionData {
    accessToken?: <span class="hljs-built_in">string</span>;
    idToken?: <span class="hljs-built_in">string</span>;
    userInfo?: <span class="hljs-built_in">any</span>;
    userName?: <span class="hljs-built_in">string</span>;
    userEmail?: <span class="hljs-built_in">string</span>;
  }
}
</code></pre>
<p>This extends <code>express-session</code> types so you can store Kinde tokens and user info directly in the session.</p>
<p><strong>4.4: Configure Sessions</strong></p>
<p>Now, let’s configure session management so users can stay logged in across requests:</p>
<pre><code class="lang-typescript">app.use(session({
  secret: process.env.JWT_SECRET || <span class="hljs-string">'your_jwt_secret_key'</span>,
  resave: <span class="hljs-literal">true</span>,
  saveUninitialized: <span class="hljs-literal">true</span>,
  cookie: { 
    secure: <span class="hljs-literal">false</span>,
    maxAge: <span class="hljs-number">7</span> * <span class="hljs-number">24</span> * <span class="hljs-number">60</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>, <span class="hljs-comment">// 7 days</span>
    httpOnly: <span class="hljs-literal">true</span>,
    sameSite: <span class="hljs-string">'lax'</span>
  }
}));
</code></pre>
<p>The <code>secret</code> value is used to sign and verify session cookies, ensuring that sessions can’t be tampered with. The cookie settings keep users logged in for up to 7 days and make sure session tokens persist even after a browser refresh.</p>
<p>Setting <code>httpOnly</code> helps protect against cross-site scripting (XSS) attacks, while <code>sameSite: 'lax'</code> allows users to log in across different origins without breaking authentication.</p>
<p><strong>4.5: Create a Session Manager Factory</strong></p>
<p>Next, you’ll define a small helper function to manage session data for each request:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> createSessionManager = (req: <span class="hljs-built_in">any</span>): <span class="hljs-function"><span class="hljs-params">SessionManager</span> =&gt;</span> ({
  getSessionItem: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>) =&gt; req.session?.[key],
  setSessionItem: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>, value: <span class="hljs-built_in">any</span>) =&gt; {
    <span class="hljs-keyword">if</span> (!req.session) req.session = {};
    req.session[key] = value;
  },
  removeSessionItem: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>) =&gt; {
    <span class="hljs-keyword">if</span> (req.session) <span class="hljs-keyword">delete</span> req.session[key];
  },
  destroySession: <span class="hljs-keyword">async</span> () =&gt; {
    req.session = {};
  }
});
</code></pre>
<p>This function creates a session manager that’s tied to each request. It provides a consistent way to store, retrieve, and clear session data which is exactly what the Kinde SDK needs to keep track of tokens and user info during authentication.</p>
<p><strong>4.6: Create the Kinde Client</strong></p>
<p>Next, set up the Kinde client that will handle the OAuth login and logout flow:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> kindeClient = createKindeServerClient(GrantType.AUTHORIZATION_CODE, {
  authDomain: process.env.KINDE_ISSUER_URL!,
  clientId: process.env.KINDE_CLIENT_ID!,
  clientSecret: process.env.KINDE_CLIENT_SECRET!,
  redirectURL: <span class="hljs-string">'&lt;http://localhost:3000/callback&gt;'</span>,
  logoutRedirectURL: <span class="hljs-string">'&lt;http://localhost:3000&gt;'</span>,
});
</code></pre>
<p>This connects your app to Kinde using the <strong>Authorization Code</strong> grant type which is the most secure option for server-side applications like this one.</p>
<p>The <code>redirectURL</code> and <code>logoutRedirectURL</code> define where users should be sent after logging in or out.</p>
<p><strong>4.7: Create the Home Page Route (</strong><code>GET /</code><strong>)</strong></p>
<p>Then you’ll define a basic route for your home page that checks whether a user is logged in:</p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/'</span>, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> token = req.session?.accessToken;
  <span class="hljs-keyword">const</span> userInfo = req.session?.userInfo;

  <span class="hljs-keyword">if</span> (token) {
    <span class="hljs-comment">// Show logged-in state with tokens</span>
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// Show login button</span>
  }
});
</code></pre>
<p>This route reads the session to determine if a user is authenticated. If a token exists, you can display their account details or dashboard. Otherwise, you’ll show a login button that directs them to Kinde’s sign-in page.</p>
<p><strong>4.8: Create the Login Route (</strong><code>GET /login</code><strong>)</strong></p>
<p>Now, add a route that starts the OAuth login process:</p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/login'</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> sessionManager = createSessionManager(req);
    <span class="hljs-keyword">const</span> loginUrl = <span class="hljs-keyword">await</span> kindeClient.login(sessionManager);
    res.redirect(loginUrl.toString());
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Login error:'</span>, error);
    res.status(<span class="hljs-number">500</span>).send(<span class="hljs-string">'Login failed'</span>);
  }
});
</code></pre>
<p>When users visit this route, your app uses the Kinde client to generate a secure login URL and redirects them to Kinde’s hosted login page. Once they log in, Kinde will send them back to your callback route with the necessary tokens.</p>
<p><strong>4.9: OAuth Callback Route (</strong><code>GET /callback</code><strong>)</strong></p>
<p>This is where Kinde redirects users back after login. First, you’ll get the authorization <code>code</code> from Kinde for token exchange:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> fullUrl = <span class="hljs-string">`http://<span class="hljs-subst">${req.headers.host}</span><span class="hljs-subst">${req.url}</span>`</span>;
<span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> URL(fullUrl);
<span class="hljs-keyword">const</span> code = url.searchParams.get(<span class="hljs-string">'code'</span>);
</code></pre>
<p>Next, you’ll exchange this code for tokens that will be needed for API calls:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> tokenResponse = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`<span class="hljs-subst">${process.env.KINDE_ISSUER_URL}</span>/oauth2/token`</span>, {
  method: <span class="hljs-string">'POST'</span>,
  headers: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/x-www-form-urlencoded'</span> },
  body: <span class="hljs-keyword">new</span> URLSearchParams({
    grant_type: <span class="hljs-string">'authorization_code'</span>,
    client_id: process.env.KINDE_CLIENT_ID!,
    client_secret: process.env.KINDE_CLIENT_SECRET!,
    code: code,
    redirect_uri: <span class="hljs-string">'http://localhost:3000/callback'</span>,
  }),
});
</code></pre>
<p>Then, you’ll store these tokens in the session for future requests so users don't need to login every time:</p>
<pre><code class="lang-typescript">req.session.accessToken = tokenData.access_token;
req.session.idToken = tokenData.id_token;
req.session.userInfo = tokenData;
</code></pre>
<p>The next thing you’ll do is to extract the user’s information from JWT and then store this in your database</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> user = <span class="hljs-built_in">JSON</span>.parse(Buffer.from(idToken.split(<span class="hljs-string">'.'</span>)[<span class="hljs-number">1</span>], <span class="hljs-string">'base64'</span>).toString());
req.session.userName = user.given_name || user.name || <span class="hljs-string">'User'</span>;
req.session.userEmail = user.email || <span class="hljs-string">'user@example.com'</span>;
</code></pre>
<p>Finally, add this code that will automatically create or update the user in the database so your MCP server can track to-dos and billing:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> existingUser = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`SELECT * FROM users WHERE user_id = <span class="hljs-subst">${userId}</span>`</span>;

<span class="hljs-keyword">if</span> (existingUser.length === <span class="hljs-number">0</span>) {
  <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    INSERT INTO users (user_id, name, email, subscription_status, plan, free_todos_used)
    VALUES (<span class="hljs-subst">${userId}</span>, <span class="hljs-subst">${userName}</span>, <span class="hljs-subst">${userEmail}</span>, 'free', 'free', 0)
  `</span>;
} <span class="hljs-keyword">else</span> {
  <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    UPDATE users 
    SET name = <span class="hljs-subst">${userName}</span>, email = <span class="hljs-subst">${userEmail}</span>
    WHERE user_id = <span class="hljs-subst">${userId}</span>
  `</span>;
}
</code></pre>
<p><strong>4.10: Create the Logout Route (</strong><code>GET /logout</code><strong>)</strong></p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/logout'</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">try</span> {
    req.session.destroy(<span class="hljs-function">(<span class="hljs-params">err</span>) =&gt;</span> {
      <span class="hljs-keyword">if</span> (err) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Session destroy error:'</span>, err);
      }
      res.redirect(<span class="hljs-string">'/'</span>);
    });
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Logout error:'</span>, error);
    res.status(<span class="hljs-number">500</span>).send(<span class="hljs-string">'Logout failed'</span>);
  }
});
</code></pre>
<p>This route simply destroys the user’s session, removing all stored tokens and user information. Once the session is cleared, it redirects the user back to the home page, effectively logging them out of the app.</p>
<h3 id="heading-5-complete-authentication-flow">5. Complete Authentication Flow</h3>
<pre><code class="lang-markdown"><span class="hljs-bullet">1.</span> User visits / → sees login button.
<span class="hljs-bullet">2.</span> Clicks login → goes to Kinde.
<span class="hljs-bullet">3.</span> Logs in → redirected back to /callback.
<span class="hljs-bullet">4.</span> Callback exchanges code for tokens.
<span class="hljs-bullet">5.</span> Tokens stored in session.
<span class="hljs-bullet">6.</span> User is created/updated in database.
<span class="hljs-bullet">7.</span> User can now access the MCP server.
</code></pre>
<h3 id="heading-6-why-this-matters">6. Why This Matters</h3>
<p>By placing Kinde in front of your MCP server, you get a secure and seamless authentication layer without the extra trouble of handling passwords or tokens manually. Your users can log in safely, and their sessions persist across page refreshes without the need to log in again each time they revisit your app.</p>
<p>Every new user who signs in is automatically added to your Neon database, making it easy to track accounts and usage. This setup also lays the groundwork for more advanced features later on, like enforcing billing limits or managing user-specific to-dos.</p>
<h3 id="heading-7-key-connections">7. Key Connections</h3>
<ul>
<li><p><strong>Session ↔ Database</strong>: Sync user data</p>
</li>
<li><p><strong>Kinde ↔ Session</strong>: Tokens flow from Kinde to session storage</p>
</li>
<li><p><strong>Session ↔ MCP</strong>: Tokens passed into the server for access control</p>
</li>
<li><p><strong>Database ↔ MCP</strong>: User billing + to-dos read from Neon</p>
</li>
</ul>
<h3 id="heading-8-full-kinde-auth-serverts-file">8. Full <code>kinde-auth-server.ts</code> File</h3>
<p>You can view the complete implementation of the <code>kinde-auth-server.ts</code> file in the <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server/blob/main/src/kinde-auth-server.ts">GitHub repo</a> and copy it directly into your project.</p>
<h2 id="heading-mcp-server-implementation-with-billing-system-integration">MCP Server Implementation (with Billing System Integration)</h2>
<p>Now it’s time to create the main file for your MCP server. This file acts as the entry point, wiring up your database, authentication, tool handlers, and overall flow into a single server.</p>
<h3 id="heading-1-create-your-file">1. Create Your File</h3>
<p>Inside your project, create a new file:</p>
<pre><code class="lang-bash">src/server.ts
</code></pre>
<p>This file will contain the full implementation of your MCP server.</p>
<h3 id="heading-2-project-setup-and-imports">2. Project Setup and Imports</h3>
<p>At the top of the file, import the dependencies you’ll need:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Server } <span class="hljs-keyword">from</span> <span class="hljs-string">'@modelcontextprotocol/sdk/server/index.js'</span>;
<span class="hljs-keyword">import</span> { StdioServerTransport } <span class="hljs-keyword">from</span> <span class="hljs-string">'@modelcontextprotocol/sdk/server/stdio.js'</span>;
<span class="hljs-keyword">import</span> { CallToolRequestSchema, ListToolsRequestSchema } <span class="hljs-keyword">from</span> <span class="hljs-string">'@modelcontextprotocol/sdk/types.js'</span>;
<span class="hljs-keyword">import</span> { neon } <span class="hljs-keyword">from</span> <span class="hljs-string">'@neondatabase/serverless'</span>;
<span class="hljs-keyword">import</span> jwt <span class="hljs-keyword">from</span> <span class="hljs-string">'jsonwebtoken'</span>;
<span class="hljs-keyword">import</span> fs <span class="hljs-keyword">from</span> <span class="hljs-string">'fs'</span>;
<span class="hljs-keyword">import</span> path <span class="hljs-keyword">from</span> <span class="hljs-string">'path'</span>;
</code></pre>
<p>Each import in your MCP server has a specific purpose. The <code>Server</code> class is the foundation that powers your entire implementation. It listens for requests, manages responses, and keeps track of all registered tools. The <code>StdioServerTransport</code> handles communication between your MCP server and other tools through standard input and output, which is exactly how Cursor connects behind the scenes.</p>
<p>The <code>CallToolRequestSchema</code> and <code>ListToolsRequestSchema</code> act as validators, ensuring every incoming request follows the correct structure before it’s processed. This reduces errors and keeps communication between your tools and the MCP client consistent.</p>
<p><code>neon</code> connects your server to the Neon PostgreSQL database, providing a clean way to manage persistent data like users and to-dos. The <code>jsonwebtoken</code> library decodes and verifies tokens from Kinde, letting you identify and authenticate users securely.</p>
<p><code>fs</code> is used to read and write authentication tokens locally, which means users don’t need to log in every time. Finally, <code>path</code> helps manage file paths cleanly across different systems, keeping everything organized and portable.</p>
<p>Together, these imports form the backbone of your server’s logic, handling authentication, database access, and reliable communication with Cursor.</p>
<h3 id="heading-3-database-connection-and-configuration">3. Database Connection and Configuration</h3>
<p>Next, configure your database connection and token storage:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> sql = neon(process.env.DATABASE_URL!);
<span class="hljs-keyword">const</span> TOKEN_FILE = <span class="hljs-string">'.auth-token'</span>;
</code></pre>
<p>The <code>sql</code> constant creates a live connection to your Neon PostgreSQL database using the <code>DATABASE_URL</code> environment variable. Think of it as the bridge that lets your MCP server talk to your database with every query, insert, and update running through this connection.</p>
<p>It’s what allows your server to persist user data, to-dos, and billing information reliably without having to manage complex configurations manually.</p>
<p>The <code>TOKEN_FILE</code> constant, on the other hand, acts as a lightweight local storage system for authentication tokens. Whenever a user logs in, their token is saved here so they don’t have to reauthenticate every time they restart the server.</p>
<p>It’s a simple but effective way to maintain session continuity, especially during local development or testing.</p>
<h3 id="heading-4-authentication-system">4. Authentication System</h3>
<p>To manage tokens, you’ll add three helper functions:</p>
<p><strong>4.1. Get Stored Token</strong></p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getStoredToken</span>(<span class="hljs-params"></span>): <span class="hljs-title">string</span> | <span class="hljs-title">null</span> </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">if</span> (fs.existsSync(TOKEN_FILE)) {
      <span class="hljs-keyword">return</span> fs.readFileSync(TOKEN_FILE, <span class="hljs-string">'utf8'</span>).trim();
    }
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error reading token file:'</span>, error);
  }
  <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
}
</code></pre>
<p>Retrieves a saved JWT token from the local file system.</p>
<p><strong>4.2. Store Token</strong></p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">storeToken</span>(<span class="hljs-params">token: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">void</span> </span>{
  <span class="hljs-keyword">try</span> {
    fs.writeFileSync(TOKEN_FILE, token);
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error storing token:'</span>, error);
  }
}
</code></pre>
<p>Stores a new JWT token locally so authentication persists across server restarts.</p>
<p><strong>4.3. Decode JWT</strong></p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">decodeJWT</span>(<span class="hljs-params">token: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">any</span> </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">return</span> jwt.decode(token);
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error decoding JWT:'</span>, error);
    <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
  }
}
</code></pre>
<p>Decodes JWTs to extract user info such as ID, email, and subscription status.</p>
<h3 id="heading-5-core-helper-functions">5. Core Helper Functions</h3>
<p><strong>5.1. Check Billing Status</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getKindeBillingStatus</span>(<span class="hljs-params">userId: <span class="hljs-built_in">string</span>, accessToken: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">Promise</span>&lt;</span>{ plan: <span class="hljs-built_in">string</span>; features: <span class="hljs-built_in">any</span>; canCreate: <span class="hljs-built_in">boolean</span>; reason?: <span class="hljs-built_in">string</span> }&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> decoded = jwt.decode(accessToken) <span class="hljs-keyword">as</span> <span class="hljs-built_in">any</span>;
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🔍 JWT Token data for user:'</span>, userId, <span class="hljs-string">'Decoded:'</span>, decoded);

    <span class="hljs-keyword">const</span> subscription = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
      SELECT * FROM users 
      WHERE user_id = <span class="hljs-subst">${userId}</span>
    `</span>;

    <span class="hljs-keyword">if</span> (subscription.length === <span class="hljs-number">0</span>) {
      <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
        INSERT INTO users (user_id, name, email, subscription_status, plan, free_todos_used)
        VALUES (<span class="hljs-subst">${userId}</span>, <span class="hljs-subst">${decoded.given_name || decoded.name || <span class="hljs-string">'User'</span>}</span>, <span class="hljs-subst">${decoded.email || <span class="hljs-string">'user@example.com'</span>}</span>, 'free', 'free', 0)
      `</span>;
      <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'👤 New user created:'</span>, decoded.given_name || decoded.name, decoded.email);
    }

    <span class="hljs-keyword">const</span> freeTodosUsed = subscription.length &gt; <span class="hljs-number">0</span> ? subscription[<span class="hljs-number">0</span>].free_todos_used : <span class="hljs-number">0</span>;

    <span class="hljs-keyword">if</span> (freeTodosUsed &lt; <span class="hljs-number">1</span>) {
      <span class="hljs-keyword">return</span> {
        plan: <span class="hljs-string">'free'</span>,
        features: { maxTodos: <span class="hljs-number">1</span>, used: freeTodosUsed },
        canCreate: <span class="hljs-literal">true</span>,
        reason: <span class="hljs-string">`Free tier - <span class="hljs-subst">${<span class="hljs-number">1</span> - freeTodosUsed}</span> todo remaining`</span>
      };
    }

    <span class="hljs-keyword">return</span> {
      plan: <span class="hljs-string">'free'</span>,
      features: { maxTodos: <span class="hljs-number">1</span>, used: freeTodosUsed },
      canCreate: <span class="hljs-literal">false</span>,
      reason: <span class="hljs-string">'You have used your free todo. Please upgrade your plan at &lt;https://learnflowai.kinde.com/portal&gt; to create more todos.'</span>
    };
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error checking Kinde billing:'</span>, error);
    <span class="hljs-keyword">return</span> {
      plan: <span class="hljs-string">'free'</span>,
      features: { maxTodos: <span class="hljs-number">1</span> },
      canCreate: <span class="hljs-literal">false</span>,
      reason: <span class="hljs-string">'Error checking billing status'</span>
    };
  }
}
</code></pre>
<p>This checks a user's billing status and enforces a 1-todo free tier. It also auto-creates a user in the database if it doesn’t exist.</p>
<p><strong>5.2. Check To-Do Permission</strong></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">canCreateTodo</span>(<span class="hljs-params">userId: <span class="hljs-built_in">string</span>, accessToken: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">boolean</span>&gt; </span>{
  <span class="hljs-keyword">const</span> billingStatus = <span class="hljs-keyword">await</span> getKindeBillingStatus(userId, accessToken);
  <span class="hljs-keyword">return</span> billingStatus.canCreate;
}
</code></pre>
<p>This is a simple wrapper that returns a boolean for permission checks.</p>
<h3 id="heading-6-core-server-implementation">6. Core Server Implementation</h3>
<p>Initialize your MCP server:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> server = <span class="hljs-keyword">new</span> Server(
  {
    name: <span class="hljs-string">'todo-mcp-server'</span>,
    version: <span class="hljs-string">'1.0.0'</span>,
  },
  {
    capabilities: {
      tools: {},
    },
  }
);
</code></pre>
<p>This declares a new MCP server with the capability to register tools.</p>
<h3 id="heading-7-register-tools">7. Register Tools</h3>
<p>Every tool that your Cursor IDE can use needs to be listed so it knows what’s available. In this part of the server setup, you’re registering all the tools that your MCP server will expose. Think of it like giving Cursor a menu of what your backend can do.</p>
<p>Here’s how that looks in code:</p>
<pre><code class="lang-typescript">server.setRequestHandler(ListToolsRequestSchema, <span class="hljs-keyword">async</span> () =&gt; {
  <span class="hljs-keyword">return</span> {
    tools: [
      {
        name: <span class="hljs-string">'login'</span>,
        description: <span class="hljs-string">'Get authentication URL for Kinde login'</span>,
        inputSchema: {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'object'</span>,
          properties: {},
        },
      },
      <span class="hljs-comment">// ... more tools</span>
    ],
  };
});
</code></pre>
<p>Tools you’ll create include:</p>
<ul>
<li><p>Authentication → <code>login</code>, <code>save_token</code>, <code>logout</code></p>
</li>
<li><p>To-Do Management → <code>list_todos</code>, <code>create_todo</code>, <code>update_todo</code>, <code>delete_todo</code></p>
</li>
<li><p>Billing → <code>refresh_billing_status</code></p>
</li>
</ul>
<h3 id="heading-8-tool-handlers">8. Tool Handlers</h3>
<p>Each tool in your MCP server has its own handler. The handler checks if the user is authenticated, talks to the database to perform the request, and returns a clean, structured response that Cursor can display.</p>
<p>This keeps the server organized, secure, and easy to extend later.</p>
<p><strong>8.1. Login Tool</strong></p>
<p>The <code>login</code> tool is responsible for starting the authentication flow with Kinde. When users call it, the server returns a short message explaining how to sign in and store their token.</p>
<p>Once logged in, it lists a few commands that they user can try.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'login'</span>: {
  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-string">`🔐 **Authentication Required**

To use this MCP server, you need to authenticate with Kinde:

1. **Open your browser** and go to: &lt;http://localhost:3000&gt;
2. **Click "Login with Kinde"** to authenticate
3. **Copy your ID Token** from the page
4. **Use the save_token tool** to store it

**Note:** Make sure the Kinde auth server is running:
\\`</span>\\<span class="hljs-string">`\\`</span>bash
npm run auth-server
\\<span class="hljs-string">`\\`</span>\\<span class="hljs-string">`

After authentication, you can use commands like:
- \\`</span>list todos\\<span class="hljs-string">` - List your todos
- \\`</span>create todo\\<span class="hljs-string">` - Create a new todo
- \\`</span>refresh billing status\\<span class="hljs-string">` - Check your plan status`</span>,
      },
    ],
  };
}
</code></pre>
<p><strong>8.2. Save Token Tool</strong></p>
<p>The <code>save_token</code> tool handles storing the user’s authentication token locally so they don’t need to re-authenticate each time they use the MCP server.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'save_token'</span>: {
  <span class="hljs-keyword">const</span> { token } = args <span class="hljs-keyword">as</span> { token: <span class="hljs-built_in">string</span> };
  storeToken(token);
  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-string">'✅ Token saved successfully! You can now use commands like "list todos" and "create todo" without providing the token each time.'</span>,
      },
    ],
  };
}
</code></pre>
<p>When a user runs this command and passes in their token, the server saves it to a local file using the <code>storeToken()</code> function. From then on, every other command (like <code>list todos</code>, <code>create todo</code>, or <code>refresh billing status</code>) can automatically authenticate using that stored token.</p>
<p>This small step makes the development flow smoother and keeps authentication persistent across sessions.</p>
<p><strong>8.3. List To-Dos Tool</strong></p>
<p>The <code>list_todos</code> tool retrieves all to-dos that belong to the currently authenticated user. It first checks for a stored authentication token and decodes it to identify the user. If the token is missing or invalid, it asks the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'list_todos'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please:\\n1. Type "login" to get the authentication URL\\n2. Complete login at &lt;http://localhost:3000&gt;\\n3. Copy your token and use "save_token" to store it\\n4. Then try "list todos" again'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again using the "login" command.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> todos = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    SELECT * FROM todos 
    WHERE user_id = <span class="hljs-subst">${decoded.sub}</span> 
    ORDER BY created_at DESC
  `</span>;

  <span class="hljs-keyword">if</span> (todos.length === <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'📝 No todos found. Create your first todo using "create todo"!'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> todosList = todos.map(<span class="hljs-function">(<span class="hljs-params">todo: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
    <span class="hljs-string">`**<span class="hljs-subst">${todo.id}</span>.** <span class="hljs-subst">${todo.title}</span><span class="hljs-subst">${todo.description ? <span class="hljs-string">` - <span class="hljs-subst">${todo.description}</span>`</span> : <span class="hljs-string">''</span>}</span> <span class="hljs-subst">${todo.completed ? <span class="hljs-string">'✅'</span> : <span class="hljs-string">'⏳'</span>}</span>`</span>
  ).join(<span class="hljs-string">'\\n'</span>);

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-string">`📝 **Your Todos (<span class="hljs-subst">${todos.length}</span>):**\\n\\n<span class="hljs-subst">${todosList}</span>`</span>,
      },
    ],
  };
}
</code></pre>
<p>Results from this tool are formatted for easy reading in the MCP client, showing each task’s title, description, and completion status. If there are no to-dos yet, it simply prompts the user to create one.</p>
<p><strong>8.4. Create To-Do Tool</strong></p>
<p>The <code>create_todo</code> tool lets authenticated users add new to-dos to their list. It starts by verifying that a valid token exists, ensuring only logged-in users can create tasks. If the token is present, it checks billing limits otherwise, it instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'create_todo'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> { title, description, completed } = args <span class="hljs-keyword">as</span> { 
    title: <span class="hljs-built_in">string</span>; 
    description?: <span class="hljs-built_in">string</span>; 
    completed?: <span class="hljs-built_in">boolean</span>; 
  };

  <span class="hljs-comment">// Check billing status before creating todo</span>
  <span class="hljs-keyword">const</span> canCreate = <span class="hljs-keyword">await</span> canCreateTodo(decoded.sub, token);
  <span class="hljs-keyword">if</span> (!canCreate) {
    <span class="hljs-keyword">const</span> billingStatus = <span class="hljs-keyword">await</span> getKindeBillingStatus(decoded.sub, token);
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">`🚫 **Cannot create todo**

<span class="hljs-subst">${billingStatus.reason}</span>

**Upgrade your plan:** &lt;https://learnflowai.kinde.com/portal`</span>&gt;,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    INSERT INTO todos (user_id, title, description, completed)
    VALUES (<span class="hljs-subst">${decoded.sub}</span>, <span class="hljs-subst">${title}</span>, <span class="hljs-subst">${description || <span class="hljs-literal">null</span>}</span>, <span class="hljs-subst">${completed || <span class="hljs-literal">false</span>}</span>)
    RETURNING *
  `</span>;

  <span class="hljs-comment">// Update free todos used count</span>
  <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    UPDATE users 
    SET free_todos_used = free_todos_used + 1 
    WHERE user_id = <span class="hljs-subst">${decoded.sub}</span>
  `</span>;

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          todoId: result[<span class="hljs-number">0</span>].id,
          message: <span class="hljs-string">'Todo created successfully'</span>,
          title: result[<span class="hljs-number">0</span>].title,
          description: result[<span class="hljs-number">0</span>].description,
          completed: result[<span class="hljs-number">0</span>].completed
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>If the user has hit their limit after calling this tool, it returns a clear message explaining why and provides a link to upgrade their plan.</p>
<p><strong>8.5. Update To-Do Tool</strong></p>
<p>The <code>update_todo</code> tool allows an authenticated user to modify an existing to-do’s title, description, or completion status. It first checks for a valid token and decodes it to identify the user. If authentication fails, the tool instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'update_todo'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> { todoId, title, description, completed } = args <span class="hljs-keyword">as</span> { 
    todoId: <span class="hljs-built_in">number</span>; 
    title?: <span class="hljs-built_in">string</span>; 
    description?: <span class="hljs-built_in">string</span>; 
    completed?: <span class="hljs-built_in">boolean</span>; 
  };

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    UPDATE todos 
    SET 
      title = COALESCE(<span class="hljs-subst">${title || <span class="hljs-literal">null</span>}</span>, title),
      description = COALESCE(<span class="hljs-subst">${description || <span class="hljs-literal">null</span>}</span>, description),
      completed = COALESCE(<span class="hljs-subst">${completed !== <span class="hljs-literal">undefined</span> ? completed : <span class="hljs-literal">null</span>}</span>, completed),
      updated_at = CURRENT_TIMESTAMP
    WHERE id = <span class="hljs-subst">${todoId}</span> AND user_id = <span class="hljs-subst">${decoded.sub}</span>
    RETURNING *
  `</span>;

  <span class="hljs-keyword">if</span> (result.length === <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Todo not found or you do not have permission to update it.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          message: <span class="hljs-string">'Todo updated successfully'</span>,
          todo: result[<span class="hljs-number">0</span>]
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>Once the token is verified, the server updates the specified to-do in the database, using <code>COALESCE</code> to leave any fields unchanged if no new value is provided. The <code>updated_at</code> timestamp is refreshed automatically.</p>
<p>If the to-do doesn’t exist or the user doesn’t have permission to modify it, the tool returns an error message. Otherwise, it responds with the updated to-do in a clean JSON format:</p>
<p><strong>8.6. Delete To-Do Tool</strong></p>
<p>The <code>delete_todo</code> tool allows an authenticated user to remove a specific to-do. It first checks for a valid token and decodes it to identify the user. If the token is missing or invalid, the tool instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'delete_todo'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> { todoId } = args <span class="hljs-keyword">as</span> { todoId: <span class="hljs-built_in">number</span> };

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> sql<span class="hljs-string">`
    DELETE FROM todos 
    WHERE id = <span class="hljs-subst">${todoId}</span> AND user_id = <span class="hljs-subst">${decoded.sub}</span>
    RETURNING *
  `</span>;

  <span class="hljs-keyword">if</span> (result.length === <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Todo not found or you do not have permission to delete it.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          message: <span class="hljs-string">'Todo deleted successfully'</span>,
          deletedTodo: result[<span class="hljs-number">0</span>]
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>This ensures users can only delete their own tasks, keeps the response structured for easy consumption in Cursor, and provides immediate confirmation of the deletion.</p>
<p><strong>8.7. Refresh Billing Status Tool</strong></p>
<p>The <code>refresh_billing_status</code> tool allows an authenticated user to force a fresh check of their billing status from Kinde. It first verifies that a valid token exists and decodes it to identify the user. If the token is missing or invalid, the tool instructs the user to log in again.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'refresh_billing_status'</span>: {
  <span class="hljs-keyword">const</span> token = getStoredToken();
  <span class="hljs-keyword">if</span> (!token) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ No authentication token found. Please login first.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-keyword">const</span> decoded = decodeJWT(token);
  <span class="hljs-keyword">if</span> (!decoded || !decoded.sub) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'❌ Invalid token. Please login again.'</span>,
        },
      ],
    ];
  }

  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🔄 Force refreshing billing status for user:'</span>, decoded.sub);
  <span class="hljs-keyword">const</span> billingStatus = <span class="hljs-keyword">await</span> getKindeBillingStatus(decoded.sub, token);

  <span class="hljs-keyword">return</span> {
    content: [
      {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
        text: <span class="hljs-built_in">JSON</span>.stringify({
          success: <span class="hljs-literal">true</span>,
          message: <span class="hljs-string">'Billing status refreshed successfully!'</span>,
          kindeBilling: {
            plan: billingStatus.plan,
            features: billingStatus.features,
            canCreate: billingStatus.canCreate,
            reason: billingStatus.reason,
            upgradeUrl: <span class="hljs-string">'&lt;https://learnflowai.kinde.com/portal&gt;'</span>,
            selfServicePortal: <span class="hljs-string">'&lt;https://learnflowai.kinde.com/portal&gt;'</span>,
            lastChecked: <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString()
          }
        }, <span class="hljs-literal">null</span>, <span class="hljs-number">2</span>),
      },
    ],
  };
}
</code></pre>
<p>This tool ensures that users always have an up-to-date view of their subscription status and usage limits, providing all necessary information to decide whether they need to upgrade their plan.</p>
<p><strong>8.8. Logout Tool</strong></p>
<p>The <code>logout</code> tool lets a user end their session by clearing the locally stored authentication token. When called, it checks if the token file exists and deletes it. If successful, the tool confirms that the user has been logged out.</p>
<p>Here’s the handler:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">case</span> <span class="hljs-string">'logout'</span>: {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">if</span> (fs.existsSync(TOKEN_FILE)) {
      fs.unlinkSync(TOKEN_FILE);
    }
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'✅ Logged out successfully. Authentication token cleared.'</span>,
        },
      ],
    ];
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-keyword">return</span> {
      content: [
        {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'text'</span>,
          text: <span class="hljs-string">'⚠️ Logout completed, but there was an issue clearing the token file.'</span>,
        },
      ],
    };
  }
}
</code></pre>
<p>This ensures that the session ends cleanly, preventing further use of the MCP server until the user logs in again. It also provides immediate feedback so the user knows the logout process succeeded or if there were minor issues.</p>
<h3 id="heading-9-full-serverts-file">9. Full <code>server.ts</code> File</h3>
<p>You can view the complete implementation of the <code>server.ts</code> file in the <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server/blob/main/src/server.ts">GitHub repo</a> and copy it directly into your project.</p>
<h3 id="heading-10-data-flow-amp-integration">10. Data Flow &amp; Integration</h3>
<p>Now that you’ve wired up authentication, database persistence, and billing checks, let’s step back and look at how everything fits together.</p>
<p>Authentication Flow:</p>
<ul>
<li><p>User clicks <strong>Login with Kinde</strong>.</p>
</li>
<li><p>They are redirected to the <strong>Kinde Auth URL.</strong></p>
</li>
<li><p>Kinde issues a <strong>JWT token</strong> after successful login.</p>
</li>
<li><p>The user copies this token and runs <em>“save_token: &lt;your-jwt-token&gt;”</em> in the Cursor Chat to store it.</p>
</li>
<li><p>The token is stored in the user’s session.</p>
</li>
<li><p>All future requests include the token for validation.</p>
</li>
</ul>
<p>To-do Flow:</p>
<ul>
<li><p>User sends a request (for example, to <em>“create todo”</em> to create a new todo).</p>
</li>
<li><p>The server checks the session for a valid token.</p>
</li>
<li><p>The server verifies the user’s billing plan and usage limits.</p>
</li>
<li><p>If valid, the request hits the <strong>Neon Postgres database</strong>.</p>
</li>
<li><p>Usage counters are updated and a success response is returned.</p>
</li>
</ul>
<p>Complete Data Flow:</p>
<pre><code class="lang-markdown">User Input 
   → MCP Server 
<span class="hljs-code">      → Authentication Check
         → Billing Check
            → Database Operation
               → Response</span>
</code></pre>
<p>Diagram:</p>
<pre><code class="lang-mermaid">flowchart LR
    A[User Input] --&gt; B[MCP Server]
    B --&gt; C{Authenticated?}
    C -- No --&gt; D[Reject Request]
    C -- Yes --&gt; E{Billing OK?}
    E -- No --&gt; F[Reject Request]
    E -- Yes --&gt; G[Database Operation]
    G --&gt; H[Update Usage + Return Response]
</code></pre>
<p>This overview shows how the different components work together. Each feature you’ve added (authentication, billing, and persistence) acts as a checkpoint in the request flow.</p>
<h3 id="heading-11-error-handling-amp-security">11. Error Handling &amp; Security</h3>
<p>Authentication Security:</p>
<ul>
<li><p>JWT Validation: Every request validates JWT token</p>
</li>
<li><p>User Isolation: Users can only access their own to-dos</p>
</li>
<li><p>Token Storage: Tokens are stored locally, not in your database</p>
</li>
</ul>
<p>Database Security:</p>
<ul>
<li><p>SQL Injection Prevention: Your MCP server uses parameterized queries</p>
</li>
<li><p>User Scoping: All queries are filtered by <code>user_id</code></p>
</li>
<li><p>Permission Checks: Every operation validates user ownership</p>
</li>
</ul>
<p>Error Handling:</p>
<ul>
<li><code>try/catch</code> returns safe error messages</li>
</ul>
<h3 id="heading-12-testing-amp-deployment">12. Testing &amp; Deployment</h3>
<p>Finally, start the MCP server:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">main</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> transport = <span class="hljs-keyword">new</span> StdioServerTransport();
  <span class="hljs-keyword">await</span> server.connect(transport);
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Todo MCP server running on stdio'</span>);
}

main().catch(<span class="hljs-function">(<span class="hljs-params">error</span>) =&gt;</span> {
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Fatal error in main():'</span>, error);
  process.exit(<span class="hljs-number">1</span>);
});
</code></pre>
<p>Test it by running:</p>
<pre><code class="lang-powershell">npm run dev
</code></pre>
<p>In Cursor, you can now try commands like:</p>
<pre><code class="lang-markdown">login
save<span class="hljs-emphasis">_token: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">your-jwt-token</span>&gt;</span></span>
list todos
create todo
refresh billing status
logout</span>
</code></pre>
<h2 id="heading-testing-the-complete-system"><strong>Testing the Complete System</strong></h2>
<h3 id="heading-1-start-the-services">1. Start the Services</h3>
<pre><code class="lang-powershell"><span class="hljs-comment"># Terminal 1: Start MCP Server</span>
npm run dev

<span class="hljs-comment"># Terminal 2: Start Kinde Auth Server</span>
npm run auth<span class="hljs-literal">-server</span>
</code></pre>
<h3 id="heading-2-configure-cursor-mcp">2. Configure Cursor MCP</h3>
<p>In your Cursor project:</p>
<ul>
<li><p>Go to Settings → Tools &amp; MCP → New MCP Server</p>
</li>
<li><p>Edit the <code>~/.cursor/mcp.json</code> and paste this code below</p>
</li>
</ul>
<pre><code class="lang-json">{
  <span class="hljs-attr">"mcpServers"</span>: {
    <span class="hljs-attr">"todo-mcp-server"</span>: {
      <span class="hljs-attr">"command"</span>: <span class="hljs-string">"node"</span>,
      <span class="hljs-attr">"args"</span>: [<span class="hljs-string">"dist/server.js"</span>],
      <span class="hljs-attr">"cwd"</span>: <span class="hljs-string">"/path/to/your/todo-mcp-server"</span>,
      <span class="hljs-attr">"env"</span>: {
        <span class="hljs-attr">"DATABASE_URL"</span>: <span class="hljs-string">"your-neon-connection-string"</span>,
        <span class="hljs-attr">"KINDE_ISSUER_URL"</span>: <span class="hljs-string">"&lt;https://your-domain.kinde.com&gt;"</span>,
        <span class="hljs-attr">"KINDE_CLIENT_ID"</span>: <span class="hljs-string">"your-client-id"</span>,
        <span class="hljs-attr">"KINDE_CLIENT_SECRET"</span>: <span class="hljs-string">"your-client-secret"</span>,
        <span class="hljs-attr">"JWT_SECRET"</span>: <span class="hljs-string">"your-jwt-secret-key"</span>,
        <span class="hljs-attr">"NODE_ENV"</span>: <span class="hljs-string">"development"</span>
      }
    }
  }
}
</code></pre>
<h3 id="heading-3-test-the-complete-flow">3. Test the Complete Flow</h3>
<p>Open your Cursor chat window and test MCP commands:</p>
<ul>
<li><p><em>login</em> → Get authentication URL</p>
</li>
<li><p><em>save_token</em> → Save your token gotten from Kinde</p>
</li>
<li><p><em>list to-dos</em> → List to-dos</p>
</li>
<li><p><em>create to-do</em> - Create a to-do</p>
</li>
<li><p><em>refresh billing status</em> - Check billing</p>
</li>
</ul>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Even with everything set up correctly, you might run into issues. Here are some common problems and how to fix them.</p>
<h3 id="heading-1-mcp-server-not-detected">1. MCP Server Not Detected</h3>
<p>If Cursor can’t see your server:</p>
<ul>
<li><p>Double-check the syntax of your <code>~/.cursor/mcp.json</code> file.</p>
</li>
<li><p>Make sure all file paths in <code>mcp.json</code> are <strong>absolute paths</strong> (not relative).</p>
</li>
<li><p>Restart Cursor after making changes to the config file.</p>
</li>
</ul>
<h3 id="heading-2-database-connection-issues">2. Database Connection Issues</h3>
<p>If your Neon database won’t connect:</p>
<ul>
<li><p>Confirm your <code>DATABASE_URL</code> environment variable is correctly formatted.</p>
</li>
<li><p>Log into the <a target="_blank" href="https://console.neon.tech">Neon dashboard</a> and make sure your database is active and not paused.</p>
</li>
<li><p>If you’re using SSL, verify that the SSL mode matches Neon’s connection settings.</p>
</li>
</ul>
<h3 id="heading-3-kinde-authentication-problems">3. Kinde Authentication Problems</h3>
<p>If login isn’t working as expected:</p>
<ul>
<li><p>In your <a target="_blank" href="https://app.kinde.com/admin">Kinde dashboard</a>, make sure the redirect URLs are set correctly (for example, <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a>).</p>
</li>
<li><p>Double-check that your client ID and client secret are correct.</p>
</li>
<li><p>Ensure your auth server is running locally on port <code>3000</code> before attempting login.</p>
</li>
</ul>
<h3 id="heading-4-token-errors">4. Token Errors</h3>
<p>If you’re getting token-related errors:</p>
<ul>
<li><p>Confirm the token you’re saving is in JWT format (three dot-separated parts).</p>
</li>
<li><p>Make sure the token hasn’t expired.</p>
</li>
<li><p>Use the ID token provided by Kinde, not the access token.</p>
</li>
</ul>
<p>Following these steps should resolve most issues you’ll run into when setting up your MCP server with Cursor, Neon, and Kinde.</p>
<h2 id="heading-final-mcp-server-architecture">Final MCP Server Architecture</h2>
<pre><code class="lang-markdown">┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Cursor IDE    │    │   MCP Server     │    │  Kinde Auth     │
│                 │◄──►│                  │◄──►│   Server        │
│ - MCP Tools     │    │ - Todo CRUD      │    │ - OAuth Flow    │
│ - Chat Interface│    │ - Billing Check  │    │ - Token Storage │
└─────────────────┘    └──────────────────┘    └─────────────────┘
<span class="hljs-code">                                │
                                ▼
                       ┌─────────────────┐
                       │ Neon PostgreSQL │
                       │                 │
                       │ - Users Table   │
                       │ - Todos Table   │
                       │ - Billing Data  │
                       └─────────────────┘</span>
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You’ve just built a fully functional MCP server with:</p>
<ul>
<li><p><strong>Authentication</strong> → secure logins with Kinde</p>
</li>
<li><p><strong>Data persistence</strong> → to-dos stored in Neon</p>
</li>
<li><p><strong>Billing enforcement</strong> → usage limits + upgrade path</p>
</li>
<li><p><strong>Tool exposure</strong> → MCP tools accessible in Cursor</p>
</li>
</ul>
<p>This foundation is flexible enough to power more advanced apps while keeping the core flow simple and secure.</p>
<h3 id="heading-next-steps">Next Steps</h3>
<p>Here are some ideas to extend what you’ve built:</p>
<ul>
<li><p><strong>Role-based access control (RBAC):</strong> create admin vs normal user permissions (see my <a target="_blank" href="https://dev.to/sholajegede/part-1-master-authentication-and-role-based-access-control-rbac-with-kinde-and-convex-in-a-h3c">two-part RBAC guide</a> for reference).</p>
</li>
<li><p><strong>Billing tiers:</strong> offer free, pro, and enterprise plans with different limits.</p>
</li>
<li><p><strong>Features:</strong> add search, tags, or sharing to to-dos.</p>
</li>
<li><p><strong>Deployment:</strong> run the service on a cloud platform with HTTPS and a production-grade database.</p>
</li>
</ul>
<h3 id="heading-resources">Resources</h3>
<p>You can find the complete source code for this tutorial in <a target="_blank" href="https://github.com/sholajegede/todo_mcp_server">this GitHub repository</a>. If it helped you in any way, consider giving it a star (⭐) to show your support!</p>
<p>Also, if you found this tutorial valuable, feel free to share it with others who might benefit from it. I’d really appreciate your thoughts, you can mention me on X <a target="_blank" href="https://x.com/wani_shola">@wani_shola</a> or <a target="_blank" href="https://www.linkedin.com/in/sholajegede">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use the ChatGPT Apps SDK: Build a Pizza App with Apps SDK ]]>
                </title>
                <description>
                    <![CDATA[ OpenAI recently introduced ChatGPT Apps, powered by the new Apps SDK and the Model Context Protocol (MCP). Think of these apps as plugins for ChatGPT: You can invoke them naturally in a conversation. They can render custom interactive UIs inside Ch... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-the-chatgpt-apps-sdk/</link>
                <guid isPermaLink="false">68efe8d446ad0f2d5932c5e1</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ openai ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatgpt ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Wed, 15 Oct 2025 18:32:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1760552846436/808fcd59-4dbc-4874-bd62-2e13965f956c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>OpenAI recently introduced ChatGPT Apps, powered by the new <a target="_blank" href="https://developers.openai.com/apps-sdk">Apps SDK</a> and the Model Context Protocol (MCP).</p>
<p>Think of these apps as plugins for ChatGPT:</p>
<ul>
<li><p>You can invoke them naturally in a conversation.</p>
</li>
<li><p>They can render custom interactive UIs inside ChatGPT (maps, carousels, videos, and more).</p>
</li>
<li><p>They run on an MCP server that you control, which defines the tools, resources, and widgets the app provides.</p>
</li>
</ul>
<p>In this step-by-step guide, you’ll build a ChatGPT App using the official <a target="_blank" href="https://github.com/openai/openai-apps-sdk-examples/tree/main/pizzaz_server_node">Pizza App example</a>. This app shows how ChatGPT can render UI widgets like a pizza map or carousel, powered by your local server.</p>
<h2 id="heading-what-youll-learn">What You’ll Learn</h2>
<p>By following this tutorial, you’ll learn how to:</p>
<ul>
<li><p>Set up and run a ChatGPT App with the OpenAI Apps SDK.</p>
</li>
<li><p>Understand the core building blocks: tools, resources, and widgets.</p>
</li>
<li><p>Connect your local app server to ChatGPT using Developer Mode.</p>
</li>
<li><p>Render custom UI directly inside a ChatGPT conversation.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-youll-learn">What You’ll Learn</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-table-of-contents">Table of Contents</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-chatgpt-apps-work-big-picture">How ChatGPT Apps Work (Big Picture)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-clone-the-examples-repo">Step 1. Clone the Examples Repo</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-run-the-pizza-app-server">Step 2. Run the Pizza App Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-expose-your-local-server">Step 3. Expose Your Local Server</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-31-get-ngrok">3.1 Get ngrok</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-32-install-ngrok">3.2 Install ngrok</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-33-connect-your-account">3.3 Connect Your Account</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-34-start-a-tunnel">3.4 Start a Tunnel</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-walk-through-the-pizza-app-code">Step 4. Walk Through the Pizza App Code</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-41-imports-and-setup">4.1 Imports and Setup</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-42-defining-pizza-widgets">4.2 Defining Pizza Widgets</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-43-mapping-widgets-to-tools-and-resources">4.3 Mapping Widgets to Tools and Resources</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-44-handling-requests">4.4 Handling Requests</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-45-creating-the-server">4.5 Creating the Server</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-enable-developer-mode-in-chatgpt">Step 5. Enable Developer Mode in ChatGPT</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-51-enable-developer-mode">5.1 Enable Developer Mode</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-52-create-app">5.2 Create App</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-53-use-your-app">5.3 Use Your App</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-challenges-try-these-yourself">Challenges (Try These Yourself)</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-challenge-a-add-a-pizza-specials-widget-text-only">Challenge A: Add a “Pizza Specials” widget (text-only)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-challenge-b-support-multiple-toppings">Challenge B: Support Multiple Toppings</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-challenge-c-fetch-real-pizza-data-from-an-external-api">Challenge C: Fetch Real Pizza Data from an External API</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-how-chatgpt-apps-work-big-picture">How ChatGPT Apps Work (Big Picture)</h2>
<p>Here’s the architecture in simple terms:</p>
<pre><code class="lang-markdown">ChatGPT (frontend)
   |
   v
MCP Server (your backend)
   |
   v
Widgets (HTML/JS markup displayed inside ChatGPT)
</code></pre>
<ul>
<li><p><strong>ChatGPT</strong> sends requests like: <em>“Show me a pizza carousel.”</em></p>
</li>
<li><p><strong>MCP Server</strong> responds with resources (HTML markup) and tool logic.</p>
</li>
<li><p><strong>Widgets</strong> are rendered inline in ChatGPT.</p>
</li>
</ul>
<h2 id="heading-step-1-clone-the-examples-repo">Step 1. Clone the Examples Repo</h2>
<p>OpenAI provides an official examples repo that includes the Pizza App. Clone it and install the dependencies using these commands:</p>
<pre><code class="lang-powershell">git clone https://github.com/openai/openai<span class="hljs-literal">-apps</span><span class="hljs-literal">-sdk</span><span class="hljs-literal">-examples</span>.git
<span class="hljs-built_in">cd</span> openai<span class="hljs-literal">-apps</span><span class="hljs-literal">-sdk</span><span class="hljs-literal">-examples</span>
pnpm install
</code></pre>
<p>After installing, build the components and start the dev server:</p>
<pre><code class="lang-powershell">pnpm run build  
pnpm run dev
</code></pre>
<h2 id="heading-step-2-run-the-pizza-app-server">Step 2. Run the Pizza App Server</h2>
<p>Navigate to the Pizza App server and start it:</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">cd</span> pizzaz_server_node
pnpm <span class="hljs-built_in">start</span>
</code></pre>
<p>If it works, you should see:</p>
<pre><code class="lang-powershell">Pizzaz MCP server listening on http://localhost:<span class="hljs-number">8000</span>
  SSE stream: GET http://localhost:<span class="hljs-number">8000</span>/mcp
  Message post endpoint: POST http://localhost:<span class="hljs-number">8000</span>/mcp/messages
</code></pre>
<p>This means your server is running locally.</p>
<h2 id="heading-step-3-expose-your-local-server">Step 3. Expose Your Local Server</h2>
<p>To let ChatGPT communicate with your app, your local server needs a public URL. ngrok provides a quick way to expose it during development.</p>
<h3 id="heading-31-get-ngrok">3.1 Get ngrok</h3>
<p>Sign up at <a target="_blank" href="https://ngrok.com">ngrok.com</a> and copy your <strong>authtoken</strong>.</p>
<h3 id="heading-32-install-ngrok">3.2 Install ngrok</h3>
<p><strong>macOS:</strong></p>
<pre><code class="lang-powershell">brew install ngrok
</code></pre>
<p><strong>Windows:</strong></p>
<ul>
<li><p>Download and unzip ngrok.</p>
</li>
<li><p>Optionally, add the folder to your PATH.</p>
</li>
</ul>
<h3 id="heading-33-connect-your-account">3.3 Connect Your Account</h3>
<pre><code class="lang-powershell">ngrok config <span class="hljs-built_in">add-authtoken</span> &lt;your_authtoken&gt;
</code></pre>
<h3 id="heading-34-start-a-tunnel">3.4 Start a Tunnel</h3>
<pre><code class="lang-powershell">ngrok http <span class="hljs-number">8000</span>
</code></pre>
<p>This gives you a public HTTPS URL (like <a target="_blank" href="https://xyz.ngrok.app/mcp"><code>https://xyz.ngrok.app/mcp</code></a>).</p>
<h2 id="heading-step-4-walk-through-the-pizza-app-code">Step 4. Walk Through the Pizza App Code</h2>
<p>The full Pizza App server code is long, so let’s break it down into digestible parts.</p>
<h3 id="heading-41-imports-and-setup">4.1 Imports and Setup</h3>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { createServer } <span class="hljs-keyword">from</span> <span class="hljs-string">"node:http"</span>;
<span class="hljs-keyword">import</span> { Server } <span class="hljs-keyword">from</span> <span class="hljs-string">"@modelcontextprotocol/sdk/server/index.js"</span>;
<span class="hljs-keyword">import</span> { SSEServerTransport } <span class="hljs-keyword">from</span> <span class="hljs-string">"@modelcontextprotocol/sdk/server/sse.js"</span>;
<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">"zod"</span>;
</code></pre>
<ul>
<li><p><code>Server</code> and <code>SSEServerTransport</code> come from the Apps SDK.</p>
</li>
<li><p><code>zod</code> validates input to ensure ChatGPT sends the right arguments.</p>
</li>
</ul>
<h3 id="heading-42-defining-pizza-widgets">4.2 Defining Pizza Widgets</h3>
<p>Widgets are the heart of the app. Each one represents a piece of UI ChatGPT can display.</p>
<p>Here’s the Pizza Map widget:</p>
<pre><code class="lang-typescript">{
  id: <span class="hljs-string">"pizza-map"</span>,
  title: <span class="hljs-string">"Show Pizza Map"</span>,
  templateUri: <span class="hljs-string">"ui://widget/pizza-map.html"</span>,
  html: <span class="hljs-string">`
    &lt;div id="pizzaz-root"&gt;&lt;/div&gt;
    &lt;link rel="stylesheet" href=".../pizzaz-0038.css"&gt;
    &lt;script type="module" src=".../pizzaz-0038.js"&gt;&lt;/script&gt;
  `</span>,
  responseText: <span class="hljs-string">"Rendered a pizza map!"</span>
}
</code></pre>
<ul>
<li><p><code>id</code> → unique name of the widget.</p>
</li>
<li><p><code>templateUri</code> → how ChatGPT fetches the UI.</p>
</li>
<li><p><code>html</code> → actual markup and assets.</p>
</li>
<li><p><code>responseText</code> → message that shows in chat.</p>
</li>
</ul>
<p>The app defines five widgets:</p>
<ul>
<li><p>Pizza Map</p>
</li>
<li><p>Pizza Carousel</p>
</li>
<li><p>Pizza Album</p>
</li>
<li><p>Pizza List</p>
</li>
<li><p>Pizza Video</p>
</li>
</ul>
<h3 id="heading-43-mapping-widgets-to-tools-and-resources">4.3 Mapping Widgets to Tools and Resources</h3>
<p>Next, widgets are converted into <strong>tools</strong> (things ChatGPT can call) and <strong>resources</strong> (UI markup ChatGPT can render).</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> tools = widgets.map(<span class="hljs-function">(<span class="hljs-params">widget</span>) =&gt;</span> ({
  name: widget.id,
  description: widget.title,
  inputSchema: toolInputSchema,
  title: widget.title,
  _meta: widgetMeta(widget)
}));

<span class="hljs-keyword">const</span> resources = widgets.map(<span class="hljs-function">(<span class="hljs-params">widget</span>) =&gt;</span> ({
  uri: widget.templateUri,
  name: widget.title,
  description: <span class="hljs-string">`<span class="hljs-subst">${widget.title}</span> widget markup`</span>,
  mimeType: <span class="hljs-string">"text/html+skybridge"</span>,
  _meta: widgetMeta(widget)
}));
</code></pre>
<p>This makes each widget callable and displayable.</p>
<h3 id="heading-44-handling-requests">4.4 Handling Requests</h3>
<p>The MCP server responds to ChatGPT’s requests. For example, when ChatGPT calls a widget tool:</p>
<pre><code class="lang-typescript">server.setRequestHandler(CallToolRequestSchema, <span class="hljs-keyword">async</span> (request) =&gt; {
  <span class="hljs-keyword">const</span> widget = widgetsById.get(request.params.name);
  <span class="hljs-keyword">const</span> args = toolInputParser.parse(request.params.arguments ?? {});
  <span class="hljs-keyword">return</span> {
    content: [{ <span class="hljs-keyword">type</span>: <span class="hljs-string">"text"</span>, text: widget.responseText }],
    structuredContent: { pizzaTopping: args.pizzaTopping },
    _meta: widgetMeta(widget)
  };
});
</code></pre>
<p>This:</p>
<ul>
<li><p>Finds the widget requested.</p>
</li>
<li><p>Validates the input (<code>pizzaTopping</code>).</p>
</li>
<li><p>Responds with text + metadata so ChatGPT can render the widget.</p>
</li>
</ul>
<h3 id="heading-45-creating-the-server">4.5 Creating the Server</h3>
<p>Finally, the server is bound to HTTP endpoints (<code>/mcp</code> and <code>/mcp/messages</code>) so ChatGPT can stream messages to and from it.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> httpServer = createServer(<span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-comment">// handle requests to /mcp and /mcp/messages</span>
});

httpServer.listen(<span class="hljs-number">8000</span>, <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Pizzaz MCP server running on port 8000"</span>);
});
</code></pre>
<h2 id="heading-step-5-enable-developer-mode-in-chatgpt">Step 5. Enable Developer Mode in ChatGPT</h2>
<h3 id="heading-51-enable-developer-mode">5.1 Enable Developer Mode</h3>
<ul>
<li><p>Open ChatGPT</p>
</li>
<li><p>Go to <strong>Settings → Apps &amp; Connectors → Advanced Settings</strong></p>
</li>
<li><p>Toggle <strong>Developer Mode</strong></p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313826734/7cf96d44-ae03-48d1-92b9-7fee42d895ad.png" alt="Toggle developer mode" class="image--center mx-auto" width="1560" height="1402" loading="lazy"></p>
<p>When <strong>Developer Mode</strong> is enabled, ChatGPT should look like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313206155/f2677b50-8bc0-4c10-b971-b0a60d66181f.png" alt="Developer mode enabled" class="image--center mx-auto" width="1622" height="602" loading="lazy"></p>
<h3 id="heading-52-create-app">5.2 Create App</h3>
<ul>
<li><p>Go back to <strong>Settings → Apps &amp; Connectors</strong></p>
</li>
<li><p>Click <strong>Create</strong></p>
</li>
<li><p>Next:</p>
<ul>
<li><p><strong>Name</strong>: Enter a name for your app (for example, <em>Pizza App</em>)</p>
</li>
<li><p><strong>Description</strong>: Enter any description for your app (or leave empty)</p>
</li>
<li><p><strong>MCP Server URL</strong>: Paste the public HTTPS URL of your MCP endpoint. Make sure it points directly to <code>/mcp</code>, not just the server root</p>
</li>
<li><p><strong>Authentication</strong>: Choose <strong>No authentication</strong></p>
</li>
<li><p>Check <strong>I trust this application</strong></p>
</li>
<li><p>Click <strong>Create</strong> to finish</p>
</li>
</ul>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313317398/93d30263-59db-4606-8066-467b7949efb9.png" alt="Create your app in ChatGPT" class="image--center mx-auto" width="1626" height="1598" loading="lazy"></p>
<p>Once your app is connected to ChatGPT, it should look like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313733914/944b363a-7004-4737-a102-bd1e328f717d.png" alt="App is connected to ChatGPT" class="image--center mx-auto" width="1542" height="1446" loading="lazy"></p>
<p>When you click on the <strong>Back</strong> icon, you should see your app and other apps that you can connect to and use with ChatGPT:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313649918/627594a8-a89b-4cd5-90a6-1fa2d804063e.png" alt="View all apps that can be connected to ChatGPT" class="image--center mx-auto" width="1624" height="1460" loading="lazy"></p>
<h3 id="heading-53-use-your-app">5.3 Use Your App</h3>
<p>To use your app,</p>
<ul>
<li><p>Open a new chat in ChatGPT</p>
</li>
<li><p>Click on the <strong>+</strong> icon</p>
</li>
<li><p>Scroll down to <strong>more</strong></p>
</li>
<li><p>You would see your app</p>
</li>
<li><p>Choose <strong>Pizza App</strong> to start using your app</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313495700/e978f689-622b-4ceb-aa73-6459302e8b3b.png" alt="How to use your app in ChatGPT" class="image--center mx-auto" width="1626" height="1288" loading="lazy"></p>
<p>Here are some commands you can try out with your pizza app in ChatGPT:</p>
<ul>
<li><p><em>Show me a pizza map with pepperoni topping</em></p>
</li>
<li><p><em>Show me a pizza carousel with mushroom topping</em></p>
</li>
<li><p><em>Show me a pizza album with veggie topping</em></p>
</li>
<li><p><em>Show me a pizza list with cheese topping</em></p>
</li>
<li><p><em>Show me a pizza video with chicken topping</em></p>
</li>
</ul>
<p>Each command tells ChatGPT which widget to render, and you can swap in any topping you like.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760313589161/07b69ab7-fd36-4a14-84de-883a0f634b82.png" alt="Type in a command into ChatGPT to make tool calls to your app" class="image--center mx-auto" width="1622" height="688" loading="lazy"></p>
<p>Below are samples:</p>
<ul>
<li>Pepperoni topping map:</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760314642952/6527fe96-061b-433c-94b9-86b8152fd082.png" alt="Sample app response: Pepperoni topping map" class="image--center mx-auto" width="1886" height="1504" loading="lazy"></p>
<ul>
<li>Extra cheese carousel:</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760314675799/8b65e9b3-4547-40a2-9269-cec56aa8705f.png" alt="Sample app response: Extra cheese carousel" class="image--center mx-auto" width="2108" height="1292" loading="lazy"></p>
<ul>
<li>Mushroom topping album:</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760314714658/ae6edf57-44c9-421b-a140-364cc8873db4.png" alt="Sample app response: Mushroom topping album" class="image--center mx-auto" width="3348" height="1992" loading="lazy"></p>
<h2 id="heading-challenges-try-these-yourself">Challenges (Try These Yourself)</h2>
<p>Here are three practical ways to extend your Pizza App. Each one ties directly to the code you already have.</p>
<h3 id="heading-challenge-a-add-a-pizza-specials-widget-text-only">Challenge A: Add a “Pizza Specials” widget (text-only)</h3>
<p><strong>Goal:</strong> Create a widget that just shows a short message like <em>“Today’s special: Margherita with basil.”</em></p>
<p><strong>Where to change:</strong></p>
<ul>
<li><p><code>resources.widgets</code> → duplicate an entry and give it a new <code>id</code>/<code>title</code>.</p>
</li>
<li><p><code>tools</code> → register it as a new tool.</p>
</li>
<li><p><code>CallTool</code> handler → detect when it’s called (<code>if (request.params.name === "pizza-special")</code>) and return your special.</p>
</li>
</ul>
<p><strong>Hint:</strong><br>This widget doesn’t need extra CSS/JS files. Just keep its <code>html</code> to something like <code>&lt;div&gt;🍕 Today’s special: Margherita&lt;/div&gt;</code>. The idea is to show that widgets can be as simple as plain HTML.</p>
<h3 id="heading-challenge-b-support-multiple-toppings">Challenge B: Support Multiple Toppings</h3>
<p><strong>Goal:</strong> Let users order a pizza with more than one topping, like <code>["pepperoni", "mushroom"]</code>.</p>
<p><strong>Where to change:</strong></p>
<ul>
<li><p><code>toolInputSchema</code> → switch from <code>z.string()</code> to <code>z.array(z.string())</code>.</p>
</li>
<li><p><code>CallTool</code> handler → after parsing, <code>args.pizzaTopping</code> will be an array. Join it into a string before inserting into HTML/response.</p>
</li>
<li><p>Widget HTML → update the display so it lists all chosen toppings.</p>
</li>
</ul>
<p><strong>Hint:</strong><br>Console.log the parsed <code>args</code> first to confirm you’re actually getting an array. Then try something like:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> toppings = args.pizzaTopping.join(<span class="hljs-string">", "</span>);
<span class="hljs-keyword">return</span> { responseText: <span class="hljs-string">`Pizza ordered with <span class="hljs-subst">${toppings}</span>`</span> };
</code></pre>
<h3 id="heading-challenge-c-fetch-real-pizza-data-from-an-external-api">Challenge C: Fetch Real Pizza Data from an External API</h3>
<p><strong>Goal:</strong> Instead of hard-coding content, fetch real pizza info. For example, you could call Yelp’s API to list pizza places in a location, or use a free placeholder API to simulate data.</p>
<p><strong>Where to change:</strong></p>
<ul>
<li><p>Inside the <code>CallTool</code> handler for your widget.</p>
</li>
<li><p>Replace the static HTML with a <code>fetch(...)</code> call that builds dynamic HTML from the response.</p>
</li>
</ul>
<p><strong>Hint:</strong><br>Start small with a free API like <a target="_blank" href="https://jsonplaceholder.typicode.com/posts">JSONPlaceholder</a>. For example:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"https://jsonplaceholder.typicode.com/posts?_limit=3"</span>);
<span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();

<span class="hljs-keyword">const</span> html = <span class="hljs-string">`
  &lt;ul&gt;
    <span class="hljs-subst">${data.map((p: <span class="hljs-built_in">any</span>) =&gt; <span class="hljs-string">`&lt;li&gt;<span class="hljs-subst">${p.title}</span>&lt;/li&gt;`</span>).join(<span class="hljs-string">""</span>)}</span>
  &lt;/ul&gt;
`</span>;

<span class="hljs-keyword">return</span> { responseText: <span class="hljs-string">"Fetched pizza places!"</span>, content: [{ <span class="hljs-keyword">type</span>: <span class="hljs-string">"text/html"</span>, text: html }] };
</code></pre>
<p>Once that works, swap in a real API such as Yelp or Google Maps Places to render actual pizza places.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You just built your first ChatGPT App using the <strong>OpenAI Apps SDK</strong>. With a bit of JavaScript and HTML, you created a server that ChatGPT can talk to, and rendered interactive widgets right inside the chat window.</p>
<p>This example focused on the pizza app sample provided by OpenAI, but you could build:</p>
<ul>
<li><p>A weather dashboard,</p>
</li>
<li><p>A movie finder,</p>
</li>
<li><p>A financial data viewer,</p>
</li>
<li><p>Or even a mini-game.</p>
</li>
</ul>
<p>The SDK makes it possible to blend <strong>conversation + interactive UI</strong> in powerful new ways.</p>
<p>Explore the <a target="_blank" href="https://developers.openai.com/apps-sdk">OpenAI Apps SDK documentation</a> to go deeper and start building your own apps.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
