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.

  • Frontend
  • $chrome-extensions
  • 198 lines
  • 3 files
  • ~2,300 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- chrome-extensions

Installs to ~/.agents/skills/chrome-extensions/ for every project on this machine.

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.

~/.agents/skills/chrome-extensions/SKILL.md

--- name: chrome-extensions description: Builds and reviews Chrome Manifest V3 extensions - manifest.json, an event-driven background service worker that survives termination, content… license: MIT ---

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

  1. Read AGENTS.md and package.json. Note the tooling: plain files, WXT, Plasmo, or Vite with CRXJS. With a bundler, the manifest may be generated (WXT builds it from wxt.config.ts and entrypoint files); edit the source, then lint the built output.
  2. 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.
  3. For each feature, write the minimum permission it needs (table below) before coding.
  4. 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

FeatureMinimum permission
Act on the current tab after the user clicks the icon, shortcut or menuactiveTab (+ scripting to inject)
Always run on specific sitescontent_scripts.matches / host_permissions for those hosts only
Run on any site the user choosesoptional_host_permissions + chrome.permissions.request() from a user gesture
Persist settings or datastorage
Block or modify requestsdeclarativeNetRequest (static rulesets or dynamic rules), not webRequest
Periodic workalarms (minimum period 30 seconds)
Read tab URLs in the backgroundtabs 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/setInterval longer than the worker’s lifetime will not fire; use chrome.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; dynamic import() is not allowed in extension service workers.

Content scripts

NeedApproach
Read or change the page DOMContent script (isolated world, default)
Access page JS variables or patch page functionsInject with world: 'MAIN'; it loses chrome.* APIs, talk via window.postMessage and validate origin
Only when the user askschrome.scripting.executeScript with activeTab
On every matching pagecontent_scripts in manifest, or chrome.scripting.registerContentScripts for user-chosen sites
UI injected into pagesMount inside a shadow root to isolate styles both ways
  • Content scripts can use chrome.runtime, chrome.storage and chrome.i18n, not most other APIs: message the service worker for those.
  • Cross-origin fetch from 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, postMessage data) as untrusted input: never innerHTML it into extension pages, never eval.
  • Pages loaded before install or update do not have the new content script; inject on demand or tell the user to reload.

Storage

AreaSizeUse
chrome.storage.local10 MB (more with unlimitedStorage)Main data
chrome.storage.sync~100 KB total, 8 KB per itemSmall settings synced across devices
chrome.storage.session10 MB, memory onlyWorker 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-eval or remote script hosts; wasm-unsafe-eval is allowed for WebAssembly.
  • web_accessible_resources exposes files to web pages and makes the extension fingerprintable; list only what pages must load, with narrow matches.
  • externally_connectable controls which sites may message the extension; validate sender.origin and sender.id in every onMessage and onMessageExternal handler.

Migrating from Manifest V2

  • browser_action/page_action to action; background page to service worker (state moves to chrome.storage, timers to alarms, DOM work to offscreen documents).
  • Host patterns move from permissions to host_permissions.
  • Blocking webRequest to declarativeNetRequest rules.
  • chrome.tabs.executeScript to chrome.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-internals or 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 dist exits 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.yamlgenerated from this page

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.py173 lines, 7.5 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem