0%
Add-On · Chrome Extension
AI CLARITY · CHROME EXTENSION ADD-ON

Personalise your Chrome Extension add-on

The 10 segments are identical either way. Personalised retunes every example and tutor response to your role and sector — the same coach calibration the mainline AI Clarity courses use.

or

EverythingThreads · ICO: C1896585 · Privacy Policy

Segment 1 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toExplain what a Chrome extension is, how it differs from a website and a PWA, and when to pick each.
Explain what a Chrome extension is, how it differs from a website and a PWA, and when each is the right tool for the job. This is framing before code — ten minutes that saves you from building the wrong thing.

Your Tool in Every Tab

⏱ ~15 min• The frame, not the code
▸ 30-second preview — where you're headingBy the end of these ten segments your BUILD tool will be a browser extension that reads whatever page you're on, sends it to Claude, and shows you the analysis — one click away on every site you visit. And it'll be published on the Chrome Web Store.
By the end of these ten segments your BUILD tool will be a browser extension that reads whatever page you're on, sends it to Claude, and shows you the analysis — one click away on every site you visit. Not a separate app. Not a separate URL. A button in your browser that works on every page. And by the final segment, it will be published on the Chrome Web Store where anyone can install it.

BUILD gave you a tool at a URL. People had to visit the URL to use it. A Chrome extension flips that: your tool lives inside the browser they already have open all day, and it can see the page they're on. The user doesn't come to your tool. Your tool comes to them.

This isn't a small shift. The most useful AI products shipped in 2025-2026 are mostly browser extensions, not websites — because the value of "analyse this thing I'm looking at right now" scales with how close the tool sits to the work. An extension sits the closest. And with Chrome sitting at roughly 3 billion users globally, you don't have to convince anyone to install a new app — they already have the distribution channel open.

Stuck? This add-on assumes you finished BUILD with your Worker deployed. If anything breaks along the way, use BUILD Seg 0's 5-step self-service debug flow — same habits apply here.
Your Stack — Tool Everywhere
BUILD Tool
Website + Worker
Chrome Extension
Building now
Chrome Web Store

The lit box is the unlock. Your BUILD website stays exactly as it is — same URL, same Worker, same everything. The extension is a second client for the same backend: instead of users visiting your site to analyse text they pasted in, they click your extension icon on whatever page they're already reading, and the Worker does the same analysis against the page content. One brain, two faces.

What doesn't change from BUILD: same Worker, same env.ANTHROPIC_API_KEY, same claude-sonnet-4-6 model, same system-prompt patterns you learned. The extension is a new client for that Worker, not a new backend. By Segment 10, the same Worker is serving your website and your extension and potentially more clients beyond that. This is the architecture SCALE teaches; you're already doing it.

Worker-side changes you'll make across this add-on: one CORS handler update (Seg 6) so the Worker accepts requests from chrome-extension://*. That's the entire backend change. Everything else is new code in a separate extension folder.

Extension vs PWA vs Website — pick the right shape

These three shapes look similar from the outside. Under the hood they're very different. Knowing which one fits your problem saves you from rebuilding it later in the other shape.

Website (BUILD)
Users come to you
They type the URL. They paste their content. They click Submit. The tool works on whatever they bring.
Best when: the content lives anywhere, users are comfortable copy-pasting, SEO matters.
PWA
Installed, standalone
Same website, but installable — icon on phone/desktop, works offline, standalone window. Still a destination they visit.
Best when: phone-first usage, offline matters, users open it repeatedly as its own thing.
Chrome Extension
Tool comes to them
Lives inside the browser. One click on any page. Can see the page content. No destination, no paste, no context switch.
Best when: the job is "analyse this page", repeat usage on other sites, workflow augmentation.

The test: if the value of your tool depends on what the user is currently looking at, build an extension. If the user brings content to your tool, build a website. If they want to use your tool on their phone or offline, build a PWA. It's rarely either/or — the architecture we're building lets you have all three with the same backend.

Why Chrome extensions became valuable again in 2026. For a few years the ecosystem went quiet — ad blockers, password managers, that was it. Then AI happened. Suddenly "press a button, send the current page to a model, get back an analysis" became the most useful shape for a huge number of workflows — research, reading, writing, code review, content moderation. Every AI company shipped a browser extension in 2025-2026. This is the shape you're building into.
Mental Model Set
You know what an extension is, what problems it's good at, and what you're about to build.
Segment 2 writes the manifest — the single JSON file that tells Chrome what your extension is, what it can do, and what it needs permission to reach. It's the backbone of everything from here.
Segment 2 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toWrite a valid Manifest V3 manifest.json, understand every field, and make the activeTab-vs-host_permissions decision.
Write a valid Manifest V3 manifest.json for an AI-powered extension, understand what every field does, and make the activeTab-versus-host_permissions decision correctly. This is the file Chrome reads first — if it's wrong, nothing else matters.

Manifest V3 — The Identity File

⏱ ~25 min⬡ Desktop required

Every Chrome extension has exactly one mandatory file: manifest.json. It's the first thing Chrome reads when your extension loads, and it tells Chrome four things: what your extension is (name, version, icon), what it can do (permissions), which files do what (popup, content scripts, service worker), and where it's allowed to reach (host permissions).

Manifest V3 is the only version Chrome accepts in 2026. V2 was fully deprecated at the start of the year. The three changes that matter in practice: service workers replaced background pages (background code runs only when needed, not permanently), remote code is banned (everything must be in the bundle, no CDN imports), and host permissions became a separate top-level field (not mixed in with other permissions). Everything in this add-on is V3 from day one, so nothing you learn here is already obsolete.

No npm, no bundler required. For this extension you'll write plain HTML/CSS/JS files and reference them from the manifest. No webpack, no Vite, no build step. Chrome loads the folder as-is. This is the right default for a first extension — the moment you need a build step you can add one, but 90% of useful extensions don't.
Step 1: Create the project folder

From your terminal, make a fresh folder alongside your BUILD project (not inside it — they're separate things now). This folder will hold the extension: manifest, popup, content script, icons, options page. No git repo yet; we'll initialise one when the shape settles.

Terminal · create the project
# From wherever you keep projects
mkdir ai-page-analyser
cd ai-page-analyser

# Create the four files we'll need to start
touch manifest.json popup.html popup.js content.js

Windows users: PowerShell doesn't have touch. Use ni manifest.json, popup.html, popup.js, content.js instead. Or just create the empty files manually in VS Code — New File four times.

You'll add an icons/ folder with three PNG files (16×16, 48×48, 128×128) in Segment 3. For now the four files above are all Chrome needs to load a working extension shell.

Don't put this folder inside your BUILD project. The extension and the Worker have separate lifecycles — they deploy independently, they version independently, they get pushed to different places. Keep them as sibling directories. Your BUILD project stays focused on the Worker + website; this folder stays focused on the extension.
Step 2: Write the manifest

Paste this into manifest.json. Read it top-to-bottom — every field earns its place. We'll walk through each section in detail on the next slide:

manifest.json · complete V3 manifest
{
  "manifest_version": 3,
  "name": "AI Page Analyser",
  "version": "1.0.0",
  "description": "Analyse any web page with Claude — one click, in-browser.",

  "permissions": ["activeTab", "storage"],
  "host_permissions": ["https://ai-proxy.YOUR-NAME.workers.dev/*"],

  "action": {
    "default_popup": "popup.html",
    "default_title": "Analyse this page",
    "default_icon": {
      "16":  "icons/icon-16.png",
      "48":  "icons/icon-48.png",
      "128": "icons/icon-128.png"
    }
  },

  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"]
  }],

  "icons": {
    "16":  "icons/icon-16.png",
    "48":  "icons/icon-48.png",
    "128": "icons/icon-128.png"
  }
}
Change YOUR-NAME to your actual Worker subdomain in host_permissions. Without that, the extension can reach Claude. Without Claude, the extension does nothing. This is the line-level edit you must make before Load unpacked — we'll hit this again in Seg 3's checkpoint if you skip it now.
Step 3: What every field does
manifest_version · name · version · description
manifest_version: 3 is mandatory — anything else fails. version uses dotted numbers (1.0.0); Chrome compares these to decide what's an update. description shows on the Chrome Web Store listing and inside chrome://extensions. Keep it under ~130 characters and specific.
permissions — what the extension can do
activeTab grants temporary access to the currently focused tab only when the user clicks your extension icon. It's the polite permission — it doesn't show scary warnings at install. storage lets you use chrome.storage.* (Seg 7). Both are narrow and low-friction.
host_permissions — what origins the extension can reach
This is separate from permissions in MV3. List only the hosts your extension needs to fetch from. Here, just your Worker URL. Don't use <all_urls> in host_permissions unless you genuinely need to; it triggers the Chrome Web Store's manual-review queue and most reviewers reject it on sight.
action — the toolbar icon and popup
default_popup is the HTML file that opens when the user clicks your icon. default_title is the tooltip. default_icon gives Chrome three icon sizes to use in different toolbar contexts. In MV3 this replaces V2's browser_action / page_action.
content_scripts — JS that runs on pages
matches controls which URLs the content script is injected into. <all_urls> injects on every page — necessary here because the whole point is "read whatever page the user is currently on." If your extension only needs to run on GitHub, narrow it to ["https://github.com/*"] and reviewers will approve faster.
icons — the extension-level icons (not toolbar icons)
Shown in the chrome://extensions management page, the install prompt, and elsewhere. Same paths as action.default_icon is fine for a first extension — one set of icons covering both uses.
The activeTab vs host_permissions decision

This is the one permission judgment that matters. Get it right and review breezes through; get it wrong and you'll spend weeks in manual review or rejection loops. The rule: use the narrowest permission that does the job.

activeTab — the polite one
Only grants access to the current tab, only when the user clicks your extension icon. No scary install prompt.
Use when: the user triggers the action. Browsing doesn't cause reading.
host_permissions: ["<all_urls>"] — the risky one
Grants access to every URL, always, without user interaction. Triggers "read and change all your data on all websites" warning.
Avoid unless: extension must run background logic on every page without user clicking.

Our extension uses both, but narrowly:

  • permissions: ["activeTab"] — the content script needs to read the current tab's text, but only when the user clicks our icon. Polite.
  • host_permissions: ["https://ai-proxy...workers.dev/*"] — the popup needs to call your Worker. Only your Worker, nothing else. Narrow.
  • content_scripts.matches: ["<all_urls>"] — the content script is injected on every page, but with activeTab it can only run after user interaction. This is the right pattern.
SHARP M5 The CaveatAsk AI "is this manifest production-ready?" and it'll give you a bulleted security review that misses the real concern.
When you ask AI "is this manifest production-ready?", it will confidently list Content Security Policy recommendations, remote code concerns, and other generic checks — without catching that your host_permissions is "<all_urls>" and that alone will trigger 2-3 weeks of manual review. The honest question: "Will reviewers at the Chrome Web Store flag my permissions as overly broad? Name the single most likely permission they'd object to." Specificity defeats the Caveat every time.
Direct Claude to audit your manifest for unused permissions. Reviewers reject extensions that request permissions the code doesn't actually use. Paste your manifest plus every JS file you reference into Claude with: "For each permission in this manifest, grep the JS files and tell me whether it's actually called. List any permission that's declared but never used — those trigger rejection." This is a mechanical audit Claude does reliably; five minutes saves a rejection cycle.
Checkpoint — Manifest Valid
Your manifest.json exists with all fields from Step 2. YOUR-NAME is replaced with your actual Worker subdomain. Does the file parse as valid JSON? (Paste it into jsonlint.com if unsure.)
Your manifest is Chrome-ready. It describes an extension that knows its own name, has a popup, has a content script on every page, and can reach exactly one Worker URL. Three empty JS/HTML files and no icons yet — Chrome will complain about missing icons in Segment 3's load step, which is expected. Nothing else is missing.
▲ VERIFICATION LAYER · Before Load Unpacked
Common ways AI gets THIS wrong:
  • Writes manifest_version: 2 out of training habit — Chrome rejects it at install
  • Puts host_permissions inside the permissions array — V2 syntax, V3 keeps them separate
  • Uses browser_action instead of action — V2 naming, V3 renamed it
  • Adds "background": { "scripts": [...] } — V2 syntax. V3 uses "service_worker" (which we'll add in Seg 8)
The 30-second check: manifest_version is 3. host_permissions is its own top-level field. action (not browser_action). YOUR-NAME replaced. JSON lint passes.
Segment 3 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toGenerate icons at three sizes, load your extension into Chrome in developer mode, and open the two DevTools contexts you'll use to debug it.
Generate icons at three sizes (no design skill required), load your extension into Chrome in developer mode, click the toolbar icon to open an empty popup, and understand the two separate DevTools contexts you'll use throughout the build. By the end you'll have an extension visible in your toolbar. It won't do anything useful yet. That's Segments 4-6.

First Load — Extension in Your Toolbar

⏱ ~15 min⬡ Desktop required

Three short steps. Make the icons. Load the folder. See the icon. Each step is quick. None of it is conceptually hard. But one tiny misstep — icon paths wrong, dev mode forgotten, extension folder selected instead of its parent — and you'll spend half an hour wondering why nothing works. We'll hit all of those in the checkpoint troubleshooting.

"Load unpacked" is Chrome's developer mode. It lets you install an extension from a local folder, skipping the Chrome Web Store entirely. The extension runs with the exact same capabilities as a published one — message passing, storage, host permissions, all of it. The only difference is distribution: it's installed only in your browser, and only until you unload it. Perfect for development. Terrible for shipping — which is why Segments 9 and 10 exist.
Step 1: Make the icons (3 sizes)

Chrome needs three icon sizes — 16×16, 48×48, and 128×128 — as PNG files. You need all three. Missing any one causes a manifest warning; they show up in different parts of Chrome's UI (16 in the toolbar hover, 48 in chrome://extensions, 128 in install prompts and the Web Store listing).

You don't need to be a designer. Three practical ways to get all three sizes from scratch in under 5 minutes — pick whichever is fastest:

Option A — RealFaviconGenerator
Upload one square image (ideally 512×512 SVG or PNG) to realfavicongenerator.net. It generates every icon size you'll ever need, including the Chrome extension sizes. Download the zip, extract the 16/48/128 PNGs into your icons/ folder. Fastest path, zero design knowledge.
Option B — Ask Claude to generate SVG + convert
In Claude, describe what you want: "Generate a minimal SVG icon for an AI page analyser — a magnifying glass with a small sparkle. Single colour, works on white and dark backgrounds." Save the output as icon.svg. Use a free online SVG-to-PNG tool (CloudConvert, iloveimg.com) to output 16, 48, and 128 PNG versions.
Option C — Placeholder now, design later
Use placehold.co to generate a quick coloured square with "AI" as text. Save at 16×16, 48×48, 128×128. Ugly, works, unblocks you. Come back to design before publishing in Segment 9.

Whichever option, create a folder called icons/ inside your extension folder, and save the three files there as icon-16.png, icon-48.png, icon-128.png — matching the paths in your manifest.

File-path case matters. Chrome runs its review environment on Linux, which is case-sensitive. If your manifest says icons/icon-16.png but the actual file is Icon-16.PNG, it'll load fine on macOS and silently break during Web Store review. Keep all filenames lowercase, always.
Step 2: Load unpacked in Chrome

Chrome's developer mode lets you install the extension directly from your local folder. Five clicks, takes 30 seconds.

  1. In Chrome, type chrome://extensions in the address bar and press Enter.
  2. In the top-right corner, toggle Developer mode on. Three new buttons appear: Load unpacked, Pack extension, Update.
  3. Click Load unpacked.
  4. In the file picker, navigate to your ai-page-analyser folder and select the folder itself (don't go inside it and select a file — select the folder from one level up and click Select/Open).
  5. Your extension appears as a card on the page. If there are any errors, they'll show in red below the card. If not, Chrome accepted it.

Don't see your icon in the toolbar? That's expected on fresh Chrome — the toolbar hides new extensions by default. Click the puzzle-piece icon (top-right of Chrome), find your extension in the list, click the pin icon next to it. Now the icon stays in the toolbar permanently.

Click the icon. An empty white popup appears (because popup.html is empty). That's a working extension — it loaded, it registered the popup, Chrome opened it when you clicked. The infrastructure is live.

Every time you change any file in the extension folder, you have to reload the extension — click the circular arrow icon on your extension's card in chrome://extensions. Changes don't hot-reload. This will bite you repeatedly in the first hour of building. Muscle memory: save file → hit reload on extension card → test.
Two DevTools contexts — memorise these

This is the thing that confuses everyone first week: a Chrome extension runs code in multiple separate JavaScript contexts. When something goes wrong, the error lands in the context that caused it — but you were looking at a different context's DevTools. You spend twenty minutes thinking your code didn't run, when it ran and errored, and you just couldn't see the error.

The popup context — for popup.js and popup.html
How to open: click your extension icon to open the popup, then right-click inside the popupInspect. DevTools opens pinned to that popup. Any console.log from popup.js appears here, not on the underlying page.
The page context — for content.js
How to open: press F12 on the underlying web page (not the popup). DevTools opens for that page. Your content.js runs in the page's context, so its console.log lands here. This is a different console from the popup's.
The service worker context — for background.js (Seg 8)
How to open: on chrome://extensions, click your extension card → click the "service worker" link. DevTools opens for the background script. Not relevant until Seg 8, but worth knowing it exists.

When your code seems not to run, it usually ran and errored — in a different DevTools than the one you're looking at. The first debug step for any extension bug is "which context is this code in, and which DevTools do I need to open?" Get this wrong and you'll troubleshoot the wrong file.

Checkpoint — Extension Visible, Popup Opens
Icon in your toolbar? Clicking it opens an empty white popup? Right-clicking inside the popup → Inspect opens DevTools?
You have a Chrome extension installed in your browser. It looks empty because it is empty — popup.html, popup.js, and content.js are all blank files. The hard part (getting Chrome to accept your manifest, load your icons, register your popup) is done. Segments 4-6 put functionality inside the shell. From here it's JavaScript, which you already know.

An icon in your browser. A popup that opens. Your own extension. It doesn't do anything yet, but Chrome accepted it — and Chrome is notoriously picky about manifests. If you made it here, you're past the most fragile part.

Segment 4 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toExplain why popups can't read pages directly, use chrome.runtime.sendMessage for popup↔content-script communication, and debug async message handlers.
Explain why the popup cannot read the underlying page directly, use chrome.runtime.sendMessage for communication between the popup and the content script, and debug the async message handlers that trip up every first-time extension builder. This is the mental model. Get this right and the rest is easy.

Message Passing — The One Thing to Understand

⏱ ~30 min• The concept that separates novices from debugable developers

Here's the thing everyone gets wrong at first: they try to write document.body.innerText inside popup.js and wonder why it returns the popup's own HTML, not the page they're looking at. They try to fetch() the Worker from content.js and get a CORS error they weren't expecting. They spend a week debugging symptoms. The actual problem is that they're thinking about the extension as one program. It isn't. It's three programs that happen to share a manifest.

This segment is the one where the lightbulb needs to go on. Spend the extra ten minutes on the diagram. Once you see the three contexts clearly, every confusing extension bug you'll ever hit has an obvious debug path.

The three contexts and what they can do

A running extension has (at least) three separate JavaScript contexts, each in its own sandbox. They can't share variables. They can't access each other's DOMs. They can only communicate through Chrome's messaging APIs.

① Popup
Runs: popup.html + popup.js
Can do: call your Worker via fetch (host_permissions bypasses CORS). Update its own DOM. Read from chrome.storage.
Can't do: access the underlying web page's DOM. Can't see what's on the user's current tab directly.
② Content script
Runs: content.js (injected into every page that matches the manifest)
Can do: read and modify the page's DOM (document.body.innerText, etc). Isolated from the page's own scripts (can't see the page's JS variables).
Can't do: bypass CORS like the popup can. If it calls your Worker, standard page-level CORS rules apply. That's why we do API calls from the popup, not the content script.
③ Service worker
Runs: background.js (added in Seg 8, for long-running state)
Can do: same API access as popup. Listen for events (keyboard shortcuts, alarms) without the user having the popup open.
Important gotcha: terminates when idle. Not used in Segments 4-6; revisited in Seg 8.

The architecture for our extension: the popup shows the UI, the content script reads the page. They talk via messages. The popup calls the Worker. The Worker talks to Claude. Simple flow, clean separation.

The message-passing API — three methods, one rule

Chrome's extension messaging API is small. You need three things:

  1. chrome.runtime.onMessage.addListener(fn) — the receiver registers this. Every message addressed to this script fires the function. Used in content.js (and sometimes popup.js).
  2. chrome.tabs.sendMessage(tabId, msg, callback) — send a message from the popup to a specific tab's content script. Needs a tab ID, which you get from chrome.tabs.query.
  3. chrome.runtime.sendMessage(msg, callback) — send to the extension's service worker (not to content scripts). Used in Seg 8.
content.js — listens for "getPageText" messages
// content.js — runs on every page matching the manifest
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'getPageText') {
    const text = document.body.innerText.slice(0, 4000);
    sendResponse({ text });
  }
});

That's the entire receiver. Listen for messages, check what the sender wants, respond. The content script has access to document.body because it runs inside the page — something the popup can't do.

Why the .slice(0, 4000)? Claude's context window is big, but every character costs tokens. Four thousand characters is roughly the first 600-800 words of a page — enough for a real analysis, small enough to keep per-call costs under a cent. You can tune this later; for now keep it low.

The async gotcha — return true

Here's the one thing that trips up every first-time extension builder, and most AI-generated code gets wrong. If your listener does synchronous work and calls sendResponse immediately (like the example above), it works. The moment your listener does any async work — a fetch, a chrome.storage.get, anything with await — you need to explicitly tell Chrome "keep the message channel open, I'll respond later." You do that by returning true from the listener.

content.js — async version (requires return true)
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'getPageText') {
    // Synchronous — sendResponse fires immediately, no return needed
    sendResponse({ text: document.body.innerText.slice(0, 4000) });
    return;
  }

  if (request.action === 'getPageTextAsync') {
    // Async — we'll call sendResponse inside the async function
    doAsyncWork().then(result => sendResponse({ text: result }));
    return true;  // ← KEEPS THE MESSAGE CHANNEL OPEN
  }
});
Forget the return true, and sendResponse fires into a closed channel. The popup's callback is never invoked. No error is thrown. The popup silently waits forever. This is by far the most common extension bug — and the kind Claude will confidently write wrong unless you remember this exact rule.
SHARP M3 The Confident Wrong AnswerAsk AI to write a message listener with an async fetch and it'll happily skip the return true.
Ask AI: "Write a chrome.runtime.onMessage listener that fetches data and sends the response back." You'll get code that reads beautifully, uses modern async/await, and silently fails because it doesn't return true. When it fails, AI's first suggestion will be "check your manifest permissions" or "add CORS headers" — neither of which is the problem. The tell: if your listener does anything async, it must return true. No exceptions. Put this rule in your AI prompts as a hard constraint and you'll save hours.
The popup side — sending a message to the active tab

The content script listens. The popup sends. To send from the popup, you need the tab ID of the currently active tab — which you get from chrome.tabs.query. Here's the pattern you'll use every time:

popup.js — ask content.js for the page text
async function getPageText() {
  // 1. Find the currently active tab in the current window
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

  // 2. Send a message to that tab's content script, wait for response
  const response = await chrome.tabs.sendMessage(tab.id, { action: 'getPageText' });

  // 3. Response is whatever the content script's sendResponse({}) passed back
  return response.text;
}

Three things to notice about this pattern:

  • chrome.tabs.query returns an array because you could ask for multiple tabs; we destructure the first with const [tab].
  • chrome.tabs.sendMessage in MV3 returns a promise by default — you can await it directly. No callbacks needed.
  • The action property is a convention, not a rule. You could call it anything. But using a consistent action field makes it easy to dispatch multiple message types from one listener.
Content scripts aren't loaded on chrome:// pages (chrome://extensions, chrome://settings, etc.) or on the Chrome Web Store itself. If you test on one of those, sendMessage fails with "Could not establish connection." Test on a normal website (GitHub, a news article, a Wikipedia page) instead.
Direct Claude — prompt template for debugging a disappearing message
# When a message seems to vanish (no error, no response, popup closes
# silently) — paste both sides of the exchange and any console output
# from either context. Claude is good at this class of audit because
# the failure mode is usually mechanical (missing return true, wrong
# tab ID, await before sendMessage) not logical.

I'm sending a message from my Chrome extension popup to a content script.
The popup closes or sendResponse never fires. Nothing in either console.

Popup code (popup.js):
<PASTE YOUR sendMessage CALL>

Content script (content.js):
<PASTE YOUR onMessage LISTENER>

Popup console shows: <PASTE CONSOLE OUTPUT OR "nothing">
Content script console shows: <PASTE OR "nothing">
Extension loaded on: <URL of the tab I was testing on>

Diagnose specifically:
(1) Is the listener actually receiving the message?
(2) Is sendResponse being called before the channel closes?
(3) Does the listener have `return true` if any code path is async?
(4) Is the URL I tested on one where content scripts inject?
Give me the single most likely cause, then the second most likely.
Checkpoint — First Message Round-Trip
Put the content.js listener code in content.js. In popup.js, put a getPageText() call that console.logs the length of the returned text. Reload your extension. Navigate to any article on the web, click your extension icon. Open the popup's DevTools (right-click in popup → Inspect). Does the console show the character count?
You just passed a message from one JavaScript context, had it read the page's DOM in another context, and got the result back. This is the mechanism every Chrome extension uses — from 100-line tools to billion-user products. From here, everything is "what do you do with the page text?" — which is the Claude integration in Segment 6.
▲ VERIFICATION LAYER · Message-Passing Sanity
Common ways AI gets THIS wrong:
  • Writes document.body.innerText directly in popup.js and wonders why it's empty — popup has its own DOM, not the page's
  • Makes the listener async but forgets return true — response never reaches the sender
  • Uses chrome.runtime.sendMessage (sends to service worker) when it should use chrome.tabs.sendMessage (sends to content script) — silent failure
The 30-second check: Content.js registered the listener at top level (not inside an async function). Popup uses chrome.tabs.sendMessage with tab.id. Async listeners return true. Testing on a normal website, not chrome://.
Segment 5 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toShip a working popup UI, wire the content script to it, and click a button that reads the current page's text.
Ship a working popup UI (you'll direct Claude to write most of this), wire the content script to return page text, and click a button that displays a character count from the current page. By end of segment: your extension reads pages. Segment 6 sends what it reads to Claude.

Building the Tool

⏱ ~25 min• Mostly AI-assisted scaffolding

Everything in this segment is standard HTML/CSS/JavaScript. You wrote plenty of that in BUILD. The popup is just an HTML page with a 380-pixel width constraint; the content script is the listener you already wrote in Seg 4. This segment is where BUILD's "direct Claude, don't type" muscle gets used — you're going to ask Claude to scaffold popup.html and popup.js, paste the output in, tweak the specific lines that matter, and move on.

What you'll type yourself: the prompts. What Claude produces: the popup markup, the CSS, the click handler wiring. What you'll read carefully: the two or three lines of glue code that connect the Seg 4 message passing to the DOM — because those are the bits that break if anything changes later.

Peek at Seg 6 now if you plan to fetch() from your popup. As soon as your popup tries to call your BUILD Worker's API endpoint (happens in Seg 6), you'll hit a CORS error unless your Worker is configured to accept chrome-extension://* origins. Segment 6 fixes this explicitly. If you see "Access to fetch at ... has been blocked by CORS policy" in this segment, jump to Seg 6's CORS section, apply the Worker-side change, redeploy the Worker, then come back here.
Step 1: Direct Claude to scaffold popup.html

Open Claude. Paste this prompt verbatim (tweak the colours/branding to match your BUILD site if you want):

Prompt template for Claude · popup.html
Write a popup.html for a Chrome extension. Constraints:

- Fixed width 380px, no scrollbars.
- Dark theme: background #0b0b0c, text #f3efe9 at 75% opacity.
- One h1 at top: "AI Page Analyser" (font-size 18px).
- One subtitle paragraph under h1 explaining what it does.
- One big primary button: "Analyse this page". Full-width, orange
  background #ff6a1f, dark text, no border, padded 12px, bold, 8px radius.
- One result div below the button, initially hidden, shows the analysis.
  Dark panel #1a1a1c, 14px padding, 8px radius, max-height 300px,
  scroll if overflow, whitespace: pre-wrap so newlines render.
- All CSS inline in a <style> tag. All fonts are system-ui, sans-serif.
- At the bottom of <body>, one script tag: <script src="popup.js"></script>

Output only the HTML, no explanations, no markdown fences.

Paste Claude's output into popup.html. Save. Reload the extension on chrome://extensions. Click the icon — the popup should now have the UI you described.

The prompt above is a contract. Everything I specified is intentional — the 380px width (Chrome's popup sweet spot), white-space: pre-wrap (so Claude's newline-separated analyses render correctly), the specific colour palette (matches BUILD). When you direct AI, be specific about the constraints, not just the goal. "Build me a popup" gets you something generic; specifying dimensions, colours, and behaviour gets you something shippable.
Step 2: Wire popup.js — the full click handler

Popup.js is the glue. It listens for the button click, asks the content script for page text, shows a "reading..." state, then displays the result. Here's the complete file — paste into popup.js:

popup.js · click handler + content-script message
const analyseBtn = document.getElementById('analyse');
const resultEl   = document.getElementById('result');

analyseBtn.addEventListener('click', async () => {
  // UI: disable button, show loading
  analyseBtn.disabled = true;
  analyseBtn.textContent = 'Reading page...';
  resultEl.style.display = 'block';
  resultEl.textContent = 'Extracting text...';

  try {
    // 1. Find the active tab
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

    // 2. Ask the content script for the page text
    const response = await chrome.tabs.sendMessage(tab.id, {
      action: 'getPageText'
    });

    if (!response?.text) {
      resultEl.textContent = 'Could not read this page. Try refreshing.';
      return;
    }

    // 3. For now, just show character count. Seg 6 adds the Claude call.
    resultEl.textContent = `Read ${response.text.length} characters from this page. (Seg 6 will send it to Claude.)`;

  } catch (err) {
    resultEl.textContent = 'Error: ' + err.message;
    console.error(err);
  } finally {
    analyseBtn.disabled = false;
    analyseBtn.textContent = 'Analyse this page';
  }
});
Notice the try/catch/finally. Real code. The button always re-enables in finally, even if the message fails. The error lands in resultEl so the user sees it, not in a silent console somewhere. This is the minimum viable pattern for UI-triggered async work.
Checkpoint — "Read N characters" Working
Reload your extension. Go to a news article or blog post. Click your extension icon → click "Analyse this page." Does the result panel show "Read N characters" with a real number?
Your extension reads web pages. It has a UI, a working button, a working message exchange, error handling, and visible feedback. Everything from here is "what do you do with the page text" — and Segment 6 makes that the real Claude analysis. The hard architectural work is behind you.

A functional Chrome extension that reads any web page. In four segments. Roughly an hour of work. This is the kind of thing that was a full product three years ago.

Segment 6 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toConnect the extension to your existing BUILD Worker, fix the CORS change the Worker needs, and show real AI analysis in the popup.
Connect the extension to your existing BUILD Worker, apply the one-line CORS change the Worker needs to accept extension requests, and show real Claude-generated analysis of the current page in the popup. This is the segment where the extension becomes useful.

AI Connection + CORS

⏱ ~30 min• Where it becomes useful

Your BUILD Worker already knows how to take a prompt and call Claude. You wrote it. It sits at a URL you own. The extension's popup is the one context (see Seg 4) that can make cross-origin fetches without running into CORS — because you listed the Worker URL in host_permissions. From the popup's perspective, calling your Worker is the same as calling it from your BUILD website: one fetch with a JSON body.

What changes on the Worker side is small but non-negotiable: the Worker has to respond with a CORS header that accepts the extension's origin. If it doesn't, Chrome blocks the response from reaching the popup. One-line manifest on the popup side, two-line fix on the Worker side. That's the entire CORS story.

Why the popup can fetch the Worker (and the content script can't)

This catches everyone once. Here's why popup-side fetches work and content-script-side fetches don't:

Popup fetches Worker — ✓ works
The popup runs with extension origin (chrome-extension://...). Chrome extends extensions special CORS privileges for origins listed in host_permissions. Your Worker URL is in there. Chrome waves the fetch through.
Content script fetches Worker — ✗ blocked (usually)
MV3 changed this: content scripts now run with the page's origin, so a fetch from content.js looks like github.com calling your Worker. Standard CORS rules apply. Your Worker must explicitly allow it — and on most setups it won't.

Our architecture puts the fetch in the popup, which is the right default. The content script's job is limited: read the page, hand the text back. The popup does the AI work. This also keeps secrets (like API keys, if you ever have any in the frontend) away from content scripts — which run in every page and are the most exposed surface of the extension.

The flow: popup → asks content script for page text → receives text → popup calls Worker → Worker calls Claude → popup receives analysis → popup renders analysis. Two message exchanges, one fetch. Nothing hits the page directly.
Worker-side change — add chrome-extension://* to CORS

Your BUILD Worker's existing CORS headers probably allow * (every origin) or just your Netlify site. For the extension to work, the Worker needs to either keep * (simpler) or explicitly allow chrome-extension://*. You also need to handle the OPTIONS preflight request that Chrome sends before the actual POST.

If your BUILD Worker already has a fully permissive CORS setup (Access-Control-Allow-Origin: *), the extension will work with no Worker changes. If you locked it down to your Netlify domain only — very sensible for a public site — here's the minimal diff:

Worker · CORS for extension access
const CORS_HEADERS = {
  'Access-Control-Allow-Origin': '*',  // or more specific (see below)
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type'
};

export default {
  async fetch(request, env) {
    // Handle CORS preflight — Chrome sends this before the POST
    if (request.method === 'OPTIONS') {
      return new Response(null, { headers: CORS_HEADERS });
    }

    // ... your existing Claude proxy logic ...
    const data = await callClaude(env, body);

    return new Response(JSON.stringify(data), {
      headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }
    });
  }
};
Two valid choices for the Allow-Origin value:
  • '*' — any origin can call. Simplest, fine for a course project, common in public APIs. Anyone could call your Worker, but since it doesn't expose secrets, the risk is limited to rate/cost.
  • request.headers.get('Origin') === 'chrome-extension://YOUR_EXT_ID' ? 'chrome-extension://YOUR_EXT_ID' : 'https://your-build-site.netlify.app' — echo back only approved origins. Stricter. Requires you to know your extension's ID (available once you load it in Chrome: chrome://extensions → your extension → ID field).

Deploy the Worker with npx wrangler deploy. Takes ~30 seconds. Your extension can now fetch from it.

popup.js — full version with Claude call

Update your popup.js from Seg 5 — replace the "Read N characters" placeholder with the real Worker call. This is the complete file:

popup.js · complete with Claude integration
const WORKER_URL = 'https://ai-proxy.YOUR-NAME.workers.dev';  // same URL as manifest

const SYSTEM_PROMPT = `You are a web page analyst. Given the text of a web page, provide:
1. A one-sentence summary (under 20 words).
2. The main argument or purpose.
3. Key claims (note any that lack clear sources).
4. A reliability assessment: High / Medium / Low, with one-line reason.

Keep the entire response under 200 words. Use short paragraphs.`;

const analyseBtn = document.getElementById('analyse');
const resultEl   = document.getElementById('result');

analyseBtn.addEventListener('click', async () => {
  analyseBtn.disabled = true;
  analyseBtn.textContent = 'Reading...';
  resultEl.style.display = 'block';
  resultEl.textContent = 'Extracting page text...';

  try {
    // 1. Get page text from content script (Seg 4/5)
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    const { text } = await chrome.tabs.sendMessage(tab.id, { action: 'getPageText' });

    if (!text) {
      resultEl.textContent = 'Could not read this page.';
      return;
    }

    // 2. Send to Worker → Claude
    analyseBtn.textContent = 'Analysing...';
    resultEl.textContent = 'Sending to Claude...';

    const res = await fetch(WORKER_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        prompt: text,
        system: SYSTEM_PROMPT
      })
    });

    if (!res.ok) throw new Error(`Worker: ${res.status}`);
    const data = await res.json();

    // 3. Render result (data.text depends on your Worker's response shape)
    resultEl.textContent = data.text || JSON.stringify(data, null, 2);

  } catch (err) {
    resultEl.textContent = 'Error: ' + err.message;
    console.error(err);
  } finally {
    analyseBtn.disabled = false;
    analyseBtn.textContent = 'Analyse this page';
  }
});
Shape of the request + response depends on your BUILD Worker. I've assumed the Worker accepts { prompt, system } and returns { text }. If yours takes different fields (e.g. { message, context } or { userInput }), adjust the request body and response parsing to match. One of the few places where line-level editing matters — your Worker's contract, not mine.
Checkpoint — Real AI Analysis Working
Reload your extension. Go to a real news article or blog post. Click extension icon → "Analyse this page". Does Claude's analysis (1-sentence summary + main argument + reliability rating) appear in the result panel within ~10 seconds?
You have a working AI-powered Chrome extension. Right now. Reading any page, sending it to Claude via your Worker, rendering the analysis in a popup. This is the kind of extension that, three years ago, would have been a company. Push the code: git init && git add . && git commit -m "working AI page analyser" — Segments 7-10 are about polish, power features, and publishing.

Try the extension on different kinds of pages — a news article, a Wikipedia page, a technical blog post. Notice how the analysis changes with the content. That's the system prompt doing its work.

▲ VERIFICATION LAYER · CORS Sanity
Common ways AI gets THIS wrong:
  • Tells you to move the fetch into the content script — which then hits full CORS, doesn't help
  • Suggests mode: 'no-cors' in fetch — makes the response opaque, you can't read the body, looks like it works until you notice data is always empty
  • Writes CORS headers into the request instead of the response — wrong direction, silently does nothing
The 30-second check: Fetch is in popup.js (not content.js). Worker URL in popup.js matches host_permissions. Worker returns Access-Control-Allow-Origin header. Worker handles OPTIONS with a 200.
Segment 7 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toSave analysis history to chrome.storage, add an options page for user settings, and wire a keyboard shortcut that triggers analysis.
Save analysis history to chrome.storage so users can see previous analyses, add an options page so users can customise the system prompt, and wire a keyboard shortcut (Ctrl+Shift+Y) that triggers analysis without clicking. Three power features that turn the extension from a toy into something people use daily.

Power Features · Storage, Options, Shortcuts

⏱ ~35 min• The difference between "demo" and "daily driver"

A usable extension needs three things your Seg 6 version doesn't have. It needs to remember what it did (history, preferences). It needs a place for users to change settings without editing code (options page). And it needs a keyboard shortcut so power users don't have to click (Commands API). None of these are conceptually hard. All of them are the reason someone installs an extension once and never uses it again if they're missing.

After this segment, your extension is a real product. You could publish it to the Chrome Web Store as-is. Segments 8-10 add the production polish (service worker lifecycle, publishing) but this segment is the functional finish line. Everything here is the kind of feature reviewers look for before approving an extension as "useful."
chrome.storage — three flavours, pick one

Extensions can't use localStorage reliably (different contexts see different copies, service workers can't access it at all). The replacement is chrome.storage, which comes in three flavours. The decision is judgment-based — here's when each applies:

chrome.storage.sync
~100 KB total · 8 KB per item · 512 items max
Syncs across every Chrome browser the user is signed in to. Ideal for small settings — their preferred system prompt, their theme choice. Not for history or large data.
chrome.storage.local · 10 MB total
Local to this machine, persists forever
Ten megabytes is plenty for hundreds of stored analyses. Use for history, caches, anything that grows. Doesn't sync across devices — fine for per-machine data.
chrome.storage.session · 10 MB in-memory only
Survives service-worker restarts, clears on browser restart
The service-worker's memory. Survives when the SW terminates and wakes up (Seg 8), doesn't survive the browser quitting. Use for transient state that shouldn't leak to disk.

Our extension uses two: storage.sync for the user's custom system prompt (small, syncs across their devices), storage.local for analysis history (grows over time, single-machine is fine).

Step 1: Save history on every analysis

In popup.js, after you display the result, write it to storage.local. Keep the most recent N items; drop older ones when the list grows.

popup.js — add after result is displayed
async function saveToHistory(entry) {
  const { history = [] } = await chrome.storage.local.get('history');
  history.unshift(entry);  // newest first
  if (history.length > 50) history.length = 50;  // cap
  await chrome.storage.local.set({ history });
}

// Call this right after `resultEl.textContent = data.text`:
await saveToHistory({
  timestamp: Date.now(),
  url: tab.url,
  title: tab.title,
  analysis: data.text.slice(0, 500)  // trim to save space
});

That's it. Chrome handles the persistence. Reload the extension, run a few analyses, then in popup DevTools console run await chrome.storage.local.get('history') — you'll see your recent analyses as structured data.

Fifty items × 500 chars per analysis × a few bytes of metadata = well under 100KB. Nowhere near the 10MB storage.local limit. If you ever want unlimited storage (useful for long-term history or caching large responses), add "unlimitedStorage" to your manifest's permissions — but only then, not pre-emptively.
Step 2: Add an options page

An options page lets users change settings without touching code. Standard pattern: create an options.html with a form, write submitted values to storage.sync, read them from popup.js before the Claude call.

Add this to manifest.json:

manifest.json — add options_page
"options_page": "options.html",

Create options.html — direct Claude with:

Prompt template · options.html
Write an options.html for a Chrome extension. Constraints:

- Full page, max-width 600px, centered.
- Dark theme: same colours as my popup (bg #0b0b0c, text #f3efe9 at 75%).
- h1: "AI Page Analyser — Settings".
- One labelled textarea for "System prompt" (15 rows), placeholder "Leave
  blank to use default…".
- One "Save" button below — primary orange #ff6a1f.
- One success message div (initially hidden) that shows "Saved!" for 2s.
- Inline <style> tag. Link to options.js at the bottom.

Output only HTML.

Then options.js — three short handlers:

options.js
const textarea = document.getElementById('systemPrompt');
const saveBtn  = document.getElementById('save');
const success  = document.getElementById('success');

// Load on open
chrome.storage.sync.get('systemPrompt').then(({ systemPrompt }) => {
  textarea.value = systemPrompt || '';
});

// Save on click
saveBtn.addEventListener('click', async () => {
  await chrome.storage.sync.set({ systemPrompt: textarea.value });
  success.style.display = 'block';
  setTimeout(() => success.style.display = 'none', 2000);
});

In popup.js, read the saved prompt before calling Claude (fall back to the default):

popup.js — use stored system prompt
const { systemPrompt } = await chrome.storage.sync.get('systemPrompt');
const finalSystemPrompt = systemPrompt?.trim() || SYSTEM_PROMPT;
// ... then use finalSystemPrompt in the fetch body
Users now control the prompt. They can turn your "page analyser" into a fact-checker, a writing critic, a sentiment analyser, whatever they want — by editing one textarea. Same code, infinite variations. Right-click the extension icon → Options to open the page.
Step 3: Keyboard shortcut with the Commands API

Add a "commands" block to manifest.json declaring the shortcut. There's a reserved command name _execute_action that Chrome handles for you — when the shortcut fires, it opens your popup. No JS needed:

manifest.json — add commands block
"commands": {
  "_execute_action": {
    "suggested_key": {
      "default": "Ctrl+Shift+Y",
      "mac": "Command+Shift+Y"
    },
    "description": "Open AI Page Analyser"
  }
}

Reload your extension. Navigate to any page. Press Ctrl+Shift+Y (or Cmd+Shift+Y on Mac). Your popup opens — same as clicking the icon. Users can remap this to any shortcut they prefer at chrome://extensions/shortcuts.

Rules for shortcuts that save you grief:
  • Must include either Ctrl or Alt. Shift alone won't work.
  • Avoid Ctrl+number shortcuts (Chrome uses them for tab switching).
  • Avoid Ctrl+T, Ctrl+W, Ctrl+Shift+N (also Chrome-reserved).
  • You can suggest up to 4 shortcuts total. Users add more via chrome://extensions/shortcuts.
  • If your chosen shortcut conflicts with something the user already has, Chrome silently refuses to bind it — no error. Test after reload.

If you want the shortcut to run the analysis immediately (not just open the popup): use a custom command name instead of _execute_action, add a service worker (Seg 8), and listen for chrome.commands.onCommand in it. For now, opening the popup is the right call — it lets the user see what's happening.

Checkpoint — Power Features Live
(1) Run an analysis, then open popup DevTools console and run await chrome.storage.local.get('history') — do you see entries? (2) Right-click extension icon → Options, change the system prompt, save, run an analysis — does it use the new prompt? (3) Press Ctrl+Shift+Y on any page — does the popup open?
Your extension now has history, customisable system prompts, and a keyboard shortcut. It's a real product. A user who installs it can: press a key to open it, click a button to analyse any page, change the analysis style via settings, scroll back through previous analyses. That's the full feature set most popular Chrome extensions ship with.

The extension is now feature-complete for shipping. Three more segments before it goes to the Chrome Web Store — service worker lifecycle gotchas (Seg 8), and the publishing flow itself (Segs 9-10).

Segment 8 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toExplain why the Manifest V3 service worker terminates, debug it when state vanishes, and write background logic that survives lifecycle events.
Explain the MV3 service worker lifecycle, debug the "my variable was there and then it wasn't" class of bug, use chrome.storage as the only reliable state store, set up chrome.alarms for periodic work, and handle chrome.runtime.lastError properly. This segment is the hour-of-debugging-avoided segment — the single biggest source of wasted time for first-time MV3 extension builders.

Service Worker Lifecycle · The State Trap

⏱ ~20 min• The #1 debug-hour-eater in MV3

Up to now your extension hasn't had a background service worker — the popup and the content script have done all the work. For anything more ambitious (scheduled checks, cross-tab coordination, right-click menus, badge updates), you need a service worker. And the MV3 service worker behaves in one specific way that trips up every first-time extension builder: it terminates when idle, and your in-memory state vanishes with it.

This is the single most common source of "why doesn't my extension remember what I told it?" bugs. The variable was there five seconds ago. You didn't touch it. You come back from checking another tab and it's undefined. The cause is almost never a bug in your logic — it's that Chrome killed your service worker to save memory and restarted it cold when the next event arrived.

The one rule that saves you: assume your service worker can terminate between any two events. If a value needs to survive for more than a single message handler, put it in chrome.storage. Treat in-memory variables as caches, not state. Get this habit right now and 90% of MV3 background-script bugs disappear.
What actually happens · the lifecycle in plain English
① Idle → terminated
After roughly 30 seconds of no events, Chrome shuts down your service worker. Memory freed. Timers cancelled. Variables gone. This is a feature, not a bug — it's why MV3 extensions don't hog RAM.
② Event arrives → wake up
A message, an alarm, a tab update, a click — anything the SW listens for fires. Chrome restarts the SW cold. Your top-level code runs again. Then the event handler runs.
③ Handler finishes → back to idle
Once your handler returns, the 30-second countdown starts again. If another event arrives within it, the SW stays warm. If not, it terminates once more.

Key implication: every time your SW wakes up, it starts fresh. Any let userCount = 0 at the top of your file is back at 0 after termination. Any object cached in a closure is gone. Any setInterval you started has been cancelled. The only things that persist are chrome.storage and data inside IndexedDB.

In DevTools you can watch this happen. Open chrome://extensions → click "Inspect views: service worker" under your extension. The DevTools console shows the SW. Leave it alone for 30-40 seconds; the word "service worker" in the tab header goes grey (terminated). Send a message to it from your popup — it goes back to black (alive), and your top-level console.logs fire again. That's the cycle.
The wrong way vs the right way
❌ Wrong — state in a variable
// background.js (service worker)
let analysisCount = 0;  // resets every time SW wakes up

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'analysed') {
    analysisCount++;
    console.log('Total analyses:', analysisCount);
  }
});

// User runs 3 analyses quickly → logs "1, 2, 3"
// User waits 5 minutes, runs another → logs "1" again. Why?
// Because the SW was terminated and the counter reset.

The fix: store the value in chrome.storage. Read it when you need it, write it when you update it. Never trust the in-memory version to survive.

✅ Right — state in chrome.storage
// background.js (service worker)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'analysed') {
    (async () => {
      const { analysisCount = 0 } = await chrome.storage.local.get('analysisCount');
      const next = analysisCount + 1;
      await chrome.storage.local.set({ analysisCount: next });
      console.log('Total analyses:', next);
      sendResponse({ count: next });
    })();
    return true; // async sendResponse, keep channel open
  }
});
Read-modify-write race condition warning: if two events fire at the same time and both do get → +1 → set, you can lose updates (both read 5, both write 6, instead of one writing 6 and the other writing 7). For a personal tool this rarely matters. For anything with high-frequency writes, use chrome.storage.onChanged listeners or batch updates. Good to know. Not a panic point.
When you need periodic work · chrome.alarms

If your extension needs to do something on a schedule — refresh cached data every hour, poll an API every 15 minutes, clean up old history weekly — don't use setInterval. The SW terminates, the interval cancels, nothing ever fires again. Use chrome.alarms, which Chrome persists outside the service worker and uses to wake it up on schedule.

Using chrome.alarms for periodic work
// background.js — create the alarm once, on install
chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.create('cleanup-history', {
    periodInMinutes: 60  // fire every hour, min 0.5 (30s) since Chrome 120
  });
});

// Handle the alarm when it fires (wakes up the SW if needed)
chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === 'cleanup-history') {
    const { history = [] } = await chrome.storage.local.get('history');
    // Keep only the most recent 100 entries
    const trimmed = history.slice(0, 100);
    await chrome.storage.local.set({ history: trimmed });
  }
});

Three things to know about alarms:

  • Alarms need the "alarms" permission in manifest. Add it alongside "storage".
  • Since Chrome 120, minimum period is 30 seconds (previously 1 minute). For daily or hourly jobs this is irrelevant; for "every 30s" polling, it's enabled.
  • If the computer is asleep when the alarm should fire, Chrome fires it once when the device wakes up — not every missed slot. Good behaviour for daily tasks; bad if you're counting on it to fire every hour for 24 hours.
Checkpoint — SW Survives the Idle Cycle
Open your SW's DevTools. Run an analysis (SW wakes up). Wait 40+ seconds without clicking anything (watch the SW tab go inactive/grey). Run another analysis. Does your history count in storage continue from where it left off instead of resetting?
You now have the MV3 mental model right. State lives in chrome.storage. Variables are caches. Alarms wake the SW for periodic work. The next time you build an extension from scratch — or debug someone else's — this segment will save you an hour of "but the variable was right there..."
▲ VERIFICATION LAYER · The MV3 Mental Model
Common ways AI gets THIS wrong:
  • Claude will happily write let cache = {} at the top of background.js and treat it as persistent — then be surprised when it empties
  • Writes setInterval instead of chrome.alarms for periodic work — works in testing, fails overnight
  • Forgets to declare "alarms" or "storage" in manifest permissions and writes code that looks right but silently fails
The 30-second check: Anything you want to survive SW termination lives in chrome.storage. Any periodic work uses chrome.alarms. Permissions in manifest match what you actually use. Long-running work goes in the alarm handler, not a setInterval.
Segment 9 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toPrepare a production-ready submission zip, produce the screenshots and descriptions the Chrome Web Store requires, and pay the $5 one-time developer fee.
Prepare a production-ready submission zip with the correct folder structure, produce the 1280×800 screenshots and the short/full descriptions the Chrome Web Store requires, pay the $5 one-time developer fee, and avoid the 6 most common pre-submission mistakes that force resubmission. By the end of this segment your extension is uploaded and sitting ready to submit.

Publishing Part 1 · Preparing the Submission

⏱ ~35 min• The payoff segment

Up to now your extension has been a dev-mode unpacked extension — working in your Chrome, invisible to everyone else. This segment ends with it uploaded to the Chrome Web Store and one click away from submission. The actual submit + review + live moment happens in Segment 10. This segment gets you across the starting line.

Most extensions that get rejected on first submission are rejected for avoidable reasons: wrong zip structure, missing privacy policy, unused permissions, no screenshots. Working through this segment methodically — tick every box before submitting — is the difference between a one-shot approval in 3 days and three rejection cycles over 3 weeks.

A note on timing. First-time developer accounts take 7-14 business days to review. Returning accounts (once you've had one extension approved) drop to 2-5 days. Updates to approved extensions review in 24-48 hours. Plan for a 2-week buffer from "I'm ready to submit" to "it's live" for your first submission.
Step 1: The pre-flight checklist

Before you touch the Chrome Web Store dashboard, verify these items in your project. Every one of them is a known rejection trigger. Five minutes here saves three days of review cycle later.

☐ manifest.json version bumped — if this is an update, increment "version". Web Store rejects identical-version reuploads.
☐ No unused permissions — every permission in your manifest must be called somewhere in your code. If you copied "tabs" or "scripting" from a tutorial and never used it, remove it.
☐ host_permissions is not <all_urls> unless genuinely needed — request specific domains (your Worker's URL) instead of the universal wildcard. Broad permissions trigger extra review scrutiny.
☐ No remote code — every JavaScript file referenced in your extension must be in the zip. No CDN scripts, no eval() of fetched code, no dynamically-imported remote modules. MV3 bans all of this.
☐ Icons present at all required sizes — 16×16, 48×48, 128×128 PNG minimum. The 128×128 is mandatory for the store listing itself.
☐ All file paths in manifest resolve — file paths are case-sensitive on the Linux machines Chrome reviews on. Icons/icon-128.png and icons/icon-128.png are different files. If it works on your Mac but your manifest has a capital, it will fail review.
☐ No console errors — reload your extension, open the popup, open the SW inspector, use the extension on a normal page. Zero red errors in any console. One unhandled runtime error is a rejection.
Direct Claude to help. If you want a fast sanity check, paste your entire manifest.json plus the paths of every JS file you reference to Claude and ask: "Audit this against the Chrome Web Store pre-submission checklist. Flag any permissions that aren't used in the linked files, any paths that won't resolve, any MV3 policy violations." Claude is good at this class of audit because it's structured verification, not creative work.
Step 2: Build the zip correctly

This catches people. The zip you upload must contain manifest.json at the root level. Not inside a folder. Not inside ai-extension/. If your extension folder is called ai-extension and you right-click the folder and "Compress", the zip will contain the folder which contains the manifest — and the store will reject it with "no manifest.json at root."

✅ Correct zip structure
my-extension.zip
├── manifest.json     ← at root, not inside a folder
├── popup.html
├── popup.js
├── content.js
├── background.js
├── options.html
├── options.js
└── icons/
    ├── icon-16.png
    ├── icon-48.png
    └── icon-128.png

How to do it right: open the extension folder, select all files and subfolders inside it, then compress the selection. Not the folder — the contents.

Mac / Linux terminal — one-liner
# From inside your extension folder:
cd ai-extension
zip -r ../my-extension.zip . -x ".*" -x "__MACOSX"

The -x flags exclude hidden files (the . files Macs love adding) and the __MACOSX metadata folder. Both will cause the store to flag your zip as containing unexpected files.

Size limits
Max zip size: 500 MB. Recommended: under 10 MB for faster automated review. If your extension is over 50 MB, something is wrong — most likely you've bundled node_modules or images you don't need. Audit the contents before uploading.
SHARP M2 The Missing ContextAsk Claude to "zip my extension" and it'll produce a command that zips the folder, not the contents.
When you ask Claude "write a command to zip my extension for the Chrome Web Store," the default response is zip -r extension.zip ai-extension/. That produces a zip with the folder inside, which the store rejects. The fix is context: "The Chrome Web Store requires manifest.json at the root of the zip, not inside a subfolder. Give me the command." Specificity defeats the Caveat and the Missing Context at once.
Step 3: Screenshots and descriptions

Every store listing needs a minimum of one screenshot, a 128×128 icon for the listing, a short description (132 chars max), and a full description. Your extension can be perfect and reject if these are wrong. Treat them as first-class deliverables.

Screenshots
  • Minimum 1, maximum 5
  • 1280×800 or 640×400 PNG/JPEG
  • Must actually show your extension in use, not a mockup
  • Keep the screenshot of the popup in action the first — it's what users see in search results
Descriptions
  • Short: 132 chars, shown in search results
  • Full: 16,000 chars max, shown on the listing page
  • No keyword stuffing — Google's filter catches it
  • State exactly what the extension does and which data it sends where

For the screenshots themselves: open a real news article, run your analyser, screenshot the popup while the analysis is showing. Do this twice — once for a successful analysis, once for the options page — and you have two screenshots. Don't over-design; reviewers and users both prefer "shows what it does" to "beautiful graphic design." Use your desktop's built-in screenshot tool (Cmd+Shift+4 on Mac, Win+Shift+S on Windows) and resize to 1280×800 if needed.

Description template — direct Claude to draft this
# Prompt template — paste into Claude, get back a good first draft

Write a Chrome Web Store description for my AI Page Analyser extension.
It reads the current web page, sends the text to my Cloudflare Worker
which calls Claude (Anthropic API), and shows a 4-section analysis
(summary, main argument, key claims, reliability). Include:
(1) 132-char short description,
(2) 3-4 paragraph full description,
(3) a numbered list of permissions and why each is needed.
Plain clear language. No marketing fluff.
Permission justifications matter more than you think. Reviewers check every permission in your manifest against your code and your description. If you have activeTab but your description never mentions reading pages, that's a flag. Keep descriptions honest and specific and reviews go smoothly.
Step 4: Pay the $5, upload the zip

The moment of commitment. You'll register as a Chrome Web Store developer (one-time), pay $5 (one-time, covers your lifetime of publishing), and upload your zip. After this, you have an unpublished draft listing ready for Segment 10's submission flow.

  1. Go to chrome.google.com/webstore/devconsole
  2. Sign in with the Google account you want to publish under (you cannot change this later for an extension — pick carefully)
  3. Accept the developer agreement
  4. Pay the $5 one-time registration fee via Google Pay
  5. Click "New Item" and upload your zip
  6. You'll land on the draft listing page with tabs: Store listing, Privacy practices, Distribution
  7. Fill in the Store Listing tab now — title, short description, full description, icon, screenshots, category
  8. Don't submit yet. Privacy Practices + final review happen in Segment 10.
Category matters for discoverability. For an AI page analyser, the right category is typically "Productivity" or "Developer Tools". Browse the Web Store, find extensions similar to yours, note their categories, pick the match. Wrong category isn't a rejection risk but it will bury your listing in searches.
Checkpoint — Draft Listing Created
In your developer dashboard, do you see your new item listed with "Draft" status, zip uploaded, and at least the Store Listing tab filled in?
Halfway to live. Your extension is uploaded, the fee is paid, the listing is drafted. One more segment covers the Privacy Practices tab, the submission itself, and the review flow. After that you're waiting on Google, not working.
Segment 10 of 10 · Chrome Extension Add-On
By the end of this segment you will be able toPublish a privacy policy, complete the Privacy Practices form, submit for review, manage the review cycle, ship future updates, and close the project into your portfolio.
Publish a privacy policy on your BUILD site, complete the Chrome Web Store Privacy Practices form honestly, submit the extension for review, understand and plan around the review timeline, push future updates without drama, and close the project into your portfolio in a way that pulls its weight as a hiring signal. After this segment the extension is on Google's side of the fence.

Publishing Part 2 · Submit & Go Live

⏱ ~30 min⬡ The final push

The Privacy Practices tab is the one tab that rejects more extensions than any other. Your extension reads web page content and sends it to a third-party API (Anthropic, via your Worker). That is reportable data handling. Not declaring it is a Chrome Web Store policy violation that gets extensions removed even after they go live. Declaring it honestly is a 10-minute form.

This segment does the honest declaration, the submission, and the "what happens between 'submit' and 'live'" part that nobody writes about clearly.

The mindset: Chrome Web Store reviewers are not adversaries. They have a checklist. Pass the checklist and you get approved. Miss an item and you get a specific rejection email you can fix in 10 minutes. Most of the horror stories come from developers who didn't follow the checklist and don't want to admit it.
Step 1: Write and publish your privacy policy

You need a privacy policy at a publicly accessible URL before you can complete the Privacy Practices tab. It doesn't need to be long. It needs to be truthful, cover every type of data you handle, and live at a URL the reviewer can load.

Where to host it: your BUILD site. You already have a live Netlify deployment with your AI tool. Add a privacy.html page to that same repo, git push, and Netlify deploys it automatically at https://yoursite.netlify.app/privacy.html. That URL is what you paste into the Chrome Web Store dashboard.

Direct Claude to draft it for your specific case
# Prompt template — paste into Claude, edit the output for accuracy

Draft a privacy policy page for my Chrome extension "AI Page Analyser".

Facts about the extension:
- It reads the text content of the active tab when the user clicks "Analyse"
- It sends that text to my Cloudflare Worker at 
- The Worker forwards the text to Anthropic's Claude API
- The Worker returns the analysis and the extension displays it
- It stores the user's last 50 analyses in chrome.storage.local (local only, not synced)
- It stores the user's custom system prompt in chrome.storage.sync (synced across their Chrome sign-in)
- It does NOT send data to any analytics service, ad network, or third party beyond Anthropic
- It does NOT collect the user's identity, email, or cookies
- Data retention: page text is sent to Anthropic per Anthropic's own retention policy; local history is retained until the user uninstalls

Structure the policy as HTML with h1/h2/p tags, styled minimally.
Include: what data is collected, how it is used, third parties (Anthropic), user controls (how to clear history), contact info placeholder.
Target length: 400-600 words. Plain English. No legalese.

Edit the output before you publish it. Replace every placeholder. Verify every factual claim actually matches what your extension does — if you said "we don't send to third parties beyond Anthropic" but you also added Google Analytics for fun, fix one of them. The privacy policy is the legal promise you're making to users.

Privacy policy URL + Store declaration must agree. If your privacy policy says "we collect nothing" but your Privacy Practices tab declares "web content," Google flags the inconsistency and holds your submission for manual review. Match them. Exactly. It's the single easiest rejection to avoid and the single most common reason for delay.
Step 2: Complete the Privacy Practices form

In your developer dashboard, click the Privacy Practices tab. There are several required sections. Here's the honest answer for an AI page analyser that sends page content to Anthropic — adjust if your extension does more or less.

Single purpose description
"Analyse the content of the current web page using AI and display a summary with reliability assessment in a popup." One clear purpose, one sentence.
Permission justifications (one per permission)
  • activeTab — "Required to read the text content of the tab the user clicks to analyse."
  • scripting — "Required to inject the content script that extracts page text on demand."
  • storage — "Required to save the user's analysis history locally and their custom system prompt."
  • host_permissions: https://your-worker-url — "Required to send analysis requests to our own Cloudflare Worker which proxies to the AI provider."
Data types disclosure (tick every box that applies)
  • Website content — yes. You send page text to Anthropic.
  • Web history — no, unless you store URLs. Usually no.
  • User activity — no, unless you track clicks/hovers. Usually no.
  • All others — no (personal info, auth, location, etc.)
Certification statements (you must tick all three)
  • I do not sell or transfer user data to third parties outside of the approved use cases
  • I do not use or transfer user data for purposes unrelated to my item's single purpose
  • I do not use or transfer user data to determine creditworthiness or for lending purposes

Paste the privacy policy URL into the field at the bottom. Save. The tab now shows green / complete.

Direct Claude to draft the permission-justification strings. The text you write for each permission matters — reviewers read these word-for-word and reject vague justifications. Claude writes much better ones than most developers do on first try. Prompt: "For a Chrome extension with these permissions [paste list], write a one-sentence justification for each that (a) names the specific user-facing feature that requires it, (b) avoids the word 'may' or 'might', (c) states what would break if the permission were removed. Under 30 words each." Paste the output into the Privacy Practices form verbatim.
Step 3: Submit and the honest review timeline

Click Submit for Review. You'll get an email confirming submission within a minute. From here you're waiting, not working.

First submission
7–14 days
Brand-new developer accounts get the longest review window. Google is evaluating account trust as well as your extension.
Returning developer
2–5 days
Once you have one approved extension, subsequent reviews drop significantly. Trust accumulates on the account, not per extension.
Updates to existing
24–48 hours
Updates to an already-approved extension clear the fastest. Minor bumps sometimes go live within a few hours of submission.

What to do during review: nothing urgent. Don't withdraw and resubmit — that restarts the queue. Don't email support asking for updates until day 14+ for a first submission. The review queue moves in order; bothering it doesn't speed it up.

If you get rejected: you'll receive an email with a specific reason. It's almost always one of these: unused permission, privacy policy missing/mismatched, remote code, description doesn't match functionality, broken file reference. Fix the one thing, bump your manifest version, re-zip, click "Resubmit" on the same listing (not a new one). Resubmission reviews are faster than the initial.

If you get approved: the listing goes live at a URL like https://chromewebstore.google.com/detail/your-extension-id. You'll get an email. Share the URL. Screenshot it. This is a portfolio moment.

Step 4: Shipping updates post-approval

Your extension is approved and live. At some point you'll want to update it — fix a bug, add a feature, change the system prompt. The update flow is the same submission flow compressed into 24-48 hours.

  1. Make your code changes, test locally in unpacked mode
  2. Bump "version" in manifest.json (1.0.01.0.1 for patches, 1.1.0 for features, 2.0.0 for breaking changes — semver is a convention, Chrome doesn't enforce it)
  3. Re-zip using the same one-liner as Segment 9
  4. In dev dashboard → your item → Package tab → Upload new package
  5. If any Store Listing or Privacy Practices fields changed (new permissions, new data types), update those tabs too
  6. Click Submit. 24-48 hours later, existing users get the update automatically.
New permissions require user re-consent. If your update adds a permission the original install didn't have, existing users see the extension get disabled until they manually click "accept new permissions." This is the right behaviour for security but it kills your user base's engagement. Avoid it if you can — use chrome.permissions.request() for runtime-optional permissions instead of adding them to manifest.
▸ Beta-channel releases (for when you want to test updates with a few users first)Chrome lets you publish to a "trusted testers" group before rolling an update to everyone. Useful for risky changes.
In the Chrome Web Store dashboard, under your item → Advanced → Visibility, you can set a release to "Trusted testers" and whitelist specific Google accounts. Submit. Only those accounts see the update. Once you've confirmed it's stable, flip visibility back to Public and push to everyone. This is how professional extension teams ship risky changes. For a solo project with low user counts, it's overkill — but worth knowing exists when your user base crosses a few hundred.
Blast radius — borrowed from the Automation add-on. A published extension's blast radius is larger than most developers realise. A bug that injects wrong content, auto-fills a form incorrectly, or sends a bad API request isn't "bad for one user" — it's bad for every installed user, simultaneously, until Google pushes the next update. Ask yourself before each ship: "if this extension fired incorrectly on 10,000 pages tomorrow morning, what would break?" For a read-only analyser like the tool you built, the answer is "nothing meaningful." For an extension that fills, sends, posts, or purchases — the answer is a lot, and the extension needs a human-in-the-loop approval pattern before those actions go live. The Automation add-on's HITL pattern (Seg 9 there) transfers directly: queue the intended action, email the user for review, only execute on explicit approval.
Chrome Extension Add-On Complete
Your extension is on the Chrome Web Store. 3.3 billion potential users are a search away.
Portfolio close · do this now
1. Update your BUILD project's README. Add a section: Chrome Extension — available on the Chrome Web Store. Link to the live listing. One screenshot of the popup in action.

2. Pin the repo on your GitHub profile. An "AI tool with live Chrome extension" is a harder story to tell than "I followed a tutorial." Both the code and the live distribution matter.

3. The one-liner for everywhere else. On LinkedIn, in cover letters, in portfolio pages: "Built and shipped a published Chrome extension that uses AI to analyse web pages — live on the Chrome Web Store." Every word in that sentence is load-bearing. Most developers never ship a published extension. You did.
FINAL · TESTS JUDGMENT NOT TRIVIA
Your extension is live. A user emails you: "Your extension stopped remembering my analyses when I restart Chrome." You look at the code — you're using chrome.storage.local. It should work. What's the most likely cause?
chrome.storage.local is broken in the latest Chrome
Almost never the answer. Chrome's storage APIs are battle-tested. When your code looks right but behaviour is wrong, the explanation is usually in your own mental model, not Chrome's implementation.
You're writing to storage in a service worker without awaiting, and the SW terminates before the write commits
This is the answer. Classic MV3 service-worker lifecycle bug. You call chrome.storage.local.set(...) without await, the handler returns, the SW terminates, and the write never completes. The fix is to await every storage write in the SW context. Segment 8's rule: assume the SW can terminate between any two statements.
The user's Chrome has storage disabled
There is no user-facing setting to disable chrome.storage for a single extension. The permissions are granted at install. If storage was disabled, nothing would be stored at all — not "stops being remembered after restart."
You need to use chrome.storage.sync instead of local
Sync and local behave the same with respect to surviving browser restart — both persist. Sync additionally replicates across the user's other Chrome sign-ins, but that's an additional feature, not a fix for persistence. If local isn't persisting, sync won't either.
10 segments. One published Chrome extension. Live.
You started with a BUILD Worker and an idea. You finished with: a Manifest V3 extension, message-passing architecture, chrome.storage persistence, an options page, a keyboard shortcut, a service worker that survives its own lifecycle, a published listing on the Chrome Web Store, a privacy policy, and three new portfolio lines. Most developers never do this. You did it in ten segments.
What's next: the PWA add-on is a different distribution path — installable web apps that don't require app-store approval, optimised for phones. Or SCALE, the full follow-on course, when your tool has real users and BUILD's verification habits aren't enough anymore. You can do either in any order. You've earned the choice.
Glossary

Key terms for this add-on

activeTab permission
A permission that grants temporary access to the current tab only when the user invokes the extension. Safer than host_permissions.
Background service worker
In MV3, the always-on event-driven script that handles messaging, alarms, and cross-tab state. Terminates when idle.
Blast radius
A bug in a published extension affects every installed user simultaneously. The same safety framework as scheduled automations applies.
chrome.alarms
MV3 API for scheduling events. Minimum period 30 seconds in Chrome 120+. Replaces the setTimeout pattern that broke when SW terminates.
chrome.runtime.sendMessage
The API for sending a message from one extension context (popup) to another (background or content script). Returns a Promise in MV3.
chrome.storage.local / sync / session
10 MB local; 100 KB sync (Google-account-replicated); 10 MB session (memory-only). Different durability guarantees per scope.
Content script
JS injected into a web page. Can read/modify the page DOM but can't use most chrome.* APIs directly — communicates with background via messages.
CORS
Cross-Origin Resource Sharing. Your Worker must send Access-Control-Allow-Origin headers accepting chrome-extension://* or the popup's fetch() will fail.
host_permissions
An array listing which sites your extension can read/modify. Broad permissions (<all_urls>) trigger longer review.
Manifest V3 (MV3)
The current required extension format. Enforces a service-worker background, restricts remote code, and tightens CSP rules.
manifest.json
The extension's identity file — name, version, permissions, entry points. MV3 requires specific fields.
Popup
The HTML page shown when the user clicks the extension icon. 380×600 px-ish; state doesn't persist between opens.
Chrome Web Store fee
One-time $5 developer registration. First-time review 7–14 days; returning 2–5 days; updates 24–48 hours.
Privacy Practices tab
Mandatory Web Store form listing what user data you collect, why each permission is needed, and your privacy policy URL.
Service worker lifecycle
install → waiting → activate → fetch/idle → terminated. Same model in PWAs, different APIs.
Unpacked extension
Loading a local folder directly into Chrome for dev (chrome://extensions → Load unpacked). Lets you iterate without re-uploading.
Welcome back — resume at slide ??