Chrome Extensions (MV3) skill for Codex
Manifest V3 extensions that survive the service worker. Codex designs the extension around Manifest V3's event-driven service worker: listeners registered at top level, state in chrome.storage, alarms instead of timers, offscreen documents for DOM work. It requests activeTab and optional host access before broad permissions, lints manifest.json with a bundled script, tests with Playwright's Chromium, and leaves Web Store submission to you.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- chrome-extensions Installs to ~/.agents/skills/chrome-extensions/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install chrome-extensions The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project chrome-extensions Installs to .agents/skills/chrome-extensions/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/chrome-extensions.zipunzip -o chrome-extensions.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) chrome-extensions PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $chrome-extensions, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds and reviews Chrome Manifest V3 extensions - manifest.json, an event-driven background service worker that survives termination, content scripts in isolated or main world, message passing, chrome.storage, minimal and optional permissions, declarativeNetRequest, side panels, offscreen documents, and Playwright testing - with a bundled manifest linter. Use when creating a browser extension, migrating from Manifest V2, debugging a service worker that loses state or a content script that cannot reach the page, or preparing a Chrome Web Store submission.
Use it when
- Creating a new Chrome or Chromium browser extension
- Migrating an extension from Manifest V2 to V3
- A background service worker loses state or a content script cannot reach the page
- Reviewing permissions before a Chrome Web Store submission
Use something else when
- You are automating a browser for tests rather than shipping an extension: use playwright-e2e
- You need a service worker for a website's offline mode: use pwa-service-workers
How to check the result
The bundled manifest linter exits 0, and every feature still works after the service worker is stopped and restarted from chrome://serviceworker-internals.
The mistake it prevents
Keeping state in service-worker global variables, which vanish when Chrome terminates the idle worker after about 30 seconds.
A shippable extension requests the fewest permissions that make it work, keeps no state in service-worker globals, registers every listener synchronously at startup, ships all code in the package (no remote scripts), and has been loaded unpacked, exercised, and linted before anyone talks about the Web Store.
Before writing code
- Read
AGENTS.mdandpackage.json. Note the tooling: plain files, WXT, Plasmo, or Vite with CRXJS. With a bundler, the manifest may be generated (WXT builds it fromwxt.config.tsand entrypoint files); edit the source, then lint the built output. - Write down the single purpose (the Web Store requires one) and the user-visible surfaces: toolbar popup, side panel, options page, context menu, content UI.
- For each feature, write the minimum permission it needs (table below) before coding.
- Lint the manifest whenever it changes:
python3 scripts/check_manifest.py <extension-dir-or-dist>
It flags MV2 leftovers (browser_action, background.scripts, string CSP), host
patterns in permissions, webRequestBlocking, missing referenced files, remote
code URLs, broad host access, sensitive permissions, and Web Store length limits.
Exit 1 means errors remain.
Manifest skeleton
{
"manifest_version": 3,
"name": "Page Notes",
"version": "1.0.0",
"description": "Attach private notes to any page and find them again later.",
"icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" },
"action": { "default_title": "Page Notes", "default_popup": "popup.html" },
"background": { "service_worker": "background.js", "type": "module" },
"permissions": ["storage", "activeTab", "scripting", "contextMenus"],
"optional_host_permissions": ["https://*/*"],
"commands": {
"_execute_action": { "suggested_key": { "default": "Ctrl+Shift+Y", "mac": "Command+Shift+Y" } }
}
}
Permissions: ask for less
| Feature | Minimum permission |
|---|---|
| Act on the current tab after the user clicks the icon, shortcut or menu | activeTab (+ scripting to inject) |
| Always run on specific sites | content_scripts.matches / host_permissions for those hosts only |
| Run on any site the user chooses | optional_host_permissions + chrome.permissions.request() from a user gesture |
| Persist settings or data | storage |
| Block or modify requests | declarativeNetRequest (static rulesets or dynamic rules), not webRequest |
| Periodic work | alarms (minimum period 30 seconds) |
| Read tab URLs in the background | tabs only if activeTab cannot do it |
Broad host access (<all_urls>, *://*/*) triggers longer Web Store review and an
alarming install prompt. Adding a permission in an update can disable the extension for
existing users until they accept: treat new permissions as a breaking change.
Service worker rules
The background service worker stops after about 30 seconds without events and restarts on the next event. Consequences:
// background.js
// 1. Register listeners at top level, synchronously. Not inside async callbacks or promises.
chrome.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === 'install') await chrome.storage.local.set({ notes: {} });
chrome.contextMenus.create({ id: 'add-note', title: 'Add note to page', contexts: ['page'] });
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== 'add-note' || !tab?.id) return;
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content/note-ui.js'] });
});
// 2. Async replies: return true and call sendResponse later.
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'get-notes') {
chrome.storage.local.get('notes').then(({ notes }) => sendResponse(notes?.[msg.url] ?? []));
return true;
}
});
// 3. No state in globals. Use chrome.storage.session for ephemeral state.
async function setBusy(tabId, busy) {
await chrome.storage.session.set({ [`busy:${tabId}`]: busy });
}
setTimeout/setIntervallonger than the worker’s lifetime will not fire; usechrome.alarms.- No DOM in the worker (
document,DOMParser, audio, clipboard). Use an offscreen document:chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['DOM_PARSER'], justification: '...' }). "type": "module"allows ES imports; dynamicimport()is not allowed in extension service workers.
Content scripts
| Need | Approach |
|---|---|
| Read or change the page DOM | Content script (isolated world, default) |
| Access page JS variables or patch page functions | Inject with world: 'MAIN'; it loses chrome.* APIs, talk via window.postMessage and validate origin |
| Only when the user asks | chrome.scripting.executeScript with activeTab |
| On every matching page | content_scripts in manifest, or chrome.scripting.registerContentScripts for user-chosen sites |
| UI injected into pages | Mount inside a shadow root to isolate styles both ways |
- Content scripts can use
chrome.runtime,chrome.storageandchrome.i18n, not most other APIs: message the service worker for those. - Cross-origin
fetchfrom a content script is subject to the page’s CORS; do network calls in the service worker (with host permission). - Treat everything from the page (DOM text,
postMessagedata) as untrusted input: neverinnerHTMLit into extension pages, nevereval. - Pages loaded before install or update do not have the new content script; inject on demand or tell the user to reload.
Storage
| Area | Size | Use |
|---|---|---|
chrome.storage.local | 10 MB (more with unlimitedStorage) | Main data |
chrome.storage.sync | ~100 KB total, 8 KB per item | Small settings synced across devices |
chrome.storage.session | 10 MB, memory only | Worker state that should not persist |
Listen to chrome.storage.onChanged in popups and content scripts to stay in sync.
Never store API secrets in the extension: the package is readable by anyone who installs it.
Security and policy
- No remotely hosted code: every script ships in the package. Fetching JSON config is fine; fetching and executing JS is not.
- The extension-pages CSP cannot allow
unsafe-evalor remote script hosts;wasm-unsafe-evalis allowed for WebAssembly. web_accessible_resourcesexposes files to web pages and makes the extension fingerprintable; list only what pages must load, with narrowmatches.externally_connectablecontrols which sites may message the extension; validatesender.originandsender.idin everyonMessageandonMessageExternalhandler.
Migrating from Manifest V2
browser_action/page_actiontoaction; background page to service worker (state moves tochrome.storage, timers toalarms, DOM work to offscreen documents).- Host patterns move from
permissionstohost_permissions. - Blocking
webRequesttodeclarativeNetRequestrules. chrome.tabs.executeScripttochrome.scripting.executeScript.- String CSP to
{ "extension_pages": "..." }; remove remote script sources. - Callback APIs return promises in MV3; convert as you touch them.
Firefox and other browsers
Firefox MV3 uses background.scripts (event pages), not service_worker, and needs
browser_specific_settings.gecko.id. WXT or a build-time manifest transform can emit
both. Edge and other Chromium browsers accept the Chrome package.
Testing and debugging
- Load unpacked at
chrome://extensions(Developer mode), use “Inspect views: service worker” for background logs, and “Errors” for runtime failures. Click the reload icon after manifest changes. - Test service worker restarts: stop the worker from
chrome://serviceworker-internalsor wait 30 seconds, then trigger each feature again. - Automated tests with Playwright’s bundled Chromium (branded Chrome 137+ ignores
--load-extension):
import { chromium } from '@playwright/test';
const ctx = await chromium.launchPersistentContext('', {
channel: 'chromium',
args: [`--disable-extensions-except=${extPath}`, `--load-extension=${extPath}`],
});
const [worker] = ctx.serviceWorkers().length ? ctx.serviceWorkers() : [await ctx.waitForEvent('serviceworker')];
const extensionId = new URL(worker.url()).host;
const page = await ctx.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`);
Publishing
Packaging is local (zip -r ext.zip dist/); uploading to the Chrome Web Store,
changing the listing, or submitting for review is outward-facing. Prepare the zip,
the permission justifications and the privacy disclosures, show them to the user, and
upload or submit only after explicit confirmation.
Verification
-
python3 scripts/check_manifest.py distexits 0; warnings reviewed and justified - Every feature works after a forced service-worker restart
- Works on a page opened before install (or degrades with a clear message)
- Popup, options and side panel have no console errors; content UI does not inherit page styles
- Permission prompt at install matches the listing’s stated purpose
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Chrome Extensions (MV3)"
short_description: "Manifest V3 extensions that survive the service worker"
default_prompt: "$chrome-extensions Build a Chrome extension that saves highlighted text from any page to a side panel" scripts/check_manifest.py
#!/usr/bin/env python3
"""Lint a Chrome extension's manifest.json for Manifest V3 mistakes.
Usage: python3 check_manifest.py <extension-dir-or-manifest.json>
Checks MV3 structure, MV2 leftovers, permission placement and breadth, file references
and Chrome Web Store length limits. Prints ERROR/WARN lines; exits 1 if any ERROR.
Standard library only. Point it at the built output folder (dist/) when using a bundler.
"""
from __future__ import annotations
import json
import os
import re
import sys
BROAD_HOSTS = {"<all_urls>", "*://*/*", "http://*/*", "https://*/*"}
SENSITIVE = {
"tabs": "exposes URL/title of every tab; activeTab is often enough",
"history": "full browsing history",
"cookies": "cookie access for granted hosts",
"webRequest": "observes all traffic for granted hosts; prefer declarativeNetRequest",
"debugger": "full control of pages; triggers a prominent warning",
"downloads": "download management",
"management": "can list and disable other extensions",
"nativeMessaging": "talks to native apps",
"clipboardRead": "reads clipboard",
}
MV2_ONLY_KEYS = {
"browser_action": "use 'action'",
"page_action": "use 'action'",
}
VERSION_RE = re.compile(r"^\d{1,5}(\.\d{1,5}){0,3}$")
def main(argv: list[str]) -> int:
if len(argv) != 1:
print(__doc__.strip(), file=sys.stderr)
return 2
target = argv[0]
path = os.path.join(target, "manifest.json") if os.path.isdir(target) else target
root = os.path.dirname(os.path.abspath(path))
try:
with open(path, encoding="utf-8") as fh:
m = json.load(fh)
except FileNotFoundError:
print(f"ERROR no manifest at {path}")
return 1
except json.JSONDecodeError as exc:
print(f"ERROR manifest.json is not valid JSON (comments are not allowed): {exc}")
return 1
errors: list[str] = []
warns: list[str] = []
err = errors.append
warn = warns.append
def must_exist(rel: str, what: str) -> None:
if not isinstance(rel, str):
return
if re.match(r"^[a-z]+://", rel):
err(f"{what} '{rel}' is a remote URL; MV3 forbids remotely hosted code")
return
if not os.path.exists(os.path.join(root, rel.lstrip("/"))):
err(f"{what} '{rel}' does not exist relative to manifest.json")
if m.get("manifest_version") != 3:
err(f"manifest_version is {m.get('manifest_version')!r}, expected 3")
for key in ("name", "version"):
if not m.get(key):
err(f"missing required key '{key}'")
version = str(m.get("version", ""))
if version and not VERSION_RE.match(version):
err(f"version '{version}' must be 1-4 dot-separated integers (no -beta suffixes; use version_name)")
elif version and any(int(p) > 65535 for p in version.split(".")):
err(f"version '{version}' has a part above 65535")
name = m.get("name", "")
if isinstance(name, str) and not name.startswith("__MSG_") and len(name) > 75:
err(f"name is {len(name)} chars; the limit is 75")
desc = m.get("description", "")
if not desc:
warn("no description; the Chrome Web Store requires one")
elif isinstance(desc, str) and not desc.startswith("__MSG_") and len(desc) > 132:
err(f"description is {len(desc)} chars; the limit is 132")
for key, hint in MV2_ONLY_KEYS.items():
if key in m:
err(f"'{key}' is Manifest V2; {hint}")
bg = m.get("background")
if isinstance(bg, dict):
if "persistent" in bg:
err("background.persistent is MV2; MV3 service workers are always event-driven")
if "scripts" in bg or "page" in bg:
if "service_worker" in bg:
warn("background has both scripts and service_worker; Chrome uses service_worker, Firefox uses scripts")
else:
err("background.scripts/page are not supported by Chrome MV3; use background.service_worker")
if "service_worker" in bg:
must_exist(bg["service_worker"], "background.service_worker")
action = m.get("action", {}) or {}
if "default_popup" in action:
must_exist(action["default_popup"], "action.default_popup")
icon_sets = [m.get("icons", {}), action.get("default_icon", {}) if isinstance(action.get("default_icon"), dict) else {}]
for icons in icon_sets:
for size, rel in (icons or {}).items():
must_exist(rel, f"icon {size}")
if not m.get("icons"):
warn("no top-level icons; provide at least 16, 48 and 128")
for key in ("options_page",):
if key in m:
must_exist(m[key], key)
if isinstance(m.get("options_ui"), dict):
must_exist(m["options_ui"].get("page"), "options_ui.page")
if isinstance(m.get("side_panel"), dict):
must_exist(m["side_panel"].get("default_path"), "side_panel.default_path")
if "sidePanel" not in m.get("permissions", []):
err("side_panel is declared but the 'sidePanel' permission is missing")
perms = m.get("permissions", []) or []
hosts = m.get("host_permissions", []) or []
for p in perms:
if "://" in p or p == "<all_urls>":
err(f"permission '{p}' is a host pattern; move it to host_permissions")
if p == "webRequestBlocking":
err("webRequestBlocking is not available to normal MV3 extensions; use declarativeNetRequest")
if p in SENSITIVE:
warn(f"permission '{p}': {SENSITIVE[p]}")
if "scripting" in perms and not hosts and "activeTab" not in perms:
warn("'scripting' without host_permissions or activeTab cannot inject anywhere")
for h in hosts + (m.get("optional_host_permissions", []) or []):
if h in BROAD_HOSTS:
warn(f"broad host permission '{h}': triggers in-depth review; prefer specific hosts, activeTab or optional_host_permissions")
for i, cs in enumerate(m.get("content_scripts", []) or []):
if not cs.get("matches"):
err(f"content_scripts[{i}] has no 'matches'")
for kind in ("js", "css"):
for rel in cs.get(kind, []) or []:
must_exist(rel, f"content_scripts[{i}].{kind}")
for pat in cs.get("matches", []):
if pat in BROAD_HOSTS:
warn(f"content_scripts[{i}] runs on '{pat}'; consider narrowing or injecting on demand")
war = m.get("web_accessible_resources")
if war is not None:
if not isinstance(war, list) or any(not isinstance(x, dict) for x in war):
err("web_accessible_resources must be a list of objects with 'resources' and 'matches' (MV3 format)")
else:
for i, entry in enumerate(war):
if not entry.get("matches") and not entry.get("extension_ids"):
err(f"web_accessible_resources[{i}] needs 'matches' or 'extension_ids'")
csp = m.get("content_security_policy")
if csp is not None:
if isinstance(csp, str):
err("content_security_policy must be an object ({extension_pages: ...}) in MV3")
elif isinstance(csp, dict):
pages = csp.get("extension_pages", "")
if "unsafe-eval" in pages.replace("wasm-unsafe-eval", "") or re.search(r"https?://", pages):
err("extension_pages CSP may not allow unsafe-eval or remote script sources in MV3")
for line in errors:
print(f"ERROR {line}")
for line in warns:
print(f"WARN {line}")
print(f"\n{len(errors)} error(s), {len(warns)} warning(s) in {path}")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.