Chrome Extension Development with Claude Code: MV3, Messaging, and Safe Permissions
Build a Chrome MV3 extension with Claude Code: manifest, service worker, content script, permissions, and tests.
Start with the extension boundary, not the UI
A Chrome extension can look like a tiny side project, but it is closer to a small product surface. It has a manifest, background behavior, page-level code, browser permissions, storage, security review, packaging, and a Chrome Web Store story. If you ask Claude Code to “build a Chrome extension” without constraints, it will often generate a large scaffold with broad permissions, a popup, a bundler, and a content script that runs on every site.
This guide takes a narrower path. We will build a Manifest V3 extension that highlights selected text on https://example.com/* from a context menu. The extension uses manifest.json, an extension service worker, a content script, message passing, and chrome.storage.local. It intentionally avoids <all_urls>, tabs, remote scripts, eval, and unnecessary host permissions. That smaller scope makes the code easier to copy, explain, test, and review.
The terms matter. Manifest V3 is the current Chrome extension manifest format. A service worker is the event-driven background worker that wakes up for extension events and does not behave like an always-running background page. A content script runs in the context of web pages and can read or modify the DOM. Message passing is the JSON-based communication channel between extension contexts. When you see the word harness in agentic development, read it as the “scaffolding” that lets the agent work safely.
Use primary docs while you iterate. The manifest format is covered in the Chrome Extensions Manifest reference. Service worker behavior is explained in About extension service workers. Content script constraints are covered by Chrome Content scripts and MDN Content scripts. Permission design belongs next to Chrome Declare permissions and MDN permissions. For Claude Code prompting habits, pair this with Claude Code productivity tips.
Three practical use cases
The first use case is internal documentation review. A team can limit the extension to https://docs.example.com/*, select a product term, and highlight every occurrence while reviewing a release note. Because the match pattern is narrow, the permission story is easy to explain: the extension only touches the documentation domain.
The second use case is support workflow assistance. A support agent might select an order ID in an admin page and use a context menu to copy, tag, or highlight related information. This is also where privacy decisions become concrete. Claude Code should be asked to list what the extension reads, what it stores, what it sends, and what it deliberately ignores.
The third use case is editorial and SEO QA. A content team can use a content script to inspect headings, description length, internal links, and official external links on a draft page. That pairs well with command-line checks from Claude Code CLI tool development and security review habits from Claude Code security best practices.
Prompt Claude Code with constraints
Give Claude Code the feature, the forbidden shortcuts, and the verification method in one prompt. MV3 work improves when the agent knows that the service worker is not persistent and that broad permissions are not acceptable.
Build a Manifest V3 Chrome extension sample.
Requirements:
- Limit the target URL to https://example.com/*
- Add a context menu item that highlights selected text
- Use plain JavaScript files: manifest.json, service-worker.js, content-script.js
- Store enabled and color in chrome.storage.local
- Communicate with the content script through runtime messages
- Do not use <all_urls>, tabs, eval, external CDN scripts, or remote code
- Add a Playwright smoke script that confirms the extension loads
- Include a permission review table based on Chrome extension docs
The important part is not the wording; it is the boundary. If Claude Code adds React, Vite, a popup, an options page, icons, and wide host permissions, ask it to reduce the sample before you review behavior. A small extension is easier to reason about than an impressive scaffold that hides risk.
Manifest and file layout
Create this folder. It is intentionally small enough to load directly with “Load unpacked” in chrome://extensions.
mv3-highlighter/
manifest.json
service-worker.js
content-script.js
package.json
playwright-extension-smoke.mjs
The manifest declares the extension shape. This version registers one service worker, one content script, and only the two API permissions the sample needs.
{
"manifest_version": 3,
"name": "Claude Code MV3 Highlighter",
"version": "0.1.0",
"description": "Highlights selected text on example.com from a context menu.",
"action": {
"default_title": "MV3 Highlighter"
},
"permissions": ["contextMenus", "storage"],
"background": {
"service_worker": "service-worker.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["content-script.js"],
"run_at": "document_idle"
}
]
}
Permission review should be written before adding features:
| Area | Setting | Reason | Avoided |
|---|---|---|---|
| API permissions | contextMenus, storage | Context menu and settings only | tabs, scripting, downloads |
| Page access | https://example.com/* | One demo domain | <all_urls> |
| Host permissions | none | Static content script match is enough | Broad host_permissions |
| Remote code | none | Easier MV3 and store review | CDN scripts, eval |
Service worker implementation
The service worker receives extension events and sends a message to the content script. It stores settings in chrome.storage.local because MV3 service workers can stop between events. Do not keep important state only in memory.
const MENU_ID = "highlight-selection";
const DEFAULT_SETTINGS = {
enabled: true,
color: "#fff176"
};
async function readSettings() {
return chrome.storage.local.get(DEFAULT_SETTINGS);
}
chrome.runtime.onInstalled.addListener(async () => {
const settings = await readSettings();
await chrome.storage.local.set(settings);
chrome.contextMenus.create({
id: MENU_ID,
title: "Highlight selected text",
contexts: ["selection"],
documentUrlPatterns: ["https://example.com/*"]
});
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== MENU_ID || !tab?.id || !info.selectionText) {
return;
}
const settings = await readSettings();
await chrome.tabs.sendMessage(tab.id, {
type: "HIGHLIGHT_SELECTION",
text: info.selectionText.slice(0, 120),
enabled: settings.enabled,
color: settings.color
});
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "GET_SETTINGS") {
readSettings().then(sendResponse);
return true;
}
if (message?.type === "SAVE_SETTINGS") {
const nextSettings = {
enabled: Boolean(message.enabled),
color: String(message.color || DEFAULT_SETTINGS.color)
};
chrome.storage.local.set(nextSettings).then(() => {
sendResponse({ ok: true, settings: nextSettings });
});
return true;
}
return false;
});
The easy bug is forgetting return true when sendResponse is called asynchronously. Without it, the response channel can close before the promise resolves. Keep Chrome Message passing nearby when reviewing this file.
Content script implementation
The content script changes the page. It does not need broad extension APIs; it waits for a message from the service worker and then performs a DOM operation. It also writes a small data-* marker so the Playwright smoke test can prove the script loaded.
const HIGHLIGHT_CLASS = "cc-mv3-highlight";
document.documentElement.dataset.ccMv3Highlighter = "ready";
function shouldSkipNode(node) {
const parent = node.parentElement;
if (!parent) return true;
return Boolean(
parent.closest(
`script, style, textarea, input, [contenteditable="true"], .${HIGHLIGHT_CLASS}`
)
);
}
function clearHighlights() {
for (const mark of document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)) {
const text = document.createTextNode(mark.textContent || "");
mark.replaceWith(text);
text.parentElement?.normalize();
}
}
function createMark(text, color) {
const mark = document.createElement("mark");
mark.className = HIGHLIGHT_CLASS;
mark.textContent = text;
mark.style.backgroundColor = color;
mark.style.color = "#111";
mark.style.padding = "0 2px";
return mark;
}
function highlightText(term, color) {
const query = term.trim();
if (!query) return 0;
clearHighlights();
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (shouldSkipNode(node)) return NodeFilter.FILTER_REJECT;
return node.nodeValue.toLowerCase().includes(query.toLowerCase())
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
}
});
const matches = [];
while (walker.nextNode()) {
const node = walker.currentNode;
const index = node.nodeValue.toLowerCase().indexOf(query.toLowerCase());
if (index >= 0) matches.push({ node, index });
}
for (const { node, index } of matches.reverse()) {
const selected = node.splitText(index);
selected.splitText(query.length);
selected.replaceWith(createMark(selected.nodeValue, color));
}
return matches.length;
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type !== "HIGHLIGHT_SELECTION") {
return false;
}
if (!message.enabled) {
clearHighlights();
sendResponse({ ok: true, count: 0 });
return false;
}
const count = highlightText(String(message.text || ""), message.color || "#fff176");
sendResponse({ ok: true, count });
return false;
});
This is a deliberately modest highlighter. It highlights the first match inside each text node and skips form fields, scripts, styles, and editable areas. In a production extension, ask Claude Code to add tests for repeated matches, Shadow DOM, dynamically inserted content, existing mark elements, and pages with heavy client-side rendering.
Smoke test with Playwright
Extension E2E tests can become complicated, but a smoke test is still useful. This script launches a persistent Chromium context, loads the unpacked extension, opens the target page, and waits for the marker written by the content script.
{
"type": "module",
"scripts": {
"smoke": "node playwright-extension-smoke.mjs"
},
"devDependencies": {
"playwright": "^1.52.0"
}
}
import { chromium } from "playwright";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const extensionPath = path.resolve(__dirname);
const userDataDir = path.resolve(__dirname, ".pw-extension-profile");
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`
]
});
const page = await context.newPage();
await page.goto("https://example.com/");
await page.waitForFunction(() => {
return document.documentElement.dataset.ccMv3Highlighter === "ready";
});
console.log("Extension content script is loaded on https://example.com/");
await page.waitForTimeout(3000);
await context.close();
Run it with:
npm install
npm run smoke
Then do the manual check. Open chrome://extensions, enable Developer mode, choose “Load unpacked,” select the folder, open https://example.com/, select part of the heading, and use the “Highlight selected text” context menu item. If the highlight appears and the service worker console has no error, the first pass is good.
Pitfalls worth reviewing
The most common mistake is adding tabs too early. This sample receives tab.id from the context menu event and sends a message to that tab, so it does not need the tabs permission. Add it only when the extension must read privileged tab metadata.
The second mistake is treating the content script as a safe place for secrets. It runs near untrusted page content and should not contain API keys, Claude tokens, or customer data. If data must be processed, keep the minimum required values in the service worker and document why they are stored.
The third mistake is assuming the service worker keeps memory forever. It can stop between events. Store settings, queues, and timestamps in extension storage and read them when needed.
The fourth mistake is rewriting the DOM with innerHTML. That can destroy event handlers, accessibility information, and page state. Splitting text nodes is safer, but still not perfect. Claude Code should be asked to avoid forms and editable regions and to provide a clear undo path.
The fifth mistake is widening permissions right before packaging. Developers often use <all_urls> while debugging and forget to narrow it. Keep the match pattern narrow from the first working prototype.
Packaging, CTA, and hands-on result
Before submitting to the Chrome Web Store, prepare icons, screenshots, a privacy explanation, permission justification, and a short description that matches the real behavior. The Chrome team’s Prepare your Extension page is the right checklist.
Package only the extension files, not node_modules or the Playwright profile:
zip -r mv3-highlighter.zip manifest.json service-worker.js content-script.js
On Windows PowerShell:
Compress-Archive -Path manifest.json,service-worker.js,content-script.js -DestinationPath mv3-highlighter.zip -Force
For teams, the valuable service is not just generating the files. It is reviewing permissions, security boundaries, tests, and the store explanation. ClaudeCodeLab can help through Claude Code training and consulting, especially when you want a repeatable review process for browser extensions, internal tools, and content automation. For self-paced material, browse the product library.
Hands-on note: when Masa tried this pattern, the code itself was not the hard part. The first useful review finding was permission scope. A version with <all_urls> was easy to demo but hard to justify. The narrow https://example.com/* version made the extension manager warning smaller, the Claude Code review more specific, and the packaging checklist much calmer. For MV3 extensions, “it works” is not enough; “it works with explainable permissions” is the real milestone.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.