references/extensions/api-calling.md
# 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.
references/extensions/auth-identity.md
# 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');
```
references/extensions/content-scripts.md
# 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 |
references/extensions/context-menus.md
# 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();
```
references/extensions/csp-sandbox.md
# 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.
references/extensions/declarative-net-request.md
# 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
references/extensions/devtools.md
# 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()`
references/extensions/icons.md
# 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.
references/extensions/media-capture.md
# 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.
references/extensions/message-passing.md
# 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.
references/extensions/omnibox.md
# 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/*"]
}
```
references/extensions/permissions.md
# Permissions
## `permissions` vs `host_permissions`
These are separate manifest keys — don't conflate them:
```json
{
"permissions": ["tabs", "scripting", "storage"],
"host_permissions": ["https://api.example.com/*"]
}
```
- `permissions` grants access to chrome.* APIs (`tabs`, `storage`, `scripting`, `desktopCapture`, etc.)
- `host_permissions` grants access to specific origins for `fetch`, `chrome.scripting.executeScript`, and reading tab URLs cross-origin
Scope `host_permissions` to specific domains rather than `<all_urls>` unless the extension genuinely needs to run on every site — broad host permissions draw extra Chrome Web Store review scrutiny.
## `tab.url` requires the `tabs` permission
Without it, `tab.url` and `tab.title` silently return `undefined` — no error thrown.
```js
// manifest.json — REQUIRED if you read tab.url or tab.title anywhere:
{ "permissions": ["tabs"] }
```
See `references/extensions/tab-management.md` for the full tabs/windows API.
## `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 `commands` API
- Accepting an omnibox suggestion
It does **NOT** grant access when clicking a button in a side panel, a popup button that opens
later, or any programmatic trigger.
```js
// ❌ 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` for the side-panel-specific writeup.
## `chrome.permissions.request()` needs a user gesture — the gesture survives one `sendMessage` hop, but not an `await` after that
`chrome.permissions.request()` (for optional/dynamic permissions) only works within a user
gesture (a click, keypress, etc.). A gesture from a UI context (side panel, popup) **does**
propagate across `chrome.runtime.sendMessage` to the service worker's `onMessage` listener — but
it's only good for that one synchronous turn. If the listener `await`s anything (a timer, a
storage read, another message round-trip) before calling `chrome.permissions.request()`, the
gesture is gone and the call throws `"This function must be called during a user gesture, such
as an onclick handler"`.
```js
// side-panel.js
button.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'REQUEST_PERMISSION' }, (granted) => { ... });
});
// service-worker.js
// ❌ BROKEN — an await before chrome.permissions.request() burns the gesture window
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'REQUEST_PERMISSION') {
(async () => {
await chrome.storage.local.get('someSetting'); // gesture is gone after this
const granted = await chrome.permissions.request({ permissions: ['downloads'] });
sendResponse(granted);
})();
return true;
}
});
// ✅ CORRECT — call chrome.permissions.request() as the first thing in the listener,
// nothing awaited before it
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'REQUEST_PERMISSION') {
chrome.permissions.request({ permissions: ['downloads'] }).then(sendResponse);
return true; // fine to await the *result* — the call itself already happened synchronously
}
});
```
**Rule of thumb:** call `chrome.permissions.request()` (or anything else gated on a user
gesture, like `activeTab`) as the very first statement of the message listener that receives the
click-triggered message — before any other `await`. Awaiting the *returned* promise afterward is
fine; awaiting anything *before* the call is what breaks it.
## Checking and removing permissions
```js
// Check whether an optional permission is currently granted
const hasIt = await chrome.permissions.contains({ permissions: ['downloads'] });
// Remove a previously granted optional permission
await chrome.permissions.remove({ permissions: ['downloads'] });
```
Declare optional permissions in the manifest so they're eligible to be requested at runtime:
```json
{ "optional_permissions": ["downloads"] }
```
references/extensions/popup-ui.md
# 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)
```
references/extensions/prompt-api.md
# 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();
});
```
references/extensions/service-worker.md
# 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;
}
}
```
references/extensions/side-panel.md
# 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.
references/extensions/storage.md
# 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`.
references/extensions/tab-management.md
# 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'
```
references/extensions/user-scripts.md
# 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`) |
references/webstore/chromewebstore-template.md
# 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 |
|------|--------|-------------|-------------|
-->
```
references/webstore/privacy-policy.md
# 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.
references/webstore/review-checklist.md
# 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.
references/webstore/store-listing.md
# 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.
SKILL.md
---
name: chrome-extensions
description: >
Build and publish Chrome Extensions using Manifest V3 best practices. Use this skill
whenever the user asks to create, modify, debug, or understand Chrome browser extensions,
add-ons, or anything involving the Chrome Extensions API. Trigger on mentions of: 'Chrome
extension', 'browser extension', 'manifest.json', 'content script', 'service worker' (in
browser context), 'popup' (in browser extension context), 'side panel', 'chrome.* API',
'declarativeNetRequest', 'omnibox', 'context menu' (in extension context), 'userScripts',
'user script', 'script manager', or any request to build functionality that integrates with
the Chrome browser UI. Also trigger for publishing to the Chrome Web Store: 'publish
extension', preparing an extension for publishing, responding to a review rejection, writing
permission justifications, or drafting a privacy policy.
---
# 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:
```js
// 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.
```js
// ❌ 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. See
`references/extensions/permissions.md`.
#### 5. Always use async/await — never `.then()` chains
```js
// ❌ 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:
```js
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:
```js
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
```js
// ❌ 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:
1. Pack the extension once (chrome://extensions → Pack)
2. Extract the public key from the .crx
3. 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:
```js
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`.
```js
// ❌ 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 on a direct user gesture (action
icon click, context menu item, keyboard shortcut, omnibox suggestion) — NOT from a button click
inside a side panel or popup. Use `tabs` + `host_permissions` instead. See
`references/extensions/permissions.md` and `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()`.
```js
// ❌ 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.
```js
// ❌ 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' }); // TypeError
```
**The only APIs available in offscreen documents are:**
- `chrome.runtime.sendMessage` / `chrome.runtime.onMessage`
- `chrome.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."`
```js
// ❌ 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:
```js
// ❌ 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.
```js
// ❌ 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 any `chrome.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 in `runtime.onInstalled` for the `"update"` reason.
- **Messaging requires explicit opt-in.** Call `configureWorld({ messaging: true })` first; listen on `runtime.onUserScriptMessage`, not `runtime.onMessage`.
- **`ScriptSource` constraint:** each `js` entry must have exactly one of `code` or `file`. **`id` constraint:** 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.
```js
// ❌ 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`.
#### 20. `chrome.permissions.request()` in the service worker must be called with no `await` before it in the message listener
A user gesture from a UI context (side panel, popup) does propagate across `chrome.runtime.sendMessage`
to the service worker's `onMessage` listener — but only for that one synchronous turn. If the
listener does an `await` (even a short delay) before calling `chrome.permissions.request()`, the
gesture is gone and the call throws `"This function must be called during a user gesture"`. Call
it as the first thing in the listener, with nothing awaited before it — see
`references/extensions/permissions.md`.
### Always Manifest V3
Never generate Manifest V2 code.
- `background.service_worker` not `background.scripts`
- `chrome.action` not `chrome.browserAction`
- `chrome.scripting.executeScript` not `chrome.tabs.executeScript`
- `host_permissions` is separate from `permissions`
- 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:
1. Read `manifest.json` to extract name, version, description, permissions, host_permissions
2. Scan the codebase for data collection (storage, fetch calls, analytics)
3. Check for icon files and their dimensions
4. 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 |
|-------|-----------|
| Permissions | `references/extensions/permissions.md` |
| 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
- [ ] `tabs` permission declared if `tab.url` or `tab.title` is 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 using `chrome.action.*` APIs
- [ ] If reading/scripting tabs from a side panel: use `tabs` + `host_permissions` (NOT `activeTab`)
- [ ] DevTools panel paths in `chrome.devtools.panels.create()` are relative to extension root
- [ ] Offscreen documents use ONLY `chrome.runtime` messaging — no `chrome.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.chooseDesktopMedia` passes `targetTab` with `tabs` permission
- [ ] `chrome.windows` calls use `getAll`/`getLastFocused`/`getCurrent` — NOT `.query()` (it doesn't exist)
- [ ] `chrome.permissions.request()` in a service worker `onMessage` listener is called with no `await` before it (gesture is lost after the first async gap)
- [ ] `chrome.userScripts` availability checked before use (API throws if user hasn't enabled it)
- [ ] User script configs persisted in `chrome.storage` and restored on `runtime.onInstalled` `"update"` reason
- [ ] `configureWorld({ messaging: true })` called before user scripts send messages; listening on `onUserScriptMessage` not `onMessage`
- [ ] `ScriptSource` entries each have exactly one of `code` or `file` (not both, not neither)
- [ ] User script `id` values do not start with underscore
- [ ] `sidePanel.setPanelBehavior` uses `openPanelOnActionClick` — NOT `openPanelOnActionIconClick`
- [ ] Error handling on all async operations
- [ ] `host_permissions` scoped to specific domains (not `<all_urls>` unless needed)
- [ ] `return true` in `onMessage` listeners with async responses
- [ ] Any use of `"tab"` in `chrome.contextMenus` `contexts` requires Chrome M150+