Agent Skills: Manifest V3 Reference

Complete reference for Manifest V3 browser extension development with cross-browser compatibility and migration guidance

UncategorizedID: arustydev/ai/manifest-v3-reference

Repository

aRustyDevLicense: AGPL-3.0
72

Install this agent skill to your local

pnpm dlx add-skill https://github.com/aRustyDev/agents/tree/HEAD/content/plugins/web/browser-extension-dev/skills/manifest-v3-reference

Skill Files

Browse the full folder contents for manifest-v3-reference.

Download Skill

Loading file tree…

content/plugins/web/browser-extension-dev/skills/manifest-v3-reference/SKILL.md

Skill Metadata

Name
manifest-v3-reference
Description
Complete reference for Manifest V3 browser extension development with cross-browser compatibility and migration guidance

Manifest V3 Reference

Complete reference for Manifest V3 browser extension development with cross-browser compatibility notes, Firefox MV2 fallbacks, and Safari-specific considerations.

Manifest Version Comparison

| Feature | MV2 | MV3 | Notes | |---------|-----|-----|-------| | Background | Persistent page | Service worker | No DOM access in MV3 | | Remote code | Allowed | Forbidden | Must bundle all scripts | | Host permissions | In permissions | Separate field | More granular control | | Content scripts | Same | Same | No changes | | Web accessible | Array | Object with matches | Per-resource rules | | CSP | Configurable | Restricted | No unsafe-eval | | Action | browserAction/pageAction | action | Unified API | | Declarative | Optional | Required for webRequest | declarativeNetRequest |

Browser Support Matrix

| Browser | MV3 Status | MV2 Status | Minimum Version | |---------|------------|------------|-----------------| | Chrome | Required | Deprecated | 88+ (full), 102+ (service workers) | | Firefox | Supported | Supported | 109+ (MV3), 48+ (MV2) | | Safari | Required | Not supported | 15.4+ | | Edge | Required | Deprecated | 88+ |

Core Manifest Structure

Minimal MV3 Manifest

{
  "manifest_version": 3,
  "name": "Extension Name",
  "version": "1.0.0",
  "description": "Brief description (max 132 chars for Chrome)",

  "icons": {
    "16": "icons/16.png",
    "32": "icons/32.png",
    "48": "icons/48.png",
    "128": "icons/128.png"
  },

  "action": {
    "default_icon": "icons/48.png",
    "default_popup": "popup.html",
    "default_title": "Click to open"
  },

  "permissions": [
    "storage"
  ],

  "background": {
    "service_worker": "background.js",
    "type": "module"
  }
}

Firefox-Specific Fields

{
  "browser_specific_settings": {
    "gecko": {
      "id": "extension@example.com",
      "strict_min_version": "109.0",
      "strict_max_version": "130.*",
      "data_collection_permissions": {
        "required": [],
        "optional": ["technicalAndInteraction"]
      }
    }
  }
}

Safari-Specific Considerations

Safari extensions require:

  1. Xcode project wrapper
  2. App Store distribution
  3. Privacy manifest (PrivacyInfo.xcprivacy)
  4. Code signing
# Convert existing extension
xcrun safari-web-extension-converter ./extension-dir \
  --project-location ./safari-project \
  --app-name "My Extension"

Background Scripts

MV3 Service Worker

// background.ts (MV3)
// Service worker - no DOM, no persistent state

// Use alarms for periodic tasks
chrome.alarms.create('periodic-task', { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'periodic-task') {
    performTask();
  }
});

// State must be stored explicitly
chrome.runtime.onInstalled.addListener(() => {
  chrome.storage.local.set({ initialized: true });
});

// Handle messages
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  handleMessage(message).then(sendResponse);
  return true; // Async response
});

MV2 Background Page (Firefox fallback)

{
  "background": {
    "scripts": ["background.js"],
    "persistent": false
  }
}
// background.js (MV2)
// Event page with DOM access

let state = {};

browser.runtime.onMessage.addListener((message, sender) => {
  return handleMessage(message);
});

// Can use DOM APIs
const parser = new DOMParser();

Cross-Browser Background Detection

// Detect environment
const isServiceWorker = typeof ServiceWorkerGlobalScope !== 'undefined'
  && self instanceof ServiceWorkerGlobalScope;

const isEventPage = typeof window !== 'undefined';

// Choose appropriate APIs
function createOffscreenDocument() {
  if (chrome.offscreen) {
    return chrome.offscreen.createDocument({
      url: 'offscreen.html',
      reasons: ['DOM_PARSER'],
      justification: 'Parse HTML content'
    });
  }
  // MV2/Firefox: Use background page DOM directly
  return Promise.resolve();
}

Permissions

Permission Types

| Type | MV3 Location | Purpose | |------|--------------|---------| | API permissions | permissions | Access to browser APIs | | Host permissions | host_permissions | Access to web content | | Optional | optional_permissions | Runtime-requested | | Optional host | optional_host_permissions | Runtime-requested URLs |

Permission Reference

| Permission | Description | User Warning | |------------|-------------|--------------| | activeTab | Current tab on user action | None | | alarms | Schedule code execution | None | | bookmarks | Read/write bookmarks | Yes | | clipboardRead | Read clipboard | Yes | | clipboardWrite | Write clipboard | None | | contextMenus | Custom context menus | None | | cookies | Read/write cookies | Yes | | declarativeNetRequest | Modify network requests | None | | downloads | Manage downloads | None | | geolocation | Access location | Yes (at use) | | history | Browser history | Yes | | identity | OAuth flows | None | | notifications | System notifications | None | | scripting | Execute scripts | None | | storage | Extension storage | None | | tabs | Tab URLs and titles | Yes | | webNavigation | Navigation events | None | | webRequest | Observe requests | None (MV3) |

Host Permissions

{
  "host_permissions": [
    "https://api.example.com/*",
    "*://*.example.org/*"
  ],
  "optional_host_permissions": [
    "<all_urls>"
  ]
}

Permission Patterns

| Pattern | Matches | Notes | |---------|---------|-------| | <all_urls> | All URLs | Requires justification | | *://*/* | All HTTP(S) | Same as all_urls for web | | https://*.example.com/* | Subdomains | Single domain family | | https://example.com/api/* | Path prefix | Most restrictive |

Cross-Browser Permission Differences

| Feature | Chrome | Firefox | Safari | |---------|--------|---------|--------| | activeTab + scripting | Full injection | Full injection | Limited to declared | | declarativeNetRequest | Full support | Partial | Full support | | offscreen | Supported | Not supported | Not supported | | sidePanel | Supported | Not supported | Not supported |

Action API

MV3 Unified Action

// MV3: Single unified action API
chrome.action.setIcon({ path: 'icons/active.png' });
chrome.action.setBadgeText({ text: '5' });
chrome.action.setBadgeBackgroundColor({ color: '#FF0000' });
chrome.action.setTitle({ title: 'New title' });

chrome.action.onClicked.addListener((tab) => {
  // No popup defined - handle click
});

MV2 browserAction/pageAction

// MV2: Separate APIs
browser.browserAction.setIcon({ path: 'icons/active.png' });
browser.pageAction.show(tabId);

Cross-Browser Action Pattern

// Unified wrapper
const action = chrome.action || chrome.browserAction || browser.browserAction;

function setIcon(path: string) {
  return action.setIcon({ path });
}

function setBadge(text: string) {
  return action.setBadgeText({ text });
}

Content Scripts

Manifest Declaration

{
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["content.js"],
      "css": ["content.css"],
      "run_at": "document_idle",
      "all_frames": false,
      "match_about_blank": false,
      "world": "ISOLATED"
    }
  ]
}

Programmatic Injection (MV3)

// MV3: scripting API
chrome.scripting.executeScript({
  target: { tabId },
  files: ['inject.js'],
  world: 'ISOLATED'
});

// With function
chrome.scripting.executeScript({
  target: { tabId },
  func: (arg) => {
    console.log('Injected with', arg);
  },
  args: ['argument']
});

Programmatic Injection (MV2)

// MV2: tabs API
browser.tabs.executeScript(tabId, {
  file: 'inject.js',
  runAt: 'document_idle'
});

World Isolation

| World | Access | Use Case | |-------|--------|----------| | ISOLATED (default) | Own JS context | Most extensions | | MAIN | Page's JS context | Page script modification |

// MAIN world injection (MV3)
chrome.scripting.executeScript({
  target: { tabId },
  func: () => {
    // Can access page's window, modify prototypes
    window.pageVariable = 'modified';
  },
  world: 'MAIN'
});

Network Request Handling

Declarative Net Request (MV3)

{
  "permissions": ["declarativeNetRequest"],
  "declarative_net_request": {
    "rule_resources": [
      {
        "id": "ruleset_1",
        "enabled": true,
        "path": "rules.json"
      }
    ]
  }
}
[
  {
    "id": 1,
    "priority": 1,
    "action": { "type": "block" },
    "condition": {
      "urlFilter": "||ads.example.com",
      "resourceTypes": ["script", "image"]
    }
  },
  {
    "id": 2,
    "priority": 2,
    "action": {
      "type": "redirect",
      "redirect": { "url": "https://example.com/blocked" }
    },
    "condition": {
      "urlFilter": "tracker.js",
      "resourceTypes": ["script"]
    }
  }
]

Dynamic Rules (MV3)

// Add rules at runtime
chrome.declarativeNetRequest.updateDynamicRules({
  addRules: [
    {
      id: 1000,
      priority: 1,
      action: { type: 'block' },
      condition: { urlFilter: userBlockedDomain }
    }
  ]
});

WebRequest (MV2)

// MV2: Blocking webRequest
browser.webRequest.onBeforeRequest.addListener(
  (details) => {
    if (shouldBlock(details.url)) {
      return { cancel: true };
    }
  },
  { urls: ['<all_urls>'] },
  ['blocking']
);

Cross-Browser Network Handling

// Check for MV3 declarativeNetRequest
if (chrome.declarativeNetRequest) {
  // Use declarative rules
  setupDeclarativeRules();
} else if (browser.webRequest) {
  // Fall back to MV2 webRequest
  setupWebRequestListeners();
}

Web Accessible Resources

MV3 Syntax

{
  "web_accessible_resources": [
    {
      "resources": ["inject.js", "styles.css"],
      "matches": ["https://*.example.com/*"],
      "use_dynamic_url": true
    },
    {
      "resources": ["public/*"],
      "matches": ["<all_urls>"],
      "extension_ids": []
    }
  ]
}

MV2 Syntax

{
  "web_accessible_resources": [
    "inject.js",
    "styles.css",
    "public/*"
  ]
}

Accessing Resources

// Get resource URL
const url = chrome.runtime.getURL('inject.js');
// chrome-extension://EXTENSION_ID/inject.js

// With dynamic URL (MV3)
// chrome-extension://EXTENSION_ID/RANDOM_TOKEN/inject.js

Messaging

Internal Messaging

// Send from content script to background
chrome.runtime.sendMessage({ type: 'getData' }, (response) => {
  console.log('Received:', response);
});

// Background listener
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'getData') {
    getData().then(sendResponse);
    return true; // Async
  }
});

Tab Messaging

// Background to specific tab
chrome.tabs.sendMessage(tabId, { type: 'update' });

// Content script listener
chrome.runtime.onMessage.addListener((message) => {
  if (message.type === 'update') {
    updateUI();
  }
});

External Messaging

{
  "externally_connectable": {
    "matches": ["https://app.example.com/*"]
  }
}
// From web page
chrome.runtime.sendMessage(extensionId, { type: 'request' }, (response) => {
  console.log('Extension responded:', response);
});

// In extension
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
  // Validate sender.url
  if (isAllowedOrigin(sender.url)) {
    handleExternalMessage(message).then(sendResponse);
    return true;
  }
});

Storage API

Storage Types

| Type | Quota | Sync | Persistence | |------|-------|------|-------------| | local | 10MB (unlimited with permission) | No | Until cleared | | sync | 100KB total, 8KB/item | Yes | Cross-device | | session | 10MB | No | Until browser close | | managed | N/A | N/A | Admin-configured |

Usage

// Set values
await chrome.storage.local.set({ key: 'value' });

// Get values
const result = await chrome.storage.local.get(['key']);
console.log(result.key);

// Remove values
await chrome.storage.local.remove(['key']);

// Listen for changes
chrome.storage.onChanged.addListener((changes, areaName) => {
  for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
    console.log(`${key} changed from ${oldValue} to ${newValue}`);
  }
});

Session Storage (MV3)

// Memory-only storage - cleared when browser closes
await chrome.storage.session.set({ temporaryData: value });

// Set access level for content scripts
await chrome.storage.session.setAccessLevel({
  accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS'
});

Alarms API

// Create alarm
chrome.alarms.create('myAlarm', {
  delayInMinutes: 1,
  periodInMinutes: 5
});

// Listen for alarm
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'myAlarm') {
    performPeriodicTask();
  }
});

// Clear alarm
chrome.alarms.clear('myAlarm');

Offscreen Documents (MV3 Chrome only)

// Create offscreen document for DOM access
async function createOffscreen() {
  if (await chrome.offscreen.hasDocument()) return;

  await chrome.offscreen.createDocument({
    url: 'offscreen.html',
    reasons: ['DOM_PARSER', 'CLIPBOARD'],
    justification: 'Parse HTML and access clipboard'
  });
}

// Communicate with offscreen document
chrome.runtime.sendMessage({ target: 'offscreen', data: htmlContent });

Reasons: AUDIO_PLAYBACK, BLOBS, CLIPBOARD, DOM_PARSER, DOM_SCRAPING, GEOLOCATION, LOCAL_STORAGE, MATCH_MEDIA, TESTING, USER_MEDIA, WEB_RTC, WORKERS

Side Panel (MV3 Chrome only)

{
  "side_panel": {
    "default_path": "sidepanel.html"
  },
  "permissions": ["sidePanel"]
}
// Open side panel
chrome.sidePanel.open({ tabId });

// Set panel behavior
chrome.sidePanel.setOptions({
  tabId,
  path: 'sidepanel.html',
  enabled: true
});

Cross-Browser Compatibility Patterns

API Detection

// Check for API availability
function hasAPI(name: string): boolean {
  const parts = name.split('.');
  let obj: any = chrome;

  for (const part of parts) {
    if (obj[part] === undefined) return false;
    obj = obj[part];
  }

  return true;
}

// Usage
if (hasAPI('offscreen.createDocument')) {
  // Chrome MV3 offscreen
} else if (hasAPI('tabs.executeScript')) {
  // MV2 injection
}

Browser-Specific Manifest

// manifest.json for Chrome
{
  "manifest_version": 3,
  "background": {
    "service_worker": "background.js"
  }
}

// manifest.json for Firefox
{
  "manifest_version": 3,
  "background": {
    "scripts": ["background.js"]
  },
  "browser_specific_settings": {
    "gecko": { "id": "extension@example.com" }
  }
}

WXT Cross-Browser Handling

// wxt.config.ts
export default defineConfig({
  manifest: {
    // Common fields
    name: 'Extension',

    // Browser-specific overrides
    $browser_specific: {
      firefox: {
        browser_specific_settings: {
          gecko: { id: 'extension@example.com' }
        }
      }
    }
  }
});

Migration Checklist: MV2 to MV3

Required Changes

  • [ ] Update manifest_version to 3
  • [ ] Convert background page to service worker
  • [ ] Move host_permissions from permissions
  • [ ] Replace browser_action/page_action with action
  • [ ] Remove remote code loading
  • [ ] Update web_accessible_resources syntax
  • [ ] Replace blocking webRequest with declarativeNetRequest

Code Changes

  • [ ] Remove DOM usage from background
  • [ ] Add state persistence (storage.session)
  • [ ] Use chrome.scripting for injection
  • [ ] Handle service worker lifecycle
  • [ ] Add offscreen document if DOM needed

Testing

  • [ ] Test after browser restart
  • [ ] Test after extension reload
  • [ ] Test alarm persistence
  • [ ] Test message handling timing
  • [ ] Test content script injection

Related Resources

  • wxt-framework-patterns skill: WXT-specific patterns
  • cross-browser-compatibility skill: API compatibility matrices
  • extension-security skill: Security best practices