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.
Need to set language, AI-usage tier or other preferences? Open the full setup wizard
EverythingThreads · ICO: C1896585 · Privacy Policy
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.
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.
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.
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.
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.
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.
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.
# 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.
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_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"
}
}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.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.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.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.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.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.action.default_icon is fine for a first extension — one set of icons covering both uses.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.
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.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.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.)" that looks curly.manifest_version must be the number 3, not the string "3".
manifest_version: 2 out of training habit — Chrome rejects it at installhost_permissions inside the permissions array — V2 syntax, V3 keeps them separatebrowser_action instead of action — V2 naming, V3 renamed it"background": { "scripts": [...] } — V2 syntax. V3 uses "service_worker" (which we'll add in Seg 8)manifest_version is 3. host_permissions is its own top-level field. action (not browser_action). YOUR-NAME replaced. JSON lint passes.
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.
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:
icons/ folder. Fastest path, zero design knowledge.icon.svg. Use a free online SVG-to-PNG tool (CloudConvert, iloveimg.com) to output 16, 48, and 128 PNG versions.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.
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.Chrome's developer mode lets you install the extension directly from your local folder. Five clicks, takes 30 seconds.
chrome://extensions in the address bar and press Enter.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).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.
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.
console.log from popup.js appears here, not on the underlying page.console.log lands here. This is a different console from the popup's.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.
icons/ folder exists and has all three PNGs with exact filenames from your manifest.default_popup path wrong in manifest, or popup.html doesn't exist. Check both.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.
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.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.
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.
fetch (host_permissions bypasses CORS). Update its own DOM. Read from chrome.storage.document.body.innerText, etc). Isolated from the page's own scripts (can't see the page's JS variables).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.
Chrome's extension messaging API is small. You need three things:
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).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.chrome.runtime.sendMessage(msg, callback) — send to the extension's service worker (not to content scripts). Used in Seg 8.// 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.
return trueHere'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.
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
}
});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.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:
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.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.sendMessage fails with "Could not establish connection." Test on a normal website (GitHub, a news article, a Wikipedia page) instead.# 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.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?return true (if content.js is async) or content.js isn't loading. Check the page's DevTools (F12 on the page, not the popup) for the content script errors.chrome.tabs is only available in popup/background contexts, not content scripts.document.body.innerText directly in popup.js and wonders why it's empty — popup has its own DOM, not the page'sreturn true — response never reaches the senderchrome.runtime.sendMessage (sends to service worker) when it should use chrome.tabs.sendMessage (sends to content script) — silent failureContent.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://.
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.
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.Open Claude. Paste this prompt verbatim (tweak the colours/branding to match your BUILD site if you want):
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.
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.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:
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'; } });
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.analyseBtn is null because the element id doesn't match between popup.html and popup.js.document.body.innerText can't reach. Most articles work fine; some JS-heavy SPAs don't. Test on Wikipedia — always works.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.
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.
This catches everyone once. Here's why popup-side fetches work and content-script-side fetches don't:
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.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.
chrome-extension://* to CORSYour 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:
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' } }); } };
'*' — 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.
Update your popup.js from Seg 5 — replace the "Read N characters" placeholder with the real Worker call. This is the complete file:
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'; } });
{ 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.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.
Access-Control-Allow-Origin, or isn't handling OPTIONS preflight. Check Seg 6 Step 2. npx wrangler deploy again.npx wrangler secret put ANTHROPIC_API_KEY from the Worker's folder.host_permissions in manifest.json. They must be identical.{"error": "..."} instead of analysis — the Worker is returning an error JSON. Open the network tab in popup DevTools → click the failed fetch → look at Response. It'll tell you what the Worker said.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 emptyFetch 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.
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.
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:
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).
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.
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.
"unlimitedStorage" to your manifest's permissions — but only then, not pre-emptively.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:
"options_page": "options.html",
Create options.html — direct Claude with:
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:
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):
const { systemPrompt } = await chrome.storage.sync.get('systemPrompt'); const finalSystemPrompt = systemPrompt?.trim() || SYSTEM_PROMPT; // ... then use finalSystemPrompt in the fetch body
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:
"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.
Ctrl or Alt. Shift alone won't work.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.
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?storage permission isn't in your manifest's permissions array. Add "storage", reload.options_page manifest field is wrong or the file doesn't exist.chrome://extensions/shortcuts. If the field is blank, the suggested key conflicted with something Chrome reserves. Assign a different one manually there.chrome:// pages have additional restrictions.await on chrome.storage.sync.set. Save is async; without awaiting, the page closes before the write completes.
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).
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.
chrome.storage. Treat in-memory variables as caches, not state. Get this habit right now and 90% of MV3 background-script bugs disappear.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.
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.// 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.
// 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 } });
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.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.
// 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" permission in manifest. Add it alongside "storage".chrome.storage.local.chrome.runtime.lastError. Either check it in your callback or use the Promise-based API (no callback → no warning)."alarms" in manifest permissions, OR you're setting periodInMinutes below 0.5.chrome.runtime.connect port, or a pending message response that never called sendResponse. Both keep the SW alive. Audit your message handlers.
let cache = {} at the top of background.js and treat it as persistent — then be surprised when it emptiessetInterval instead of chrome.alarms for periodic work — works in testing, fails overnight"alarms" or "storage" in manifest permissions and writes code that looks right but silently failsAnything 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.
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.
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.
"version". Web Store rejects identical-version reuploads.
"tabs" or "scripting" from a tutorial and never used it, remove it.
<all_urls> unless genuinely needed — request specific domains (your Worker's URL) instead of the universal wildcard. Broad permissions trigger extra review scrutiny.
eval() of fetched code, no dynamically-imported remote modules. MV3 bans all of this.
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.
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.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."
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.pngHow to do it right: open the extension folder, select all files and subfolders inside it, then compress the selection. Not the folder — the contents.
# 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.
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.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.
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.
# 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.activeTab but your description never mentions reading pages, that's a flag. Keep descriptions honest and specific and reviews go smoothly.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.
manifest_version: 3. Double-check your manifest.node_modules, remove it.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.
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.
# 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.
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.
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."Paste the privacy policy URL into the field at the bottom. Save. The tab now shows green / complete.
Click Submit for Review. You'll get an email confirming submission within a minute. From here you're waiting, not working.
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 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.
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.
"version" in manifest.json (1.0.0 → 1.0.1 for patches, 1.1.0 for features, 2.0.0 for breaking changes — semver is a convention, Chrome doesn't enforce it)chrome.permissions.request() for runtime-optional permissions instead of adding them to manifest.chrome.storage.local. It should work. What's the most likely cause?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.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.Key terms for this add-on
setTimeout pattern that broke when SW terminates.Access-Control-Allow-Origin headers accepting chrome-extension://* or the popup's fetch() will fail.<all_urls>) trigger longer review.