chrome-extensions
System prompt
Chrome Extensions
Build production-quality Chrome extensions using Manifest V3 and publish them to the Chrome Web Store.
Part 1 — Building Extensions
Mandatory Rules
These address the most common causes of broken extensions. Violating any produces a non-functional build.
1. Icons: only reference files you create — or omit icons entirely
❌ BROKEN — referencing files that don't exist or reusing one file for all sizes:
"icons": { "16": "icon.png", "48": "icon.png", "128": "icon.png" }
✅ CORRECT — each size is a separate file at the correct pixel dimensions:
"icons": { "16": "icons/icon-16.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" }
(where icon-16.png is 16×16px, icon-48.png is 48×48px, icon-128.png is 128×128px)
✅ ALSO CORRECT — omit icons from manifest if you cannot generate real PNG files:
(just remove the "icons" and "default_icon" fields — Chrome uses a default icon)If you include icon references, you MUST create the actual image files. Generate them with a script (see references/extensions/icons.md) or leave them out. Never reference non-existent files.
2. Side panel: you MUST provide a way to open it
Defining "side_panel": {"default_path": "..."} does NOT make it openable. Add a trigger:
// In service-worker.js — open side panel on extension icon click
// IMPORTANT: chrome.action.onClicked ONLY fires when there is NO default_popup
chrome.action.onClicked.addListener(async (tab) => {
await chrome.sidePanel.open({ windowId: tab.windowId });
});If the extension has both a popup AND side panel, add a button in the popup that calls chrome.sidePanel.open(). Alternatively, use chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) — but the property is openPanelOnActionClick, NOT openPanelOnActionIconClick; the "Icon" variant causes a synchronous TypeError that silently aborts the service worker. Do NOT also define default_popup when using setPanelBehavior. See references/extensions/side-panel.md.
3. Code execution: sandboxed iframes ONLY
Extension CSP blocks eval(), new Function(), inline <script> in all extension pages.
// ❌ BROKEN — direct iframe DOM access throws SecurityError
iframe.contentDocument.write(html);
// ❌ BROKEN — eval in extension page
eval(userCode); // CSP blocks this
// ✅ OPTION A: Sandbox in manifest + postMessage
// manifest.json: { "sandbox": { "pages": ["sandbox.html"] } }
iframe.contentWindow.postMessage({ html, css, js }, '*');
// sandbox.html receives and runs:
window.addEventListener('message', (e) => { eval(e.data.js); /* allowed in sandbox */ });
// ✅ OPTION B: Blob URL (creates separate origin, bypasses extension CSP)
iframe.src = URL.createObjectURL(new Blob([doc], { type: 'text/html' }));
// ✅ OPTION C: srcdoc
iframe.srcdoc = `<style>${css}</style>${html}<script>${js}<\/script>`;See references/extensions/csp-sandbox.md for full details.
4. tab.url requires the tabs permission
Without it, tab.url silently returns undefined — no error thrown.
// manifest.json — REQUIRED if you read tab.url or tab.title anywhere:
{ "permissions": ["tabs"] }See references/extensions/tab-management.md.
5. Always use async/await — never .then() chains
// ❌ BAD
chrome.tabs.query({active: true, currentWindow: true}).then(tabs => {
chrome.scripting.executeScript({target: {tabId: tabs[0].id}, files: ['content.js']}).then(() => {});
});
// ✅ GOOD
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] });For runtime.onMessage listeners that do async work:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
const data = await chrome.storage.local.get('key');
sendResponse({ data });
})();
return true; // keeps channel open
});6. Content scripts: don't block the main thread
When modifying many DOM elements, batch with requestAnimationFrame and yield between batches:
async function highlightAll(elements) {
const BATCH = 20;
for (let i = 0; i < elements.length; i += BATCH) {
await new Promise(r => requestAnimationFrame(() => {
elements.slice(i, i + BATCH).forEach(el => el.style.backgroundColor = 'yellow');
r();
}));
if (globalThis.scheduler?.yield) await scheduler.yield();
}
}See references/extensions/content-scripts.md.
7. Service workers are ephemeral — never store state in variables
// ❌ BROKEN — state lost when SW terminates (~30s of inactivity)
let count = 0;
chrome.tabs.onUpdated.addListener(() => { count++; });
// ✅ CORRECT — persist in chrome.storage, read on every event
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (changeInfo.status !== 'complete') return;
const { count = 0 } = await chrome.storage.local.get('count');
await chrome.storage.local.set({ count: count + 1 });
await chrome.action.setBadgeText({ text: String(count + 1) });
});Use chrome.alarms instead of setTimeout/setInterval. See references/extensions/service-worker.md.
8. chrome.identity: extension ID differs between dev and production
When using Google sign-in, the OAuth client_id is tied to a specific extension ID. The ID changes between unpacked development and the Chrome Web Store.
To stabilize the ID during development, add a "key" field to manifest.json:
- Pack the extension once (chrome://extensions → Pack)
- Extract the public key from the .crx
- Add
"key": "MIIBIjANBgkqh..."to manifest.json
Always document: "After publishing to the Chrome Web Store, update the OAuth client with the store-assigned extension ID." See references/extensions/auth-identity.md.
9. Context menus: show user feedback after action
When a context menu item performs an action (save, copy, etc.), confirm it to the user. Use a notification, badge flash, or injected toast — don't let actions happen silently. See references/extensions/context-menus.md for a complete toast implementation.
10. Prompt API: available in service workers, popup, and side panel
The LanguageModel API works in all extension contexts — service worker, popup, and side panel — with no additional manifest permissions required. Extensions also get LanguageModel.params(), which is unavailable on the web:
const params = await LanguageModel.params();
// { defaultTopK: 3, maxTopK: 128, defaultTemperature: 1, maxTemperature: 2 }For general Prompt API patterns (availability checks, session creation, streaming), use the modern-web-guidance skill. See references/extensions/prompt-api.md for the extension-specific wiring example.
11. chrome.action API requires action in manifest
Using chrome.action.setBadgeText, chrome.action.setIcon, or chrome.action.onClicked requires
an "action" key in manifest.json — even if it's empty. Without it, chrome.action is undefined.
// ❌ BROKEN — manifest has no "action" key
await chrome.action.setBadgeText({ text: '5' });
// TypeError: Cannot read properties of undefined (reading 'setBadgeText')
// ✅ FIX — add "action" to manifest.json (at minimum an empty object)
{ "action": {} }
// or with a popup:
{ "action": { "default_popup": "popup/popup.html" } }12. activeTab only works on direct user gestures — not from side panels
activeTab grants temporary access to the current tab ONLY when triggered by:
- Clicking the extension action icon
- A context menu item (including the
"tab"context) - A keyboard shortcut from the
commandsAPI - Accepting an omnibox suggestion
It does NOT grant access when clicking a button in a side panel, popup button that opens later,
or any programmatic trigger.
// ❌ BROKEN — activeTab does NOT work from a side panel button click
document.getElementById('summarize').addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => document.body.innerText });
});
// ✅ FIX — use "tabs" permission + specific host_permissions instead
// manifest.json: { "permissions": ["tabs", "scripting"], "host_permissions": ["<all_urls>"] }See references/extensions/side-panel.md.
13. DevTools panel URLs are relative to the extension root
When creating a DevTools panel, the panel HTML path is relative to the extension root, NOT
relative to the devtools page that calls chrome.devtools.panels.create().
// ❌ BROKEN — path relative to devtools/ directory
chrome.devtools.panels.create("My Panel", "", "panel/panel.html");
// ✅ CORRECT — full path from extension root
chrome.devtools.panels.create("My Panel", "", "devtools/panel/panel.html");See references/extensions/devtools.md.
14. Offscreen documents have NO access to most chrome.* APIs
Offscreen documents (chrome.offscreen) are severely restricted. Most chrome.* APIs
are unavailable, including chrome.downloads, chrome.tabs, chrome.action, and others.
// ❌ BROKEN — chrome.downloads is undefined in offscreen documents
chrome.downloads.download({ url, filename: 'recording.webm' }); // TypeError
// ❌ BROKEN — chrome.action is undefined in offscreen documents
chrome.action.setBadgeText({ text: 'REC' }); // TypeErrorThe only APIs available in offscreen documents are:
chrome.runtime.sendMessage/chrome.runtime.onMessagechrome.runtime.getURL- Standard Web APIs (DOM, fetch, MediaRecorder, Canvas, Web Audio, etc.)
Rule of thumb: Offscreen documents do the Web API work (recording, parsing, audio). The service worker does all chrome.* API work (downloads, badge updates, notifications). Use chrome.runtime.sendMessage to bridge between them. See references/extensions/message-passing.md.
15. Notifications and badge icons must reference real image files
chrome.notifications.create() requires a valid iconUrl pointing to an actual image file.
If the file doesn't exist or the path is wrong, the call fails with "Unable to download all specified images."
// ❌ BROKEN — icon file doesn't exist
chrome.notifications.create('reminder', {
type: 'basic',
iconUrl: 'icons/icon-128.png', // File not in extension!
title: 'Reminder',
message: 'Time is up!'
});
// ✅ Generate a data URL at runtime via OffscreenCanvas — no file needed.
// See `references/extensions/icons.md` for a reusable implementation.
const iconUrl = await getIconDataUrl();
chrome.notifications.create('reminder', { type: 'basic', iconUrl, title: 'Reminder', message: 'Time is up!' });This applies to ALL image references in chrome.* APIs — notifications, chrome.action.setIcon,
context menu icons, etc. If you reference a file, it must exist.
16. Tab capture: guard against double-start with state locking
chrome.tabCapture.getMediaStreamId() fails with "Cannot capture a tab with an active stream"
if called while a previous capture is still active. Fast double-clicks on the extension icon
easily trigger this. Use explicit state locking:
// ❌ BROKEN — no guard against rapid clicks
let isRecording = false;
chrome.action.onClicked.addListener(async (tab) => {
if (isRecording) { stopRecording(); isRecording = false; }
else { isRecording = true; startRecording(tab); } // Second click = "active stream" error
});
// ✅ CORRECT — use transitional states to lock out concurrent operations
// State machine: 'idle' → 'starting' → 'recording' → 'stopping' → 'idle'
// Store state in chrome.storage.session (survives SW restart, cleared on browser close)
chrome.action.onClicked.addListener(async (tab) => {
const { recordingState = 'idle' } = await chrome.storage.session.get('recordingState');
if (recordingState === 'starting' || recordingState === 'stopping') return;
if (recordingState === 'idle') {
await chrome.storage.session.set({ recordingState: 'starting' });
try {
await startRecording(tab);
await chrome.storage.session.set({ recordingState: 'recording' });
await chrome.action.setBadgeText({ text: 'REC' });
await chrome.action.setBadgeBackgroundColor({ color: '#FF0000' });
} catch (err) {
console.error('Failed to start recording:', err);
await chrome.storage.session.set({ recordingState: 'idle' });
}
} else if (recordingState === 'recording') {
await chrome.storage.session.set({ recordingState: 'stopping' });
try { await stopRecording(); }
finally {
await chrome.storage.session.set({ recordingState: 'idle' });
await chrome.action.setBadgeText({ text: '' });
}
}
});This pattern applies to any chrome API that manages exclusive resources:chrome.tabCapture, chrome.desktopCapture, chrome.offscreen.createDocument (only one
offscreen document allowed at a time). See references/extensions/media-capture.md.
17. chrome.desktopCapture requires a target tab with URL access
When calling chrome.desktopCapture.chooseDesktopMedia() from a service worker, you must pass
the active tab as the targetTab parameter. The tab object must have its url field populated,
which requires the "tabs" permission.
// ❌ BROKEN — called without targetTab from service worker
chrome.desktopCapture.chooseDesktopMedia(['screen', 'window'], (streamId) => { ... });
// Error: "A target tab is required when called from a service worker context."
// ❌ BROKEN — tab doesn't have url field (missing "tabs" permission)
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
chrome.desktopCapture.chooseDesktopMedia(['screen', 'window'], tab, (streamId) => { ... });
// Error: "targetTab doesn't have URL field set."
// ✅ CORRECT — "tabs" permission in manifest + pass tab object
// manifest.json: { "permissions": ["tabs", "desktopCapture"] }
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
chrome.desktopCapture.chooseDesktopMedia(['screen', 'window'], tab, (streamId) => {
if (!streamId) return; // User cancelled
});Note: Prefer chrome.tabCapture.getMediaStreamId() for tab-only recording. Use chrome.desktopCapture only when the user should choose which screen/window to capture. See references/extensions/media-capture.md.
18. User scripts: four non-obvious pitfalls
chrome.userScripts runs user-provided code at runtime. Use it for script managers and
user automation — not for extension-bundled scripts.
- API throws on property access if not enabled. Chrome 138+ requires the user to toggle "Allow User Scripts" on the extension's details page; Chrome < 138 requires Developer mode. Always call
isUserScriptsAvailable()before anychrome.userScripts.*call and show an error UI when it returns false. - Registered scripts are cleared on extension update. Persist configs in
chrome.storage; re-register them inruntime.onInstalledfor the"update"reason. - Messaging requires explicit opt-in. Call
configureWorld({ messaging: true })first; listen onruntime.onUserScriptMessage, notruntime.onMessage. ScriptSourceconstraint: eachjsentry must have exactly one ofcodeorfile.idconstraint: cannot start with_.
See references/extensions/user-scripts.md.
19. chrome.windows has NO .query() method — use getAll, getLastFocused, or getCurrent
Unlike chrome.tabs.query(), the chrome.windows API does NOT have a .query() method.
// ❌ BROKEN — chrome.windows.query does not exist
const windows = await chrome.windows.query({ focused: true });
// TypeError: chrome.windows.query is not a function
// ✅ CORRECT — use the right method for your need
const focused = await chrome.windows.getLastFocused({ populate: true });
const current = await chrome.windows.getCurrent({ populate: true });
const all = await chrome.windows.getAll({ populate: true });chrome.windows methods: getAll, getLastFocused, getCurrent, get(windowId), create, update, remove. See references/extensions/tab-management.md.
Always Manifest V3
Never generate Manifest V2 code.
background.service_workernotbackground.scriptschrome.actionnotchrome.browserActionchrome.scripting.executeScriptnotchrome.tabs.executeScripthost_permissionsis separate frompermissions- No inline scripts in HTML — use
<script src="file.js"> - No inline event handlers — use
addEventListener
Part 2 — Publishing to the Chrome Web Store
Manage CHROMEWEBSTORE.md — the single source of truth for all Chrome Web Store listing
metadata, permissions justifications, privacy disclosures, version history, and publishing
readiness for a Chrome extension project.
Core Workflow
Every time you touch a Chrome extension project in a way that affects its store presence,
update (or create) CHROMEWEBSTORE.md in the project root. The file tracks everything the
developer needs to fill out in the Chrome Developer Dashboard, so they can copy-paste from
a single doc instead of scrambling at publish time.
When to create CHROMEWEBSTORE.md
Create it the moment any of these happen:
- The user says they want to publish an extension
- The user asks to "prepare for the store" or "get ready to publish"
- You're building a new extension that will clearly end up on the store
- The user asks about store listing requirements
Use the template in references/webstore/chromewebstore-template.md as your starting point. Read it
before generating the file.
When to update CHROMEWEBSTORE.md
Update it whenever:
- User-facing changes: Bump the "Last Updated" date, update the feature list in
descriptions, and add an entry to Version History - manifest.json changes: If permissions, host_permissions, or content_scripts changed,
update the Permissions Justification section — every permission needs a plain-English
reason the review team can understand - New release: Add a Version History entry with version number, date, and summary
- Privacy-relevant changes: If data collection, storage, or transmission changed,
update the Privacy & Data Use section and the privacy policy - Asset changes: If icons or UI changed, note which screenshots need refreshing
- Rejection response: If the user reports a CWS rejection, update the file with the
fix and add a note to Version History
How to fill it out
For each section, pull information from the actual project files:
- Read
manifest.jsonto extract name, version, description, permissions, host_permissions - Scan the codebase for data collection (storage, fetch calls, analytics)
- Check for icon files and their dimensions
- Look at the extension's UI to understand features for the description
Write store-facing copy in a tone that is specific, honest, and benefit-oriented. The Chrome
Web Store review team rejects vague descriptions. "Makes your life easier" will be rejected.
"Highlights search results on any webpage and lets you save highlights to a local list" will
pass.
Never mention implementation details. Users care what the extension does for them, not
how it was built. Strip any mention of APIs, libraries, frameworks, or code patterns:
| ❌ Implementation detail (cut it) | ✅ User benefit (keep it) |
|---|---|
| "Uses a MutationObserver to detect page changes" | "Automatically detects new content as you browse" |
| "Built with custom elements and Shadow DOM" | "Works seamlessly without affecting page styles" |
| "Powered by a service worker for background processing" | "Runs quietly in the background without slowing your browser" |
| "Leverages the chrome.storage.sync API" | "Your settings sync across all your devices" |
| "Implements declarativeNetRequest for filtering" | "Blocks ads and trackers without reading your page content" |
CHROMEWEBSTORE.md Sections
Read references/webstore/chromewebstore-template.md before generating the file — it defines
what each section covers and how to fill it out. The highest-risk section is Permissions
Justification: write a specific plain-English reason per permission and per host_permission.
"Needed for the extension to work" will be rejected. Read references/webstore/privacy-policy.md
for guidance on generating a privacy policy.
Pre-Publish Checklist
Before submission, run through references/webstore/review-checklist.md. The most common
first-submission failures:
- Every permission and host_permission must have a specific justification (not "needed to work")
- Privacy policy URL must be live and match the data use disclosure form
- At least 1 screenshot at 1280×800 or 640×400
- ZIP must exclude
.git/,node_modules/,.env,CHROMEWEBSTORE.md
Store Listing Copy Guidelines
For copy guidelines and common rejection reasons, see references/webstore/store-listing.md.
Key rule: lead with function ("Highlights search terms on any webpage"), not feeling ("Enjoy
searching again").
Reference Files
For detailed API patterns and publishing guidance, read the relevant file BEFORE writing code or content:
| Topic | Reference |
|---|---|
| Side panels | references/extensions/side-panel.md |
| Content scripts & DOM | references/extensions/content-scripts.md |
| Popups | references/extensions/popup-ui.md |
| Service worker lifetime | references/extensions/service-worker.md |
| Code execution & CSP | references/extensions/csp-sandbox.md |
| API calls | references/extensions/api-calling.md |
| Declarative Net Request | references/extensions/declarative-net-request.md |
| Chrome Prompt API | references/extensions/prompt-api.md |
| DevTools panels | references/extensions/devtools.md |
| Authentication | references/extensions/auth-identity.md |
| Context menus | references/extensions/context-menus.md |
| Omnibox | references/extensions/omnibox.md |
| Storage | references/extensions/storage.md |
| Tab & window management | references/extensions/tab-management.md |
| Tab/desktop capture | references/extensions/media-capture.md |
| User scripts | references/extensions/user-scripts.md |
| Message passing | references/extensions/message-passing.md |
| Icons | references/extensions/icons.md |
| CHROMEWEBSTORE.md template | references/webstore/chromewebstore-template.md |
| Privacy policy guidance | references/webstore/privacy-policy.md |
| Pre-publish review checklist | references/webstore/review-checklist.md |
| Store listing tips & rejections | references/webstore/store-listing.md |
Output Checklist
Verify EVERY item before delivering:
-
manifest_version: 3— no V2 APIs anywhere - All icon files referenced in manifest exist as real files with correct dimensions — or icons are omitted
- Side panel has an explicit open trigger (not just a manifest declaration)
- Code execution uses sandbox/blob/srcdoc — no
eval()in extension pages -
tabspermission declared iftab.urlortab.titleis accessed - All code uses
async/await— no.then()chains - Content scripts batch DOM updates with
requestAnimationFrame - Service worker stores NO state in global variables — uses
chrome.storage - No inline scripts or event handlers in HTML
- Context menu actions show user confirmation
-
"action": {}(or more) present in manifest if usingchrome.action.*APIs - If reading/scripting tabs from a side panel: use
tabs+host_permissions(NOTactiveTab) - DevTools panel paths in
chrome.devtools.panels.create()are relative to extension root - Offscreen documents use ONLY
chrome.runtimemessaging — nochrome.downloads,chrome.action, etc. - All image refs in
chrome.notifications,chrome.action.setIcon, etc. point to real files (or use data URLs) - Tab/desktop capture uses state locking to prevent double-start errors
-
chrome.desktopCapture.chooseDesktopMediapassestargetTabwithtabspermission -
chrome.windowscalls usegetAll/getLastFocused/getCurrent— NOT.query()(it doesn't exist) -
chrome.userScriptsavailability checked before use (API throws if user hasn't enabled it) - User script configs persisted in
chrome.storageand restored onruntime.onInstalled"update"reason -
configureWorld({ messaging: true })called before user scripts send messages; listening ononUserScriptMessagenotonMessage -
ScriptSourceentries each have exactly one ofcodeorfile(not both, not neither) - User script
idvalues do not start with underscore -
sidePanel.setPanelBehaviorusesopenPanelOnActionClick— NOTopenPanelOnActionIconClick - Error handling on all async operations
-
host_permissionsscoped to specific domains (not<all_urls>unless needed) -
return trueinonMessagelisteners with async responses - Any use of
"tab"inchrome.contextMenuscontextsrequires Chrome M150+
Attachments
# Calling External APIs from Extensions
## Permissions
Ordinarily, fetch requests made by extensions follow normal CORS rules.
To determine if this is sufficient, use `curl` to call the API with a test origin. For example:
```
curl -H "Origin: https://example.com" -I https://api.openweathermap.org/data/2.5/weather?q=London&appid=KEY`
```
If the response includes either `*` or `https://example.com` as the value for the `Access-Control-Allow-Origin` header, the API supports CORS.
If the API does not support CORS, request host permissions to bypass these restrictions:
```json
{
"host_permissions": [
"https://no-cors-api.example.com/*"
]
}
```
**Do NOT use `<all_urls>` just for API calls.** Scope to the specific API domains.
## Where to Make API Calls
API calls work from any extension context (service worker, popup, side panel, content scripts):
```js
// From popup or service worker
const response = await fetch('https://api.openweathermap.org/data/2.5/weather?q=London&appid=KEY');
const data = await response.json();
```
**Content scripts** can also make fetch calls, but they follow the web page's CORS rules.
## Error Handling Pattern
```js
async function callAPI(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (err) {
if (err instanceof TypeError) {
// Network error (offline, DNS failure, etc.)
console.error('Network error:', err.message);
} else {
console.error('API error:', err.message);
}
return null;
}
}
```
## API Keys
- Never hardcode API keys in published extensions
- Use `chrome.storage.local` for user-provided keys
- For your own backend, use `chrome.identity` to authenticate instead of embedding keys
- Mark placeholder keys clearly: `const API_KEY = 'YOUR_API_KEY_HERE';`
## Service Worker Considerations
If making API calls from the service worker, remember it can terminate. For long-polling or
webhook-style patterns, use `chrome.offscreen` to create an offscreen document that stays alive,
or use `chrome.alarms` for periodic polling.
# Authentication with chrome.identity
## Setup
```json
{
"permissions": ["identity"],
"oauth2": {
"client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"scopes": [
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email"
]
}
}
```
## Getting an OAuth Token
```js
async function signIn() {
return new Promise((resolve, reject) => {
chrome.identity.getAuthToken({ interactive: true }, (token) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(token);
}
});
});
}
```
Or with the promise-based API (Chrome 116+):
```js
const { token } = await chrome.identity.getAuthToken({ interactive: true });
```
## Fetching User Profile
```js
async function getUserProfile(token) {
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to fetch profile');
return response.json();
// Returns: { sub, name, given_name, family_name, picture, email, email_verified }
}
```
## Sign Out
```js
async function signOut(token) {
// Remove cached token
await chrome.identity.removeCachedAuthToken({ token });
// Optionally revoke the token server-side
await fetch(`https://accounts.google.com/o/oauth2/revoke?token=${token}`);
}
```
## Error Handling
```js
try {
const { token } = await chrome.identity.getAuthToken({ interactive: true });
const profile = await getUserProfile(token);
displayProfile(profile);
} catch (err) {
if (err.message.includes('canceled')) {
showMessage('Sign-in was cancelled');
} else if (err.message.includes('not granted')) {
showMessage('Permission was denied');
} else {
showMessage('Sign-in failed: ' + err.message);
}
}
```
## Setting Up Google Cloud Console
1. Go to console.cloud.google.com
2. Create a project (or select existing)
3. Enable "Google People API" or "Google OAuth2 API"
4. Create OAuth 2.0 credentials → Chrome Extension type
5. Set the Application ID to your extension's ID
6. Copy the client_id to your manifest.json
### Extension ID: Development vs Production
**This is critical and often missed.** The OAuth `client_id` is tied to a specific extension ID.
The extension ID changes depending on how you load the extension:
| Context | How ID is determined |
|---------|---------------------|
| Unpacked (development) | Derived from the extension's directory path — changes if you move the folder |
| Packed (.crx) | Derived from the private key used to pack |
| Chrome Web Store | Assigned by the store, permanent |
**To get a stable ID during development**, add a `"key"` field to your manifest.json.
This ensures the same extension ID regardless of directory path:
1. Pack your extension once (`chrome://extensions` → Pack Extension)
2. Open the generated `.crx` as a ZIP, extract the `key` from its manifest
3. Add that key to your development manifest:
```json
{
"key": "MIIBIjANBgkqhk...your-public-key-here...",
"manifest_version": 3,
"name": "My Extension"
}
```
Alternatively, note your unpacked extension's ID from `chrome://extensions` and configure
the OAuth client for that specific ID. Just be aware it will change if the folder moves.
**Always tell users:** "After publishing to the Chrome Web Store, update your OAuth client
configuration with the store-assigned extension ID."
## Non-Google OAuth (launchWebAuthFlow)
For third-party OAuth providers (GitHub, Twitter, etc.):
```js
const redirectUrl = chrome.identity.getRedirectURL();
// Returns: https://<extension-id>.chromiumapp.org/
const authUrl = `https://github.com/login/oauth/authorize?client_id=XXX&redirect_uri=${redirectUrl}`;
const responseUrl = await chrome.identity.launchWebAuthFlow({
url: authUrl,
interactive: true
});
// Parse the token from responseUrl
const url = new URL(responseUrl);
const code = url.searchParams.get('code');
```
# Content Scripts & DOM Manipulation
## Two Ways to Inject
### 1. Static (manifest declaration)
```json
{
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content/content.js"],
"css": ["content/content.css"],
"run_at": "document_idle"
}]
}
```
### 2. Programmatic (from service worker or popup)
```js
// Requires "scripting" permission and host access
chrome.scripting.executeScript({
target: { tabId: tabId },
files: ['content/content.js']
});
// Or inject a function directly
chrome.scripting.executeScript({
target: { tabId: tabId },
func: (param) => {
document.body.style.backgroundColor = param;
},
args: ['yellow']
});
```
Use `activeTab` permission for on-click injection (no host_permissions needed):
```json
{
"permissions": ["activeTab", "scripting"]
}
```
## Isolated World
Content scripts run in an isolated world:
- They share the DOM with the page but NOT JavaScript variables
- They can access chrome.runtime messaging APIs
- The page's CSP does NOT restrict content script code
- `window` refers to the content script's isolated world
## Message Passing from Content Scripts
```js
// content.js → service worker
chrome.runtime.sendMessage({ type: 'DATA', payload: data }, (response) => {
console.log('Got response:', response);
});
// service worker → content script in a specific tab
chrome.tabs.sendMessage(tabId, { type: 'UPDATE', data: newData });
// content.js: listen for messages
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_CONTENT') {
const text = document.body.innerText;
sendResponse({ text });
}
return true; // Keep channel open for async sendResponse
});
```
## DOM Manipulation Best Practices
- **Avoid blocking the main thread** when modifying many DOM elements. Use `requestAnimationFrame`
to batch visual updates and `scheduler.yield()` to break up long-running tasks:
```js
// ❌ BAD: Blocks the main thread while processing hundreds of elements
const emails = document.body.innerText.match(/[\w.+-]+@[\w-]+\.[\w.]+/g);
emails.forEach(email => {
// ... find and highlight each email (can freeze the page)
});
// ✅ GOOD: Process in batches using requestAnimationFrame
async function highlightEmails(elements) {
const BATCH_SIZE = 20;
for (let i = 0; i < elements.length; i += BATCH_SIZE) {
const batch = elements.slice(i, i + BATCH_SIZE);
await new Promise(resolve => requestAnimationFrame(() => {
batch.forEach(el => el.style.backgroundColor = 'yellow');
resolve();
}));
// Yield to the main thread between batches
if (typeof scheduler !== 'undefined' && scheduler.yield) {
await scheduler.yield();
}
}
}
```
- Use `MutationObserver` for dynamic pages (SPAs, infinite scroll)
- Namespace your CSS classes to avoid conflicts (e.g., `myext-highlight`)
- Use Shadow DOM for complex UI injected into pages
- Clean up on removal: `chrome.runtime.onMessage` listeners persist until the content script context is destroyed
- Use `TreeWalker` or `document.createNodeIterator` instead of regex on `innerHTML` for finding text in the DOM — this is more reliable and doesn't break event listeners
## `run_at` Timing
| Value | When |
|-------|------|
| `document_start` | Before DOM is constructed (useful for blocking) |
| `document_idle` | After DOM is ready but before all resources load (default, recommended) |
| `document_end` | After DOM is complete but before images/subframes |
# Context Menus
## Setup
```json
{ "permissions": ["contextMenus"] }
```
## Creating Menus
Create in the service worker, typically in `onInstalled` (menus persist, but re-creating is idempotent):
```js
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'save-link',
title: 'Save to Reading List',
contexts: ['link'] // Only show on right-click of links
});
chrome.contextMenus.create({
id: 'translate-selection',
title: 'Translate "%s"', // %s = selected text
contexts: ['selection']
});
// M150+: Context menu for the tab strip (right-clicking a tab)
chrome.contextMenus.create({
id: 'duplicate-tab',
title: 'Custom Duplicate Tab',
contexts: ['tab']
});
});
```
## Handling Clicks
```js
chrome.contextMenus.onClicked.addListener((info, tab) => {
switch (info.menuItemId) {
case 'save-link':
saveLink(info.linkUrl, info.selectionText || info.linkUrl);
break;
case 'translate-selection':
translateText(info.selectionText, tab.id);
break;
case 'duplicate-tab':
chrome.tabs.duplicate(tab.id); // 'tab' parameter is the clicked tab
break;
}
});
```
## Context Types
`all`, `page`, `frame`, `selection`, `link`, `editable`, `image`, `video`, `audio`, `launcher`, `browser_action`, `action`, `tab` (M150+)
## Submenus
```js
chrome.contextMenus.create({ id: 'parent', title: 'My Extension', contexts: ['page'] });
chrome.contextMenus.create({ id: 'child1', parentId: 'parent', title: 'Option 1', contexts: ['page'] });
chrome.contextMenus.create({ id: 'child2', parentId: 'parent', title: 'Option 2', contexts: ['page'] });
```
## Dynamic Updates
```js
chrome.contextMenus.update('save-link', { title: 'New Title' });
chrome.contextMenus.remove('save-link');
chrome.contextMenus.removeAll();
```
# CSP & Sandboxed Code Execution
## Extension CSP Restrictions
Chrome Extensions enforce a strict Content Security Policy that cannot be relaxed for extension
pages (popup, side panel, options, new tab, etc.).
Blocked by default:
- `eval()`, `new Function()`, `setTimeout("string")`
- Inline `<script>` tags
- Inline event handlers (`onclick="..."`, `onload="..."`, etc.)
- `javascript:` URLs
## HTML Best Practices
```html
<!-- ❌ BAD: Inline script -->
<script>
document.getElementById('btn').onclick = () => alert('hi');
</script>
<!-- ❌ BAD: Inline event handler -->
<button onclick="doThing()">Click</button>
<!-- ✅ GOOD: External script file -->
<script src="popup.js"></script>
```
In `popup.js`:
```js
document.getElementById('btn').addEventListener('click', () => {
// Handle click
});
```
## Executing User Code (Code Playground Pattern)
If you need to execute arbitrary code (e.g., a CodePen-like playground), you MUST use one of these
approaches. **Extension CSP completely blocks `eval()`, `new Function()`, and inline scripts in
normal extension pages.** There is no way around this — you need sandboxing.
### Option 1: Sandboxed Page in Manifest (Recommended)
Declare a sandboxed page in manifest.json. Sandboxed pages have a relaxed CSP that allows
`eval()` and inline scripts, but they cannot access chrome.* APIs.
```json
{
"sandbox": {
"pages": ["sandbox.html"]
}
}
```
Use an iframe in your extension page to embed the sandbox:
```html
<!-- playground.html (extension page) -->
<iframe id="preview" src="sandbox.html"></iframe>
```
**CRITICAL:** Communication between the extension page and the sandboxed iframe MUST use
`postMessage`. You CANNOT access `iframe.contentDocument` or `iframe.contentWindow.document`
directly — this will throw:
```
SecurityError: Blocked a frame with origin "chrome-extension://..." from accessing a cross-origin frame.
```
Correct pattern:
```js
// playground.js — send code to sandbox
const iframe = document.getElementById('preview');
iframe.contentWindow.postMessage({
html: htmlCode,
css: cssCode,
js: jsCode
}, '*');
// sandbox.js — receive and execute
window.addEventListener('message', (event) => {
const { html, css, js } = event.data;
// Clear previous content
document.body.innerHTML = '';
document.head.querySelectorAll('style.user-style').forEach(s => s.remove());
// Apply HTML
const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
// Apply CSS
const style = document.createElement('style');
style.className = 'user-style';
style.textContent = css;
document.head.appendChild(style);
// Execute JS (eval is allowed in sandbox!)
try {
eval(js);
} catch (e) {
const errEl = document.createElement('pre');
errEl.style.color = 'red';
errEl.textContent = e.message;
document.body.appendChild(errEl);
}
});
```
### Option 2: Blob URL in iframe
Create a self-contained HTML document via blob URL:
```js
function updatePreview(htmlCode, cssCode, jsCode) {
const html = `
<!DOCTYPE html>
<html>
<head><style>${cssCode}</style></head>
<body>
${htmlCode}
<script>${jsCode}<\/script>
</body>
</html>
`;
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const iframe = document.getElementById('preview');
// Revoke previous URL
if (iframe.dataset.blobUrl) URL.revokeObjectURL(iframe.dataset.blobUrl);
iframe.dataset.blobUrl = url;
iframe.src = url;
}
```
### Option 3: srcdoc Attribute
```js
const iframe = document.getElementById('preview');
iframe.srcdoc = `
<!DOCTYPE html>
<style>${cssCode}</style>
${htmlCode}
<script>${jsCode}<\/script>
`;
```
Both blob URLs and srcdoc create a separate origin, so they bypass the extension's CSP.
However, they also cannot access chrome.* APIs, and you cannot access their DOM directly
from the extension page (same cross-origin restriction as sandbox).
### What NOT to Do
```js
// ❌ WILL FAIL: Trying to set iframe content directly
iframe.contentDocument.open();
iframe.contentDocument.write(html);
iframe.contentDocument.close();
// ❌ WILL FAIL: Accessing cross-origin sandbox DOM
const doc = iframe.contentWindow.document;
doc.body.innerHTML = html;
// ❌ WILL FAIL: eval in a normal extension page
eval(userCode); // CSP blocks this
```
## CSP for Remote Resources
Extension pages cannot load remote scripts by default. If you need external libraries:
1. **Bundle them** — download and include in your extension
2. **Use chrome.scripting to inject into web pages** — web pages have their own CSP
For content scripts injected into web pages, the web page's CSP does NOT apply to the
content script's own code. Content scripts run in an isolated world.
# Declarative Net Request (Content Filtering)
## Setup
```json
{
"permissions": ["declarativeNetRequest"],
"declarative_net_request": {
"rule_resources": [{
"id": "ruleset_1",
"enabled": true,
"path": "rules/rules.json"
}]
}
}
```
Add `"declarativeNetRequestFeedback"` permission to use `onRuleMatchedDebug` (dev only).
## Rule Format
`rules/rules.json`:
```json
[
{
"id": 1,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "doubleclick.net",
"resourceTypes": ["script", "image", "xmlhttprequest", "sub_frame"]
}
},
{
"id": 2,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "google-analytics.com",
"resourceTypes": ["script", "xmlhttprequest"]
}
}
]
```
### Rule Fields
- `id`: Unique integer per rule
- `priority`: Higher priority rules win conflicts
- `action.type`: `"block"`, `"redirect"`, `"allow"`, `"modifyHeaders"`, `"allowAllRequests"`, `"upgradeScheme"`
- `condition.urlFilter`: Pattern matching (supports `*`, `||`, `|`, `^`)
- `condition.resourceTypes`: Array of resource types to match
### URL Filter Patterns
| Pattern | Matches |
|---------|---------|
| `"doubleclick.net"` | Any URL containing "doubleclick.net" |
| `"||doubleclick.net"` | Domain starts with doubleclick.net |
| `"||example.com/ads/*"` | Specific path pattern |
| `*://*.tracking.com/*` | Subdomain matching |
### Resource Types
`main_frame`, `sub_frame`, `stylesheet`, `script`, `image`, `font`, `object`, `xmlhttprequest`,
`ping`, `csp_report`, `media`, `websocket`, `webtransport`, `webbundle`, `other`
## Dynamic Rules (runtime)
```js
// Add rules at runtime
await chrome.declarativeNetRequest.updateDynamicRules({
addRules: [{
id: 1000,
priority: 1,
action: { type: 'block' },
condition: { urlFilter: 'ads.example.com' }
}],
removeRuleIds: [] // IDs to remove
});
```
## Tracking Blocked Requests
`onRuleMatchedDebug` only works in dev (unpacked) and requires `declarativeNetRequestFeedback`:
```js
chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => {
// info.request, info.rule
});
```
For production, count via `webRequest` (observe only) or maintain counts with `webNavigation`:
```js
// Alternative: Use webRequest to observe (requires host_permissions)
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
// Count requests to known tracking domains
if (isTrackerDomain(new URL(details.url).hostname)) {
incrementBlockCount(details.tabId);
}
},
{ urls: ["<all_urls>"] }
);
```
## Limits
- Static rules: 30,000 guaranteed per extension, plus an additional 300,000 from a pool shared between extensions
- Dynamic rules: 30,000
- Session rules: 5,000
# DevTools Panels
## Setup
```json
{
"devtools_page": "devtools/devtools.html"
}
```
The devtools page runs ONLY when DevTools is open. It's invisible — its job is to create panels.
## Creating a Panel
`devtools/devtools.html`:
```html
<!DOCTYPE html>
<html>
<body>
<script src="devtools.js"></script>
</body>
</html>
```
`devtools/devtools.js`:
```js
chrome.devtools.panels.create(
'My Panel', // Title shown in DevTools tab
'icons/icon-16.png', // Icon (optional, can be empty string)
'devtools/panel/panel.html', // Panel content page — RELATIVE TO EXTENSION ROOT
(panel) => {
// panel.onShown.addListener((window) => { ... });
// panel.onHidden.addListener(() => { ... });
}
);
```
**CRITICAL: The panel path is relative to the extension root**, NOT relative to the devtools.js
file. This is the most common DevTools extension bug.
```js
// ❌ WRONG — resolves to <ext-root>/panel/panel.html (file not found)
chrome.devtools.panels.create("My Panel", "", "panel/panel.html");
// ✅ CORRECT — resolves to <ext-root>/devtools/panel/panel.html
chrome.devtools.panels.create("My Panel", "", "devtools/panel/panel.html");
```
## Panel Content
`devtools/panel/panel.html` is a regular extension page with full chrome.* API access.
## Accessing DevTools APIs
Only available in the devtools page and panels:
```js
// Get inspected window's tab ID
const tabId = chrome.devtools.inspectedWindow.tabId;
// Evaluate JS in the inspected page
chrome.devtools.inspectedWindow.eval('document.title', (result, isException) => {
console.log('Page title:', result);
});
// Monitor network requests
chrome.devtools.network.onRequestFinished.addListener((request) => {
// request.request.url, request.response.status, etc.
// HAR entry format
});
// Get all captured requests
chrome.devtools.network.getHAR((harLog) => {
harLog.entries.forEach((entry) => { /* process */ });
});
```
## Communication Architecture
DevTools pages/panels CANNOT directly talk to the service worker via `chrome.runtime.sendMessage`
in all cases. Use a connection pattern:
```js
// In panel JS — connect to service worker
const port = chrome.runtime.connect({ name: 'devtools-panel' });
port.postMessage({ type: 'INIT', tabId: chrome.devtools.inspectedWindow.tabId });
port.onMessage.addListener((msg) => { /* handle */ });
// In service worker
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'devtools-panel') {
port.onMessage.addListener((msg) => { /* handle */ });
}
});
```
## Important Notes
- DevTools pages exist per-DevTools-window (one per inspected tab)
- They are destroyed when DevTools closes
- `chrome.devtools.*` APIs are ONLY available in the devtools page context, not in the service worker
- Panels can inject scripts into the inspected page via `chrome.devtools.inspectedWindow.eval()`
# Generating Extension Icons
## Quick: Omit icons
If generating real icon files is impractical, **omit `icons` and `default_icon` from manifest.json entirely**. Chrome uses a default puzzle-piece icon. This is always better than referencing files that don't exist.
## Generate with Python (Pillow)
```python
# generate_icons.py
from PIL import Image, ImageDraw
import os
os.makedirs('icons', exist_ok=True)
for size in [16, 48, 128]:
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = max(1, size // 16)
draw.rounded_rectangle(
[margin, margin, size - margin, size - margin],
radius=size // 4,
fill='#4688F1'
)
# Add a letter
font_size = size // 2
draw.text((size // 2, size // 2), 'E', fill='white', anchor='mm')
img.save(f'icons/icon-{size}.png')
print(f'Created icons/icon-{size}.png ({size}x{size})')
```
## Generate with Node.js (canvas)
```js
const { createCanvas } = require('canvas');
const fs = require('fs');
const path = require('path');
fs.mkdirSync('icons', { recursive: true });
for (const size of [16, 48, 128]) {
const canvas = createCanvas(size, size);
const ctx = canvas.getContext('2d');
const r = size / 4;
// Rounded rectangle
ctx.beginPath();
ctx.roundRect(1, 1, size - 2, size - 2, r);
ctx.fillStyle = '#4688F1';
ctx.fill();
// Letter
ctx.fillStyle = 'white';
ctx.font = `bold ${size / 2}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('E', size / 2, size / 2);
fs.writeFileSync(`icons/icon-${size}.png`, canvas.toBuffer('image/png'));
console.log(`Created icons/icon-${size}.png (${size}x${size})`);
}
```
## Generate with pure SVG (no dependencies)
Create SVGs and use them directly (Chrome supports SVG icons in some contexts) or convert:
```bash
for SIZE in 16 48 128; do
cat > "icons/icon-${SIZE}.svg" << EOF
<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<rect width="${SIZE}" height="${SIZE}" rx="$((SIZE/4))" fill="#4688F1"/>
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="middle"
fill="white" font-family="sans-serif" font-weight="bold" font-size="$((SIZE/2))">E</text>
</svg>
EOF
done
```
Note: For Chrome Web Store submission, PNG is required. SVG works for development.
## Manifest reference
```json
{
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"action": {
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
}
```
Each file MUST match its declared size: icon-16.png = 16×16 pixels, etc.
# Media Capture (Tab & Desktop)
## Choosing the Right API
| Need | API |
|------|-----|
| Record the active tab's audio/video | `chrome.tabCapture.getMediaStreamId()` |
| Let the user choose a screen, window, or tab | `chrome.desktopCapture.chooseDesktopMedia()` |
Prefer `tabCapture` when you only need the current tab — it requires no user chooser dialog and
no `"tabs"` permission. Use `desktopCapture` only when the user must select what to capture.
## Tab Capture
### Permissions
```json
{ "permissions": ["tabCapture"] }
```
### Pattern
`chrome.tabCapture.getMediaStreamId()` runs in the **service worker** and returns a stream ID.
The actual `getUserMedia()` call must happen in an **offscreen document** (the SW cannot access
media streams directly).
```js
// service-worker.js
chrome.action.onClicked.addListener(async (tab) => {
const streamId = await chrome.tabCapture.getMediaStreamId({ targetTabId: tab.id });
// Pass the ID to the offscreen document to call getUserMedia
await chrome.runtime.sendMessage({ type: 'START_CAPTURE', streamId });
});
// offscreen.js
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type !== 'START_CAPTURE') return;
(async () => {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { mandatory: { chromeMediaSource: 'tab', chromeMediaSourceId: msg.streamId } },
video: { mandatory: { chromeMediaSource: 'tab', chromeMediaSourceId: msg.streamId } }
});
const recorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
// ... handle recorder events
})();
});
```
## Desktop Capture
### Permissions
```json
{ "permissions": ["tabs", "desktopCapture"] }
```
`"tabs"` is **required** — `chooseDesktopMedia` needs a `targetTab` with its `url` field
populated, which requires the `"tabs"` permission.
### Pattern
```js
// service-worker.js
chrome.action.onClicked.addListener(async (tab) => {
// ❌ BROKEN — no targetTab
// chrome.desktopCapture.chooseDesktopMedia(['screen', 'window'], cb);
// ✅ CORRECT — pass the active tab
chrome.desktopCapture.chooseDesktopMedia(['screen', 'window', 'tab'], tab, (streamId) => {
if (!streamId) return; // User cancelled
// Send streamId to offscreen document for getUserMedia
chrome.runtime.sendMessage({ type: 'START_DESKTOP_CAPTURE', streamId });
});
});
```
## State Locking — Prevent Double-Start Errors
Both APIs fail if called while a previous capture is still active:
- `tabCapture`: `"Cannot capture a tab with an active stream"`
- `desktopCapture`: opens a second chooser dialog on top of the first
Use a state machine stored in `chrome.storage.session` (survives service worker restarts,
cleared on browser close):
```js
// State: 'idle' → 'starting' → 'recording' → 'stopping' → 'idle'
chrome.action.onClicked.addListener(async (tab) => {
const { recordingState = 'idle' } = await chrome.storage.session.get('recordingState');
// Ignore clicks during transitions
if (recordingState === 'starting' || recordingState === 'stopping') return;
if (recordingState === 'idle') {
await chrome.storage.session.set({ recordingState: 'starting' });
try {
await startRecording(tab);
await chrome.storage.session.set({ recordingState: 'recording' });
await chrome.action.setBadgeText({ text: 'REC' });
await chrome.action.setBadgeBackgroundColor({ color: '#FF0000' });
} catch (err) {
console.error('Failed to start recording:', err);
await chrome.storage.session.set({ recordingState: 'idle' });
}
} else if (recordingState === 'recording') {
await chrome.storage.session.set({ recordingState: 'stopping' });
try { await stopRecording(); }
finally {
await chrome.storage.session.set({ recordingState: 'idle' });
await chrome.action.setBadgeText({ text: '' });
}
}
});
```
This same pattern applies to `chrome.offscreen.createDocument` (only one offscreen document
is allowed at a time) and any other API that manages an exclusive resource.
## Saving Recordings
Offscreen documents cannot call `chrome.downloads` — send the blob back to the service worker:
```js
// offscreen.js — when recording stops
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
// Service worker handles the download
await chrome.runtime.sendMessage({ type: 'SAVE_RECORDING', url });
};
// service-worker.js
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type !== 'SAVE_RECORDING') return;
chrome.downloads.download({ url: msg.url, filename: 'recording.webm' });
});
```
See `references/extensions/message-passing.md` for the full offscreen document messaging pattern.
# Message Passing
## Basic patterns
### One-way message (fire and forget)
```js
// sender (popup, content script, etc.)
chrome.runtime.sendMessage({ type: 'LOG', data: 'hello' });
// receiver (service worker)
chrome.runtime.onMessage.addListener((message, sender) => {
if (message.type === 'LOG') console.log(message.data);
});
```
### Request/response — IIFE + return true (most compatible)
```js
// sender
const response = await chrome.runtime.sendMessage({ type: 'GET_DATA' });
console.log(response.data);
// receiver — IIFE keeps the channel open until sendResponse is called
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_DATA') {
(async () => {
const data = await chrome.storage.local.get('key');
sendResponse({ data });
})();
return true; // REQUIRED — tells Chrome to keep the channel open
}
});
```
### Request/response — return a Promise (Chrome 99+)
Returning a Promise directly from the listener is now supported and cleaner than the IIFE pattern:
```js
chrome.runtime.onMessage.addListener((message, sender) => {
if (message.type === 'GET_DATA') {
return chrome.storage.local.get('key'); // returned promise resolves the response
}
// Return nothing (or undefined) for messages this listener doesn't handle
});
```
**Note:** Requires Chrome 99+, only use when minimum Chrome version is set to 99.
**Note:** Do NOT mix the two styles. If you return a Promise, do NOT also call `sendResponse` or `return true`.
## Content script ↔ service worker
```js
// content script → service worker
const result = await chrome.runtime.sendMessage({ type: 'FETCH_DATA', url: location.href });
// service worker → specific tab's content script
await chrome.tabs.sendMessage(tabId, { type: 'HIGHLIGHT', selector: '.important' });
```
## Service worker → content script (targeted)
Always check that the tab exists and the content script is injected:
```js
async function sendToContentScript(tabId, message) {
try {
return await chrome.tabs.sendMessage(tabId, message);
} catch (err) {
// Content script not injected yet, or tab navigated away
console.warn('Could not reach content script:', err.message);
return null;
}
}
```
## Long-lived connections (ports)
Use ports when you need a persistent channel (e.g., streaming data, DevTools panel):
```js
// opener (popup or content script)
const port = chrome.runtime.connect({ name: 'my-channel' });
port.postMessage({ type: 'START' });
port.onMessage.addListener((msg) => console.log('received:', msg));
port.onDisconnect.addListener(() => console.log('disconnected'));
// receiver (service worker)
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'my-channel') return;
port.onMessage.addListener((msg) => {
if (msg.type === 'START') {
port.postMessage({ status: 'ok' });
}
});
});
```
## Common mistakes
### Missing `return true` causes response to never arrive
```js
// ❌ BROKEN — async work completes but channel is already closed
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
fetchSomething().then(data => sendResponse(data)); // too late
// missing: return true
});
// ✅ CORRECT
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
fetchSomething().then(data => sendResponse(data));
return true;
});
```
### Sending to a tab before the content script is ready
Content scripts are injected after the page loads. If the service worker sends a message immediately on `tabs.onUpdated`, the content script may not be listening yet. Use a handshake or retry:
```js
// content script — announce it's ready
chrome.runtime.sendMessage({ type: 'CONTENT_READY' });
// service worker — wait for CONTENT_READY before sending
chrome.runtime.onMessage.addListener((msg, sender) => {
if (msg.type === 'CONTENT_READY' && sender.tab) {
chrome.tabs.sendMessage(sender.tab.id, { type: 'INIT_DATA', ... });
}
});
```
### Multiple listeners responding
Only one listener should respond to a given message type. If multiple listeners call `sendResponse`, only the first one wins and the rest are silently ignored.
# Omnibox Integration
## Setup
```json
{
"omnibox": { "keyword": "wiki" }
}
```
User types `wiki` + Space in the address bar to activate.
## Providing Suggestions
```js
chrome.omnibox.onInputChanged.addListener(async (text, suggest) => {
if (text.length < 2) return;
try {
const response = await fetch(
`https://en.wikipedia.org/w/api.php?action=opensearch&search=${encodeURIComponent(text)}&limit=5&format=json`
);
const [, titles, , urls] = await response.json();
const suggestions = titles.map((title, i) => ({
content: urls[i],
description: `${title} - <url>${urls[i]}</url>`
}));
suggest(suggestions);
} catch (err) {
console.error('Search failed:', err);
}
});
```
## Handling Selection
```js
chrome.omnibox.onInputEntered.addListener((text, disposition) => {
const url = text.startsWith('http') ? text
: `https://en.wikipedia.org/wiki/${encodeURIComponent(text)}`;
switch (disposition) {
case 'currentTab':
chrome.tabs.update({ url });
break;
case 'newForegroundTab':
chrome.tabs.create({ url });
break;
case 'newBackgroundTab':
chrome.tabs.create({ url, active: false });
break;
}
});
```
## Description Formatting
Suggestions support XML-like formatting:
- `<url>text</url>` — renders as URL style
- `<match>text</match>` — bold match highlighting
- `<dim>text</dim>` — dimmed/secondary text
## Default Suggestion
```js
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
chrome.omnibox.setDefaultSuggestion({
description: `Search Wikipedia for "<match>${text}</match>"`
});
// ... fetch and suggest
});
```
## Required host_permissions
If fetching suggestions from an API, declare:
```json
{
"host_permissions": ["https://en.wikipedia.org/*"]
}
```
# Popup UI
## Setup
```json
{
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"default_title": "My Extension"
}
}
```
## Key Constraints
- Popup closes when the user clicks outside it — don't rely on it staying open
- Default max size: 800x600 px. Set size via CSS on body/html
- All scripts must be external files (CSP — no inline scripts)
- All event listeners must use `addEventListener` (no inline handlers)
## Popup HTML Template
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { width: 350px; min-height: 200px; padding: 16px; font-family: system-ui; }
</style>
</head>
<body>
<h1>My Extension</h1>
<div id="content"></div>
<script src="popup.js"></script>
</body>
</html>
```
## Persistence
Popup state is lost when closed. Use `chrome.storage` for persistence:
```js
// Save on change
document.getElementById('input').addEventListener('input', (e) => {
chrome.storage.local.set({ savedInput: e.target.value });
});
// Restore on open
document.addEventListener('DOMContentLoaded', async () => {
const { savedInput = '' } = await chrome.storage.local.get('savedInput');
document.getElementById('input').value = savedInput;
});
```
Note: `localStorage` technically works in popups (they have a persistent origin), but
`chrome.storage` is strongly preferred because it works across all extension contexts
and supports sync.
## Communicating with Service Worker
```js
// From popup
const response = await chrome.runtime.sendMessage({ type: 'GET_STATUS' });
// Long-lived connection
const port = chrome.runtime.connect({ name: 'popup' });
port.postMessage({ type: 'INIT' });
port.onMessage.addListener((msg) => { /* handle */ });
```
## Dynamic Popup vs No Popup
If you want the action click to do something instead of showing a popup, remove
`default_popup` and use `chrome.action.onClicked`:
```js
// In service worker — only fires if NO popup is set
chrome.action.onClicked.addListener((tab) => {
// Open side panel, inject script, etc.
});
```
You can toggle between popup and no-popup dynamically:
```js
chrome.action.setPopup({ popup: 'popup/popup.html' }); // Enable popup
chrome.action.setPopup({ popup: '' }); // Disable popup (enables onClicked)
```
# Chrome Prompt API (LanguageModel) — Extension-Specific Notes
The `LanguageModel` API (Prompt API) works in all extension contexts — service worker, popup,
side panel, and other extension pages — with no additional manifest permissions required.
For general Prompt API usage (availability checks, session creation, streaming, session
management), use the `modern-web-guidance` skill.
## Deprecated namespace
Extensions that used the origin trial may still have the old API surface. Remove it:
```js
// ❌ OLD — deprecated
const session = await self.ai.languageModel.create();
// ✅ CURRENT (Chrome 138+)
const session = await LanguageModel.create({ ... });
```
Also remove the expired permission from manifest.json:
```json
"permissions": ["aiLanguageModelOriginTrial"] // ❌ remove this
```
## Extension-only: `LanguageModel.params()`
Extensions have access to `LanguageModel.params()`, which returns model constraints not
available on the web:
```js
const params = await LanguageModel.params();
// { defaultTopK: 3, maxTopK: 128, defaultTemperature: 1, maxTemperature: 2 }
const session = await LanguageModel.create({
temperature: 0.7,
topK: 5
});
```
## Complete extension example: page summarizer
A full wiring example showing manifest + service worker + side panel together.
Note the use of `tabs` + `host_permissions` instead of `activeTab` — side panel button
clicks do NOT activate `activeTab` (see Rule 12).
### manifest.json
```json
{
"manifest_version": 3,
"name": "AI Page Summarizer",
"version": "1.0",
"permissions": ["sidePanel", "tabs", "scripting"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "service-worker.js" },
"side_panel": { "default_path": "sidepanel/sidepanel.html" },
"action": { "default_title": "Summarize Page" }
}
```
### service-worker.js
```js
chrome.action.onClicked.addListener(async (tab) => {
await chrome.sidePanel.open({ windowId: tab.windowId });
});
```
### sidepanel/sidepanel.js
```js
const statusEl = document.getElementById('status');
const summaryEl = document.getElementById('summary');
document.getElementById('summarize').addEventListener('click', async () => {
if (!globalThis.LanguageModel) {
statusEl.textContent = 'Prompt API not available in this browser.';
return;
}
const availability = await LanguageModel.availability({
expectedInputs: [{ type: "text", languages: ["en"] }],
expectedOutputs: [{ type: "text", languages: ["en"] }]
});
if (availability === 'unavailable') {
statusEl.textContent = 'AI model not available on this device.';
return;
}
// Requires "tabs" + "host_permissions" — activeTab does NOT work from a side panel button
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const [{ result: pageText }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const body = document.body.cloneNode(true);
body.querySelectorAll('script, style, nav, footer, header').forEach(el => el.remove());
return body.innerText.substring(0, 4000);
}
});
const session = await LanguageModel.create({
expectedInputs: [{ type: "text", languages: ["en"] }],
expectedOutputs: [{ type: "text", languages: ["en"] }],
initialPrompts: [{ role: 'system', content: 'Summarize web page content in 3-5 bullet points.' }],
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
const pct = e.total ? Math.floor((e.loaded / e.total) * 100) : 0;
statusEl.textContent = `Downloading model: ${pct}%`;
});
}
});
summaryEl.textContent = '';
for await (const chunk of session.promptStreaming(`Summarize:\n\n${pageText}`)) {
summaryEl.textContent += chunk; // APPEND — do not replace
}
session.destroy();
});
```
# Service Worker Lifetime & State Management
## The Core Problem
Chrome terminates extension service workers after ~30 seconds of inactivity. Unlike Manifest V2
persistent background pages, you CANNOT rely on in-memory state.
## Rules
1. **Never store state in global variables** — treat every event handler as if the SW just started
2. **Use chrome.storage for all persistent state** — read on demand, write after changes
3. **Use chrome.alarms for timers** — not setTimeout/setInterval (these die with the SW)
4. **Use chrome.storage.session for ephemeral session state** — survives SW restart but not browser restart
## Storage Tier Selection
| Need | Use |
|------|-----|
| Survives browser restart, syncs across devices | `chrome.storage.sync` (8KB/item, 100KB total) |
| Survives browser restart, local only | `chrome.storage.local` (10MB default) |
| Survives SW restart only | `chrome.storage.session` (10MB default) |
| Never persisted (avoid) | Global variables ❌ |
## Pattern: State Read-on-Demand
```js
// ❌ BAD: State in memory
let count = 0;
chrome.webNavigation.onCompleted.addListener(() => {
count++;
chrome.action.setBadgeText({ text: String(count) });
});
// ✅ GOOD: State in storage
chrome.webNavigation.onCompleted.addListener(async (details) => {
if (details.frameId !== 0) return; // Main frame only
const data = await chrome.storage.local.get({ visitCount: 0 });
data.visitCount++;
await chrome.storage.local.set(data);
chrome.action.setBadgeText({ text: String(data.visitCount) });
});
```
## Pattern: Alarms Instead of Timers
```js
// ❌ BAD: Timer dies when SW terminates
setInterval(() => checkForUpdates(), 60000);
// ✅ GOOD: Alarm persists
chrome.alarms.create('check-updates', { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'check-updates') {
checkForUpdates();
}
});
```
Minimum alarm interval: 0.5 minutes.
## Pattern: One-Time Initialization
```js
// Set up defaults and context menus on install
chrome.runtime.onInstalled.addListener(async (details) => {
if (details.reason === 'install') {
await chrome.storage.local.set({ settings: defaultSettings });
}
// Context menus must be re-created (they persist, but re-creating is idempotent)
chrome.contextMenus.create({
id: 'myItem',
title: 'My Context Menu Item',
contexts: ['selection']
});
});
```
## Pattern: Keeping the SW Alive (When Necessary)
Occasionally you need the SW alive for a long-running operation. Use one of:
1. **chrome.offscreen** — create an offscreen document for long tasks
2. **Periodic storage writes** — each chrome.storage call resets the idle timer
3. **Active port connections** — an open port keeps the SW alive
```js
// Port-based keepalive from popup/side panel
const port = chrome.runtime.connect({ name: 'keepalive' });
// The SW stays alive as long as this port is open
```
⚠️ Do NOT abuse keepalive patterns. Chrome may enforce stricter limits in future versions.
## Pattern: Event Registration
All event listeners MUST be registered synchronously at the top level of the service worker.
Chrome replays events to a restarted SW, but only for listeners that were registered synchronously.
```js
// ✅ GOOD: Top-level registration
chrome.runtime.onMessage.addListener(handleMessage);
chrome.tabs.onUpdated.addListener(handleTabUpdate);
chrome.webNavigation.onCompleted.addListener(handleNavigation);
// ❌ BAD: Conditional or async registration
async function setup() {
const { enabled } = await chrome.storage.local.get('enabled');
if (enabled) {
chrome.tabs.onUpdated.addListener(handleTabUpdate); // Too late!
}
}
setup();
```
Instead, register all listeners and check conditions inside them:
```js
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
const { enabled } = await chrome.storage.local.get('enabled');
if (!enabled) return;
// Process...
});
```
## Date-Based Resets
For daily counters, store the date alongside the count:
```js
function getToday() {
return new Date().toISOString().split('T')[0]; // "2025-01-15"
}
async function incrementDailyCount() {
const { dailyCount = 0, countDate = '' } = await chrome.storage.local.get(['dailyCount', 'countDate']);
const today = getToday();
if (countDate !== today) {
// New day — reset
await chrome.storage.local.set({ dailyCount: 1, countDate: today });
return 1;
} else {
const newCount = dailyCount + 1;
await chrome.storage.local.set({ dailyCount: newCount });
return newCount;
}
}
```
# Side Panel
## Setup
Add to manifest.json:
```json
{
"permissions": ["sidePanel"],
"side_panel": {
"default_path": "sidepanel/sidepanel.html"
}
}
```
## Opening the Side Panel — REQUIRED
**A side panel definition alone does NOT make it openable.** You MUST provide an explicit
trigger to open it. Without one of these, users have no way to access the panel:
### Most common: Open on action icon click
If the extension's primary function is the side panel, remove `default_popup` from the action
and use `chrome.action.onClicked` to open the side panel:
```js
// service-worker.js
chrome.action.onClicked.addListener(async (tab) => {
await chrome.sidePanel.open({ windowId: tab.windowId });
});
```
⚠️ `chrome.action.onClicked` only fires when there is NO `default_popup` set. If you have both
a popup and a side panel, open the side panel from the popup via a button, or use a different
trigger.
### Alternative triggers
```js
// Open from a context menu item
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === 'open-panel') {
await chrome.sidePanel.open({ windowId: tab.windowId });
}
});
// Open from a keyboard shortcut (defined in manifest commands)
chrome.commands.onCommand.addListener(async (command) => {
if (command === 'open-side-panel') {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.sidePanel.open({ windowId: tab.windowId });
}
});
```
You can also open it for a specific tab:
```js
await chrome.sidePanel.open({ tabId: tab.id });
```
### Simplest: Auto-open via setPanelBehavior
If the side panel should open whenever the user clicks the extension icon, use `setPanelBehavior`
as a one-liner instead of an `onClicked` listener:
```js
// service-worker.js
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
```
⚠️ **The property is `openPanelOnActionClick` — NOT `openPanelOnActionIconClick`.**
Using the wrong name causes a synchronous TypeError that silently aborts the service worker.
When using `setPanelBehavior`, do NOT also define `default_popup` — the popup takes priority.
## Setting Panel Per-Tab
```js
// Different side panel content for different tabs
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (tab.url?.includes('github.com')) {
await chrome.sidePanel.setOptions({
tabId,
path: 'sidepanel/github-panel.html',
enabled: true
});
}
});
```
## Communication with Side Panel
The side panel is an extension page, so it can use all chrome.* APIs directly and communicate
with the service worker via `chrome.runtime.sendMessage` / `chrome.runtime.onMessage`.
To get data from the active tab's content script:
```js
// In side panel JS
async function getPageContent() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const response = await chrome.tabs.sendMessage(tab.id, { type: 'GET_CONTENT' });
return response;
}
```
Or use `chrome.scripting.executeScript` from the side panel (requires `scripting` and `activeTab` permissions):
```js
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => document.body.innerText
});
```
## Side Panel vs Popup
| Feature | Side Panel | Popup |
|---------|-----------|-------|
| Stays open | Yes | Closes when clicking away |
| Resizable | Yes (by user) | Fixed size |
| Coexists with page | Yes (side by side) | Overlays page |
| Use when | Extended interaction, reading | Quick actions, settings |
## Important Notes
- The side panel shares a single instance per window — opening it replaces existing content
- Use `chrome.sidePanel.setOptions({ enabled: false })` to disable for specific tabs
- Side panel HTML files have full access to chrome.* APIs
- The side panel persists across tab switches (per-window)
### ⚠️ `activeTab` does NOT work from side panel interactions
`activeTab` only grants tab access on direct user gestures: clicking the extension icon, context
menu items, keyboard shortcuts, or omnibox suggestions. **Clicking a button inside a side panel
does NOT activate `activeTab`.**
If your side panel needs to read or modify page content (e.g., a "Summarize" button), use
`tabs` + `host_permissions` instead:
```json
{
"permissions": ["tabs", "scripting", "sidePanel"],
"host_permissions": ["<all_urls>"]
}
```
Do NOT rely on `activeTab` for side panel functionality.
# Chrome Storage API
## Storage Areas
| Area | Persists | Syncs | Quota | Use For |
|------|----------|-------|-------|---------|
| `chrome.storage.local` | Yes | No | 10 MB | Most extension data |
| `chrome.storage.sync` | Yes | Yes (across devices) | 100 KB total, 8 KB/item | User preferences, small data |
| `chrome.storage.session` | Until browser close | No | 10 MB | Ephemeral state, survives SW restart |
Permission required: `"storage"`
## Basic Operations
```js
// Set
await chrome.storage.local.set({ key: 'value', count: 42, items: [1,2,3] });
// Get (with defaults)
const { key = 'default', count = 0 } = await chrome.storage.local.get(['key', 'count']);
// Get all
const allData = await chrome.storage.local.get(null);
// Remove
await chrome.storage.local.remove('key');
await chrome.storage.local.remove(['key1', 'key2']);
// Clear all
await chrome.storage.local.clear();
```
## Change Listener (Works Across All Contexts)
```js
chrome.storage.onChanged.addListener((changes, areaName) => {
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
console.log(`${areaName}.${key}: ${oldValue} → ${newValue}`);
}
});
```
## storage.sync Quotas
Be aware of limits when using sync:
- `QUOTA_BYTES_PER_ITEM`: 8,192 bytes per key-value pair
- `MAX_ITEMS`: 512 items
- `QUOTA_BYTES`: 102,400 bytes total
- `MAX_WRITE_OPERATIONS_PER_HOUR`: 1,800
- `MAX_WRITE_OPERATIONS_PER_MINUTE`: 120
For large data, split across multiple keys or use `chrome.storage.local`.
## storage.session Notes
- Only available in MV3
- Cleared when the browser closes (not just when SW terminates)
- Accessible from service worker, popup, side panel, etc.
- Good for: auth tokens, temporary caches, in-progress operations
## Why Not localStorage?
`localStorage` works in popup and extension pages but NOT in service workers.
`chrome.storage` works everywhere and supports the cross-context change listener.
Always prefer `chrome.storage`.
# Tab Management & Groups
## Permissions
```json
{
"permissions": ["tabs", "tabGroups"]
}
```
Note: `tabs` permission gives access to `url`, `title`, `favIconUrl` on Tab objects.
Without it, you can still use `chrome.tabs` but won't see sensitive tab properties.
## Querying Tabs
```js
// All tabs
const allTabs = await chrome.tabs.query({});
// Active tab in current window
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
// Tabs matching a URL pattern
const gmailTabs = await chrome.tabs.query({ url: '*://mail.google.com/*' });
```
## Tab Operations
```js
// Create
const tab = await chrome.tabs.create({ url: 'https://example.com', active: true });
// Update
await chrome.tabs.update(tabId, { url: 'https://new-url.com', pinned: true });
// Close
await chrome.tabs.remove(tabId);
await chrome.tabs.remove([tabId1, tabId2]); // Multiple
// Move
await chrome.tabs.move(tabId, { index: 0 }); // Move to first position
// Reload
await chrome.tabs.reload(tabId);
```
## Tab Groups
```js
// Create a group from tabs
const groupId = await chrome.tabs.group({ tabIds: [tabId1, tabId2] });
// Customize the group
await chrome.tabGroups.update(groupId, {
title: 'Work',
color: 'blue', // grey, blue, red, yellow, green, pink, purple, cyan, orange
collapsed: false
});
// Move tab into existing group
await chrome.tabs.group({ tabIds: [newTabId], groupId: existingGroupId });
// Ungroup
await chrome.tabs.ungroup(tabId);
```
## Grouping by Domain
```js
async function groupByDomain() {
const tabs = await chrome.tabs.query({ currentWindow: true });
const byDomain = {};
for (const tab of tabs) {
try {
const domain = new URL(tab.url).hostname;
(byDomain[domain] ??= []).push(tab.id);
} catch { /* ignore tabs without URLs */ }
}
for (const [domain, tabIds] of Object.entries(byDomain)) {
if (tabIds.length > 1) {
const groupId = await chrome.tabs.group({ tabIds });
await chrome.tabGroups.update(groupId, {
title: domain.replace('www.', ''),
color: 'blue'
});
}
}
}
```
## Events
```js
chrome.tabs.onCreated.addListener((tab) => { /* new tab */ });
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { /* tab changed */ });
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { /* tab closed */ });
chrome.tabs.onActivated.addListener(({ tabId, windowId }) => { /* tab focused */ });
chrome.tabGroups.onUpdated.addListener((group) => { /* group changed */ });
```
## Windows
⚠️ **`chrome.windows` has NO `.query()` method.** Unlike `chrome.tabs.query()`, there is no
`chrome.windows.query()`. Use the correct method for your need:
```js
// ❌ BROKEN
const windows = await chrome.windows.query({ focused: true });
// TypeError: chrome.windows.query is not a function
// ✅ CORRECT
const focused = await chrome.windows.getLastFocused({ populate: true }); // includes tabs array
const current = await chrome.windows.getCurrent({ populate: true });
const all = await chrome.windows.getAll({ populate: true });
const single = await chrome.windows.get(windowId, { populate: true });
```
Full API: `getAll`, `getLastFocused`, `getCurrent`, `get(windowId)`, `create`, `update`, `remove`.
Pass `{ populate: true }` to include the `tabs` array on the returned window object.
```js
// Create a new window with specific tabs
const win = await chrome.windows.create({ url: 'https://example.com', focused: true });
// Move current window to a specific position/size
await chrome.windows.update(windowId, { left: 0, top: 0, width: 800, height: 600 });
// Minimise / maximise
await chrome.windows.update(windowId, { state: 'minimized' }); // 'normal' | 'minimized' | 'maximized' | 'fullscreen'
```
# User Scripts API
The `chrome.userScripts` API lets extensions run **arbitrary code provided by the user** at
runtime — code that cannot be shipped as part of the extension package. This is fundamentally
different from content scripts (which are bundled with the extension) and `chrome.scripting`
(which executes extension-owned code programmatically).
**Use userScripts when:** you are building a script manager, custom automation tool, or any
feature where the user supplies JavaScript that should run on web pages.
**Use content scripts when:** the injected code is written by you and ships with the extension.
**Use `chrome.scripting.executeScript` when:** you need one-off execution of known, extension-owned code.
## Manifest
```json
{
"manifest_version": 3,
"minimum_chrome_version": "120",
"permissions": ["userScripts"],
"host_permissions": ["https://example.com/*"]
}
```
- `"userScripts"` permission is required — without it `chrome.userScripts` is undefined.
- `host_permissions` must cover the sites where scripts will be injected.
## User Enablement — CRITICAL
**The `chrome.userScripts` API requires explicit user opt-in. Without it, the API throws on
property access.** Behavior differs by Chrome version:
| Chrome version | Requirement |
|----------------|-------------|
| < 138 | User must enable **Developer mode** at `chrome://extensions` |
| ≥ 138 | User must toggle **"Allow User Scripts"** on the extension's details page |
```js
// ❌ BROKEN — crashes if user hasn't enabled the API
await chrome.userScripts.register([{ id: 'foo', matches: [...], js: [...] }]);
// TypeError: Cannot read properties of undefined
// ✅ CORRECT — always guard before any chrome.userScripts.* call
function isUserScriptsAvailable() {
try {
chrome.userScripts; // throws if not enabled
return true;
} catch {
return false;
}
}
if (!isUserScriptsAvailable()) {
document.getElementById('warning').style.display = 'block';
document.getElementById('main-ui').style.display = 'none';
return;
}
```
## Execution Worlds
| World | Constant | Behavior |
|-------|----------|----------|
| **USER_SCRIPT** (default) | `ExecutionWorld.USER_SCRIPT` | Isolated from host page JS; exempt from page CSP |
| **MAIN** | `ExecutionWorld.MAIN` | Shares JS context with the host page; can access page variables |
Use `USER_SCRIPT` (the default) for safety. Use `MAIN` only when the user's script explicitly
needs to interact with the host page's JavaScript environment.
Chrome 133+ supports multiple isolated worlds via `worldId`, letting different user scripts
run in separate execution environments without interfering with each other.
## Registering Scripts (Persistent)
`register()` / `update()` / `getScripts()` / `unregister()` manage scripts that persist across
page loads and browser sessions. Registered scripts survive the service worker being terminated.
```js
// ❌ BROKEN — both code and file specified (ScriptSource must have exactly one)
await chrome.userScripts.register([{
id: 'my-script',
matches: ['https://example.com/*'],
js: [{ code: 'alert(1)', file: 'user-script.js' }] // Error: specify code OR file, not both
}]);
// ❌ BROKEN — id starts with underscore (reserved prefix)
await chrome.userScripts.register([{ id: '_my-script', ... }]);
// Error: User script IDs must not start with '_'
// ✅ CORRECT — register from a file bundled with the extension
await chrome.userScripts.register([{
id: 'my-user-script',
matches: ['https://example.com/*'],
js: [{ file: 'user-script.js' }],
runAt: 'document_idle' // optional, this is the default
}]);
// ✅ CORRECT — register inline code supplied by the user
await chrome.userScripts.register([{
id: 'my-user-script',
matches: ['https://example.com/*'],
js: [{ code: userProvidedCode }]
}]);
```
Always check before calling `register()` vs `update()` — registering an already-existing ID
throws an error:
```js
const existing = await chrome.userScripts.getScripts({ ids: ['my-user-script'] });
if (existing.length > 0) {
await chrome.userScripts.update([{ id: 'my-user-script', js: [{ code: updatedCode }] }]);
} else {
await chrome.userScripts.register([{
id: 'my-user-script',
matches: ['https://example.com/*'],
js: [{ code: userProvidedCode }]
}]);
}
// Remove a specific script
await chrome.userScripts.unregister({ ids: ['my-user-script'] });
// Remove all registered user scripts
await chrome.userScripts.unregister();
```
## Persistence: Restore on Extension Update
**Registered user scripts are cleared when the extension updates.**
```js
// ❌ BROKEN — scripts registered once at install time are lost on every update
chrome.runtime.onInstalled.addListener(({ reason }) => {
if (reason === 'install') {
chrome.userScripts.register([{ id: 'foo', matches: [...], js: [...] }]);
// After the next extension update, 'foo' is gone — no re-registration
}
});
// ✅ CORRECT — persist configs in storage, restore on both install and update
chrome.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === chrome.runtime.OnInstalledReason.UPDATE) {
const { scripts = {} } = await chrome.storage.local.get('scripts');
for (const s of Object.values(scripts)) {
await chrome.userScripts.register([s]).catch(() => {});
}
}
});
```
Save the full script config (id, matches, js) to `chrome.storage` whenever the user saves
a script, so it can be restored after an update.
## One-Off Injection (Chrome 135+)
`execute()` injects a script immediately into a specific tab/frame without registering it. It
does not persist across page loads.
```js
// Requires Chrome 135+
const results = await chrome.userScripts.execute({
target: { tabId: tabId },
js: [{ code: userProvidedCode }],
world: 'USER_SCRIPT' // optional, default
});
for (const result of results) {
if (result.error) {
console.error('Injection failed in frame', result.frameId, result.error);
} else {
console.log('Result from frame', result.frameId, result.result);
}
}
```
Guard for Chrome 135+ availability:
```js
if (typeof chrome.userScripts.execute === 'function') {
await chrome.userScripts.execute({ ... });
} else {
// Fall back to register-based approach
}
```
## Messaging from User Scripts
User scripts run in the `USER_SCRIPT` world which has no access to `chrome.runtime` by
default. Messaging requires an explicit opt-in.
```js
// ❌ BROKEN — messaging not enabled; chrome.runtime is undefined in the user script
// sw.js
chrome.runtime.onMessage.addListener((msg) => { ... }); // Never fires from user scripts
// user script code (running in the page)
chrome.runtime.sendMessage({ type: 'hello' }); // TypeError: chrome is not defined
// ✅ CORRECT — opt in first, then use the dedicated listener
// sw.js (run once, e.g. on install)
await chrome.userScripts.configureWorld({ messaging: true });
// sw.js — listen on the dedicated handler, not onMessage
chrome.runtime.onUserScriptMessage.addListener((message, sender, sendResponse) => {
sendResponse({ ok: true });
return true; // keep channel open for async responses
});
// user script code — chrome.runtime is now available
chrome.runtime.sendMessage({ type: 'FROM_USER_SCRIPT', data: 42 });
```
For long-lived connections use `chrome.runtime.onUserScriptConnect` (analogous to
`runtime.onConnect` for content scripts).
Chrome 133+ supports per-world messaging with `worldId`:
```js
await chrome.userScripts.configureWorld({ worldId: 'my-world', messaging: true });
```
## `configureWorld()` — CSP and Messaging
```js
await chrome.userScripts.configureWorld({
csp: "script-src 'self'", // custom CSP for the world
messaging: true // enable chrome.runtime messaging
});
// Chrome 133+: configure a named world
await chrome.userScripts.configureWorld({
worldId: 'my-world',
messaging: true
});
// Chrome 133+: reset a world's configuration
await chrome.userScripts.resetWorldConfiguration('my-world');
// Chrome 133+: list all world configurations
const configs = await chrome.userScripts.getWorldConfigurations();
```
## Complete Script Manager Pattern
```js
// options.js — save user's script and (re)register it
async function saveScript(id, matches, code) {
// Persist the config so we can restore it after extension updates
const { scripts = {} } = await chrome.storage.local.get('scripts');
scripts[id] = { id, matches, js: [{ code }] };
await chrome.storage.local.set({ scripts });
// Register or update the live script
const existing = await chrome.userScripts.getScripts({ ids: [id] });
if (existing.length > 0) {
await chrome.userScripts.update([{ id, matches, js: [{ code }] }]);
} else {
await chrome.userScripts.register([{ id, matches, js: [{ code }] }]);
}
}
```
## Key Differences from Content Scripts
| | Content Scripts | userScripts |
|--|-----------------|-------------|
| Code source | Bundled with extension | Provided by user at runtime |
| Persistence | Automatic (manifest) | Manual (register + restore on update) |
| Arbitrary code | No | Yes |
| User opt-in | No | Yes (Developer Mode / Allow User Scripts) |
| Messaging | `runtime.sendMessage` | `runtime.onUserScriptMessage` (after `configureWorld`) |
| CSP exemption | Yes | Yes (USER_SCRIPT world) |
| Multiple worlds | No | Yes (Chrome 133+ with `worldId`) |
# CHROMEWEBSTORE.md Template
Copy this template into the project root as `CHROMEWEBSTORE.md` and fill in each section.
Fields marked `[REQUIRED]` must be completed before submission. Fields marked `[RECOMMENDED]`
are optional but improve listing quality and approval odds.
---
```markdown
# Chrome Web Store Listing — [Extension Name]
> Last Updated: YYYY-MM-DD
## Store Listing
**Extension Name** [REQUIRED]
<!-- Must match manifest.json "name". Max 75 characters. -->
**Short Description** [REQUIRED]
<!-- Max 132 characters. Shown in search results and tiles. Be specific about function. -->
**Detailed Description** [REQUIRED]
<!-- Max 16,000 characters. Structure recommendation:
Line 1: One-sentence summary of what the extension does
Paragraph 2: Key features (use line breaks, not bullet points — CWS strips markdown)
Paragraph 3: How to use it (step-by-step)
Paragraph 4: Privacy/permissions note (builds trust)
Paragraph 5: Support/feedback info
WRITE FROM A USER'S PERSPECTIVE — what the extension does FOR them, not HOW it works.
Never mention implementation details: APIs used, libraries, frameworks, code patterns.
❌ "Uses a MutationObserver and custom elements to track page changes"
✅ "Automatically detects new content as you scroll"
❌ "Powered by a service worker for background processing"
✅ "Runs quietly in the background without slowing your browser"
❌ "Implements declarativeNetRequest for network filtering"
✅ "Blocks ads and trackers without reading your page content"
-->
**Category** [REQUIRED]
<!-- Pick one: Accessibility, Blogging, Developer Tools, Fun, News & Weather,
Photos, Productivity, Search Tools, Shopping, Social & Communication, Sports -->
**Single Purpose** [REQUIRED]
<!-- One sentence. Narrow and easy to understand.
Good: "Highlights and saves text selections on web pages"
Bad: "Productivity tool that helps you work better" -->
**Primary Language** [REQUIRED]
<!-- e.g., English, German, etc. -->
## Graphics & Assets
| Asset | Dimensions | Status | Filename |
|-------|-----------|--------|----------|
| Store Icon [REQUIRED] | 128×128 PNG | ⬜ Not created | |
| Screenshot 1 [REQUIRED] | 1280×800 or 640×400 | ⬜ Not created | |
| Screenshot 2 [RECOMMENDED] | 1280×800 or 640×400 | ⬜ Not created | |
| Screenshot 3 [RECOMMENDED] | 1280×800 or 640×400 | ⬜ Not created | |
| Screenshot 4 | 1280×800 or 640×400 | ⬜ Not created | |
| Screenshot 5 | 1280×800 or 640×400 | ⬜ Not created | |
| Small Promo Tile [RECOMMENDED] | 440×280 | ⬜ Not created | |
| Marquee Promo Tile | 1400×560 | ⬜ Not created | |
<!-- Status options: ⬜ Not created | 🟡 Needs update | ✅ Ready -->
### Screenshot Notes
<!-- Describe what each screenshot should show. Good screenshots demonstrate the extension
in action, not just the popup. Include annotations if helpful. -->
## Permissions Justification
<!-- Every permission in manifest.json needs a justification. The review team reads these.
Be specific about WHY the permission is needed and WHAT user-facing feature uses it.
"Required for functionality" will be rejected. -->
| Permission | Type | Justification |
|------------|------|---------------|
| | permissions | |
| | host_permissions | |
<!-- Type is either "permissions" or "host_permissions" -->
## Privacy & Data Use
<!-- These map to the CWS data use disclosure form. Be exhaustive and accurate —
mismatches between what you declare and what the code does cause rejection. -->
### Data Collection
**Does the extension collect user data?** Yes / No
<!-- If Yes, fill in the table below. If No, skip to the certification. -->
| Data Type | Collected? | Transmitted Off-Device? | Purpose | Shared with Third Parties? |
|-----------|-----------|------------------------|---------|---------------------------|
| Personally identifiable info | | | | |
| Health info | | | | |
| Financial info | | | | |
| Authentication info | | | | |
| Personal communications | | | | |
| Location | | | | |
| Web history | | | | |
| User activity | | | | |
| Website content | | | | |
### Data Use Certification
<!-- Check all that apply: -->
- [ ] Data is NOT sold to third parties
- [ ] Data is NOT used for purposes unrelated to the extension's core functionality
- [ ] Data is NOT used for creditworthiness or lending purposes
## Privacy Policy
**Privacy Policy URL** [REQUIRED]
<!-- Host this at a publicly accessible URL. GitHub Pages, your website, or a
Notion page all work. See references/webstore/privacy-policy.md for a template. -->
## Distribution
**Visibility**: Public / Unlisted / Private
**Regions**: All regions / [List specific regions]
## Developer Info
**Publisher Name** [REQUIRED]
**Contact Email** [REQUIRED]
<!-- Displayed publicly on the store listing. -->
**Support URL / Email** [RECOMMENDED]
<!-- Where users go for help. Can be a GitHub Issues page, email, or support site. -->
**Homepage URL** [RECOMMENDED]
## Version History
<!-- Add an entry for every version submitted to the Chrome Web Store.
Most recent first. -->
| Version | Date | Changes | Status |
|---------|------|---------|--------|
| | | | Draft |
<!-- Status options: Draft | Submitted | In Review | Published | Rejected -->
## Review Notes
<!-- Track rejection reasons, communication with the review team, and fixes applied.
This section is for your records, not published to the store. -->
### Known Issues / Limitations
<!-- Document anything reviewers might flag or users should know about. -->
### Rejection History
<!-- If applicable:
| Date | Reason | Fix Applied | Resubmitted |
|------|--------|-------------|-------------|
-->
```
# Privacy Policy Guidance
## When is a Privacy Policy Required?
A privacy policy URL is **required** if your extension:
- Handles personal or sensitive user data (as defined by CWS policies)
- Uses any of these permissions: `identity`, `cookies`, `webRequest`, `browsingData`,
`history`, `bookmarks`, `topSites`, `<all_urls>` host permission
- Collects any form of analytics or telemetry
- Transmits any data off the user's device
A privacy policy is **recommended** for all extensions, even if no data is collected.
It demonstrates professionalism and can prevent delays if a reviewer flags your extension.
## Where to Host It
The privacy policy must be at a publicly accessible URL. Options:
- **GitHub Pages**: Free, version-controlled. Create a `privacy.md` in a `docs/` branch.
- **GitHub Gist**: Quick and dirty. Create a public gist and link to the raw URL.
- **Project website**: If you have one, add a `/privacy` page.
- **Notion / Google Sites**: Free hosted pages. Stable URLs.
Avoid hosting on a URL that might go down or change. The CWS review team checks the link.
## What to Include
### Minimal Policy (No Data Collection)
If your extension genuinely collects no data, the policy can be short:
```
Privacy Policy for [Extension Name]
Last updated: [Date]
[Extension Name] does not collect, store, or transmit any personal data or
browsing information. All data stays on your device.
This extension does not use cookies, analytics, or third-party services.
If you have questions, contact [email].
```
### Standard Policy (Some Data Collection)
If your extension stores or transmits data, cover these topics:
1. **What data is collected** — Be specific. "User preferences" is not enough.
Say "Your selected theme preference (light/dark) and saved highlight colors."
2. **How data is stored** — Local storage only? Synced via chrome.storage.sync?
Sent to a server?
3. **Why data is collected** — Tie each data type to a specific feature.
4. **Third-party services** — If you use any APIs (analytics, auth, etc.), name them
and link to their privacy policies.
5. **Data sharing** — State whether data is shared with third parties. If yes, with
whom and why. If no, say so explicitly.
6. **Data retention** — How long is data kept? Can the user delete it?
7. **User controls** — How can users access, export, or delete their data?
If the extension has a "clear data" button, mention it.
8. **Changes to the policy** — State that you'll update the policy if practices change
and how users will be notified.
9. **Contact** — Email or URL for privacy questions.
### Template
```
Privacy Policy for [Extension Name]
Last updated: [Date]
## What Data We Collect
[Describe each type of data collected and the feature that requires it.]
## How Data Is Stored
[Describe storage mechanism — local only, synced, or server-side.]
## How Data Is Used
[Describe each use case. Tie to specific features.]
## Third-Party Services
[List any third-party services used. Link to their privacy policies.
If none, state "This extension does not use any third-party services."]
## Data Sharing
[State whether data is shared. If yes, with whom and why.]
## Data Retention and Deletion
[How long data is kept. How users can delete it.]
## Changes to This Policy
[How and when the policy may be updated. How users will be notified.]
## Contact
[Email or support URL for privacy inquiries.]
```
## Common Mistakes
- **Policy doesn't match the data disclosure form**: The CWS data disclosure form and your
privacy policy must be consistent. If the form says "no data collected" but the policy
mentions analytics, you'll be rejected.
- **Policy is too vague**: "We may collect some data" is not acceptable. Be specific.
- **Dead link**: If your privacy policy URL returns a 404, the submission is auto-rejected.
Verify the link before submitting.
- **Missing data types**: If your extension uses `chrome.storage.sync`, that data goes to
Google's servers — disclose this. If you make any `fetch()` calls, disclose what's sent.
- **No contact information**: The CWS requires a way for users to reach you about privacy
concerns. Include an email address at minimum.
# Pre-Publish Review Checklist
Run through this checklist before every submission to the Chrome Web Store. Each item
corresponds to a common rejection reason or publishing failure.
## Manifest & Package
- [ ] **manifest_version is 3** — Manifest V2 is no longer accepted for new submissions.
- [ ] **Version bumped** — CWS rejects uploads with a version ≤ the currently published
version. Use semver: bump patch for fixes, minor for features, major for breaking
changes.
- [ ] **Name matches CHROMEWEBSTORE.md** — The `name` field in manifest.json must exactly
match what you put in the store listing.
- [ ] **Description in manifest ≤ 132 chars** — This is the short description shown in
chrome://extensions. It should match or be close to your CWS short description.
- [ ] **No unnecessary files in ZIP** — Exclude: `.git/`, `node_modules/`, `.env`,
`*.map`, test files, build configs, `CHROMEWEBSTORE.md` itself, `README.md`,
`.DS_Store`, `thumbs.db`. Use a build script or `.cws-ignore`-style exclusion.
- [ ] **ZIP under 2GB** — Maximum package size. Most extensions should be under 10MB.
- [ ] **No absolute file paths** — All paths in manifest.json must be relative.
## Permissions
- [ ] **Minimum permissions** — Only request what you need. `<all_urls>` is a red flag.
Use specific host_permissions like `*://*.example.com/*` when possible.
- [ ] **Every permission justified** — Check the Permissions Justification section in
CHROMEWEBSTORE.md. The CWS dashboard has a field for each permission — you'll need
to fill these in during submission.
- [ ] **activeTab preferred over tabs + <all_urls>** — If you only need access to the
current tab when the user clicks your icon, `activeTab` is the right permission.
- [ ] **No unused permissions** — If you removed a feature that used a permission, remove
the permission from manifest.json too. Leftover permissions cause rejection.
- [ ] **host_permissions justified** — Explain which features need access to which domains
and why.
## Store Listing Content
- [ ] **Detailed description is specific** — Describes exactly what the extension does.
No vague marketing language. The review team reads this.
- [ ] **Single purpose is narrow** — One sentence that clearly states the primary function.
"Manages bookmarks into categorized folders" not "Productivity enhancement tool."
- [ ] **No misleading claims** — Don't claim features you don't have. Don't exaggerate
performance claims.
- [ ] **No keyword stuffing** — Don't repeat keywords unnaturally in the description.
- [ ] **No trademark violations** — Don't use other companies' names, logos, or trademarks
in your extension name, description, or screenshots unless you have authorization.
- [ ] **Contact email is valid** — The email shown on the listing must be monitored. Google
sends important notifications (takedowns, policy changes) to this address.
## Graphics
- [ ] **Store icon**: 128×128 PNG, no transparency issues, readable at small sizes.
- [ ] **At least 1 screenshot**: 1280×800 or 640×400 pixels. Shows the extension in action.
- [ ] **Screenshots are current** — Match the current version of the extension UI. Outdated
screenshots can trigger rejection.
- [ ] **No misleading screenshots** — Screenshots must accurately represent the extension.
- [ ] **No phone/tablet mockups** — Unless the extension actually works on those devices.
- [ ] **Small promo tile** (recommended): 440×280 PNG or JPEG. Used for featured placements.
## Privacy & Compliance
- [ ] **Data disclosure form matches reality** — The CWS data use disclosure checkboxes
must accurately reflect what the extension code actually does. Mismatch = rejection.
- [ ] **Privacy policy URL is live** — Visit the URL yourself. Confirm it loads and
contains an actual privacy policy, not a 404 or placeholder.
- [ ] **Privacy policy matches disclosure** — The text of the policy must be consistent
with what you declared in the disclosure form.
- [ ] **chrome.storage.sync disclosed** — If you use `chrome.storage.sync`, data is
transmitted to Google's servers. This counts as off-device transmission.
- [ ] **Remote code prohibition** — Extensions cannot execute remotely hosted code.
All JS must be bundled in the extension package. No loading scripts from CDNs
at runtime (Manifest V3 enforces this, but verify).
- [ ] **No obfuscated code** — Minification is fine. Obfuscation (intentionally making
code unreadable) is prohibited and will cause rejection.
## Functionality
- [ ] **Extension works** — Load it unpacked in Chrome, test all features. Check the
console for errors.
- [ ] **No crashes or blank popups** — Test popup, side panel, options page, content
scripts. All should load without errors.
- [ ] **Works on intended sites** — If the extension targets specific websites, verify
it works on current versions of those sites.
- [ ] **Graceful degradation** — The extension should handle edge cases (no internet,
empty data, restricted pages like chrome:// URLs) without crashing.
- [ ] **Uninstall is clean** — No persistent side effects after the extension is removed.
- [ ] **No excessive resource use** — The extension shouldn't noticeably slow down
browsing. Content scripts in particular should be lightweight.
## Updates (for existing extensions)
- [ ] **CHROMEWEBSTORE.md version history updated** — New entry with version, date, and
summary of changes.
- [ ] **Last Updated date bumped** — If any user-facing changes were made.
- [ ] **Feature list in descriptions updated** — If new features were added.
- [ ] **Permissions justification updated** — If manifest.json permissions changed.
- [ ] **Screenshots refreshed** — If the UI changed significantly.
- [ ] **Privacy disclosures updated** — If data practices changed.
## Packaging Script
To create a clean ZIP for submission, use a script like:
```bash
#!/bin/bash
# package-extension.sh — Creates a clean ZIP for Chrome Web Store submission
EXTENSION_NAME="my-extension"
VERSION=$(node -p "require('./manifest.json').version")
OUTPUT="${EXTENSION_NAME}-v${VERSION}.zip"
# Remove old package
rm -f "$OUTPUT"
# Create ZIP excluding dev files
zip -r "$OUTPUT" . \
-x ".git/*" \
-x "node_modules/*" \
-x ".env" \
-x "*.map" \
-x "tests/*" \
-x "__tests__/*" \
-x "*.test.*" \
-x "*.spec.*" \
-x ".eslintrc*" \
-x ".prettierrc*" \
-x "tsconfig.json" \
-x "package.json" \
-x "package-lock.json" \
-x "webpack.config.*" \
-x "vite.config.*" \
-x "rollup.config.*" \
-x "CHROMEWEBSTORE.md" \
-x "README.md" \
-x "CHANGELOG.md" \
-x ".DS_Store" \
-x "Thumbs.db" \
-x "*.sh" \
-x "store-assets/*"
echo "Packaged: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"
```
Customize the exclusion list for your project. The key principle: ship only what Chrome
needs to run the extension.
# Store Listing Tips & Common Rejections
## Writing Effective Descriptions
### Short Description (132 chars max)
This appears in search results and category pages. It's your elevator pitch. Rules:
- Start with a verb or the extension's function: "Blocks ads on all websites" not "Ad blocker"
- Be specific: "Translates selected text into 50+ languages" not "Translation tool"
- Include the primary keyword naturally
- Don't waste characters on "Chrome extension" — the user already knows
**Good examples:**
- "Save articles to read later with one click. Works offline."
- "Replace new tab with a minimal dashboard showing weather and tasks"
- "Highlight and annotate text on any webpage. Export notes as Markdown."
**Bad examples:**
- "The best productivity tool for Chrome!" (vague, marketing-speak)
- "Extension for helping you do things better" (says nothing)
- "NEW! Amazing tab manager extension tool app for Chrome browser" (keyword stuffing)
### Detailed Description (16,000 chars max)
The CWS strips all markdown formatting. Use plain text with line breaks. Structure:
```
[One sentence: what does this extension do?]
FEATURES
• Feature 1 — brief explanation
• Feature 2 — brief explanation
• Feature 3 — brief explanation
HOW TO USE
1. Click the extension icon in the toolbar
2. [Next step]
3. [Next step]
PRIVACY
This extension does not collect any personal data. Your [data type] is stored
locally on your device and never transmitted to any server.
PERMISSIONS
• "Read and change data on sites you visit" — needed to [specific feature].
The extension only activates when you [trigger action].
SUPPORT
Found a bug? Have a suggestion? Email [email] or open an issue at [URL].
Version [X.Y.Z] — [Brief changelog for latest version]
```
### The Implementation-Detail Rule
**Never describe how the extension is built.** Potential users are not developers evaluating your stack — they want to know what the extension does for them.
Strip all of the following from every piece of copy:
- Web API names: `MutationObserver`, `IntersectionObserver`, `Service Worker`, `Shadow DOM`, `IndexedDB`, `WebSockets`
- Chrome API names: `chrome.storage`, `declarativeNetRequest`, `chrome.scripting`, `offscreen document`
- Framework/library names: React, custom elements, Lit, Webpack, TypeScript
- Architecture descriptions: "background processing", "event-driven", "declarative"
**Transform every implementation sentence into a user benefit:**
| Before (implementation) | After (user benefit) |
|-------------------------|----------------------|
| "Uses a MutationObserver to detect page changes" | "Automatically detects new content as you browse" |
| "Built with custom elements and Shadow DOM" | "Works seamlessly without affecting page styles" |
| "Powered by a service worker" | "Runs quietly in the background" |
| "Your settings are synced via chrome.storage.sync" | "Your settings sync across all your devices" |
| "Implements declarativeNetRequest for filtering" | "Blocks ads and trackers without reading your page content" |
### Why This Structure Works
1. **One-sentence opener** — The reviewer and users both scan the first line. Make it count.
2. **Features list** — Users scan for capabilities. Plain-text bullets (•) render well.
3. **How to use** — Reduces support requests and proves the extension actually works.
4. **Privacy section** — Pre-empts user concerns about permissions. Builds trust.
5. **Permissions explanation** — Users see permission warnings during install. If you
explain them in the description, they're less likely to abort installation.
6. **Support info** — Required by CWS policy ("meaningful customer support").
7. **Latest version note** — Shows the extension is actively maintained.
### Single Purpose Statement
This is filled in the developer dashboard, not shown to users. The review team reads it
carefully. It must be a single sentence that describes the extension's narrow purpose.
**Approved examples:**
- "Saves highlighted text from web pages to a local reading list"
- "Replaces the new tab page with a customizable dashboard"
- "Blocks cookie consent banners on websites"
**Rejected examples:**
- "Improves your browsing experience" (too vague)
- "Productivity and organization tool" (too broad)
- "Highlights text, saves bookmarks, manages tabs, and blocks ads" (not single purpose)
If your extension does multiple things, focus on the primary function. The detailed
description can cover secondary features.
## Common Rejection Reasons
### 1. Excessive Permissions
**Symptom:** "Your extension requests more permissions than it needs."
**Fix:**
- Replace `<all_urls>` with specific host patterns
- Replace `tabs` with `activeTab` if you only need the current tab on click
- Remove permissions you're not using
- Ensure every permission has a clear justification
### 2. Missing or Inadequate Single Purpose
**Symptom:** "Your item does not have a single, clear purpose."
**Fix:**
- Rewrite the single purpose field to be narrow and specific
- If the extension truly does too many unrelated things, consider splitting it
### 3. Misleading Description or Functionality
**Symptom:** "Your extension does not provide the functionality described."
**Fix:**
- Ensure every feature listed in the description actually works
- Remove claims about features you haven't built yet
- Don't use superlatives ("the best", "the fastest") unless verifiable
### 4. Privacy Policy Issues
**Symptom:** "Your extension requires a privacy policy." or "Your privacy policy URL
is not accessible."
**Fix:**
- Host the privacy policy at a stable, public URL
- Ensure it's not behind a login wall
- Make sure it covers all data the extension actually collects
- Match the privacy policy with the data disclosure form
### 5. Trademark Violation
**Symptom:** "Your extension uses trademarked content without authorization."
**Fix:**
- Don't use other companies' names in your extension name (e.g., "YouTube Downloader")
- Don't use logos or brand colors that imply affiliation
- Use generic terms: "Video Downloader for [site]" might be fine, but check the site's terms
### 6. Code Readability
**Symptom:** "Your extension contains obfuscated code."
**Fix:**
- Minification is allowed; obfuscation is not
- If using a bundler (webpack, rollup, vite), ensure source maps are NOT included but
the output is minified, not obfuscated
- Don't use string encoding tricks to hide code intent
### 7. Remote Code Execution
**Symptom:** "Your extension executes remotely hosted code."
**Fix:**
- Bundle all JavaScript in the extension package
- Don't load scripts from CDNs at runtime
- Don't use `eval()` or `new Function()` with remote content
- Fetching JSON data from APIs is fine; fetching and executing JS is not
### 8. User Data Disclosure Mismatch
**Symptom:** "Your extension's data usage does not match your disclosure."
**Fix:**
- Audit every `fetch()`, `XMLHttpRequest`, and `chrome.storage.sync` call
- Remember that `chrome.storage.sync` transmits data to Google's servers
- If you use any analytics library (even self-hosted), declare it
- If you log errors to an external service, declare it
## After Rejection
When an extension is rejected:
1. Read the rejection email carefully — it specifies which policy was violated
2. Update CHROMEWEBSTORE.md with the rejection reason and fix
3. Make the required changes to the extension code or listing
4. Re-verify against the pre-publish checklist
5. Resubmit through the developer dashboard
6. Note: Repeated policy violations can result in account suspension
## Review Timeline
- First submission: typically 1–3 business days, can be longer
- Updates to existing extensions: usually faster, often within 24 hours
- Expedited review: not officially available; maintaining a clean track record helps
- Deferred publishing: you can choose to publish manually after review passes,
giving you control over timing. Must publish within 30 days of approval.