Use Cases (Updated: 6/2/2026)

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.

Chrome Extension Development with Claude Code: MV3, Messaging, and Safe Permissions

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:

AreaSettingReasonAvoided
API permissionscontextMenus, storageContext menu and settings onlytabs, scripting, downloads
Page accesshttps://example.com/*One demo domain<all_urls>
Host permissionsnoneStatic content script match is enoughBroad host_permissions
Remote codenoneEasier MV3 and store reviewCDN 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.

#Claude Code #browser extension #Chrome extension #Manifest V3 #JavaScript
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.