Building a Custom DevTools Panel

Add a panel to the browser's DevTools from an MV3 extension — devtools_page, panels.create, the three-context architecture, talking to the inspected page and to the service worker.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

A DevTools extension looks like one feature and is three separate documents: a hidden devtools_page that the browser loads whenever DevTools opens, the panel document that appears as a tab alongside Elements and Console, and — if the panel needs anything beyond inspectedWindow.eval — a content script in the inspected page plus the service worker to broker between them. Knowing which context owns which capability is most of the work. This guide is part of side panel and DevTools interfaces.

The contexts involved

The documents behind one DevTools panelFour contexts: the devtools_page that creates the panel, the panel document the developer sees, the service worker that brokers messages, and the content script in the inspected page.devtools_pagehidden, one per open DevToolscalls panels.create, holds devtools.* APIsPanel documentthe tab the developer seesUI, also has devtools.* APIsService workerbroker between panel and pageno devtools.* APIsContent scriptin the inspected pageDOM access, no devtools.* APIs
Only the top two exist while DevTools is open; the bottom two have their own lifetimes.

Step-by-step

1. Declare the devtools page

1{
2  "manifest_version": 3,
3  "devtools_page": "devtools.html",
4  "background": { "service_worker": "sw.js", "type": "module" }
5}

Execution context: parsed at install. devtools.html is loaded — invisibly — every time the user opens DevTools on any page. It must be tiny: its only job is to create the panel. Like every extension page it may not contain inline script.

1<!-- devtools.html -->
2<!doctype html>
3<script type="module" src="devtools.js"></script>

2. Create the panel

 1// devtools.js
 2chrome.devtools.panels.create(
 3  "Reader",                 // tab title
 4  "icons/16.png",           // tab icon
 5  "panel.html",             // the panel document
 6  (panel) => {
 7    panel.onShown.addListener((win) => win.dispatchEvent(new Event("panel-shown")));
 8    panel.onHidden.addListener(() => {});
 9  }
10);

Execution context: the devtools page. onShown passes the panel’s window, which lets the devtools page tell the panel it became visible — useful for refreshing on demand rather than polling while hidden.

3. Evaluate in the inspected page for simple reads

For read-only inspection of the page’s own JavaScript state, inspectedWindow.eval is the shortest path — it runs in the page’s main world, where the page’s globals live.

1// panel.js
2function readAppState() {
3  return new Promise((resolve, reject) => {
4    chrome.devtools.inspectedWindow.eval(
5      "JSON.stringify(window.__APP_STATE__ ?? null)",
6      (result, exceptionInfo) => exceptionInfo?.isException ? reject(exceptionInfo.value) : resolve(JSON.parse(result))
7    );
8  });
9}

Execution context: the panel document calls it; the expression runs in the inspected page’s main world. Return strings or JSON-serialisable values — DOM nodes and functions do not survive. Evaluating code in the page is acceptable here because it runs only when a developer explicitly opens your panel; it is still worth keeping the expressions fixed strings rather than building them from input.

4. Use a content script for anything ongoing

Continuous observation — watching mutations, intercepting events — belongs in a content script, which the panel reaches through the service worker.

1// panel.js — connect to the worker, identifying the inspected tab
2const port = chrome.runtime.connect({ name: `devtools:${chrome.devtools.inspectedWindow.tabId}` });
3port.onMessage.addListener((msg) => { if (msg.type === "mutation") appendRow(msg); });
4port.postMessage({ type: "watch:start" });
 1// sw.js — broker between panel ports and the tab's content script
 2const panels = new Map();   // tabId -> port
 3
 4chrome.runtime.onConnect.addListener((port) => {
 5  if (!port.name.startsWith("devtools:")) return;
 6  const tabId = Number(port.name.split(":")[1]);
 7  panels.set(tabId, port);
 8  port.onMessage.addListener((msg) => chrome.tabs.sendMessage(tabId, msg).catch(() => {}));
 9  port.onDisconnect.addListener(() => panels.delete(tabId));
10});
11
12chrome.runtime.onMessage.addListener((msg, sender) => {
13  if (sender.tab && panels.has(sender.tab.id)) panels.get(sender.tab.id).postMessage(msg);
14});

Execution context: the panel and the service worker. The open port keeps the worker alive for as long as the panel is connected, which is appropriate while a developer is actively inspecting. The tab id comes from inspectedWindow.tabId — the one piece of identity the panel has. Port lifecycles are covered in long-lived ports vs one-time messages.

5. Handle navigation of the inspected page

The inspected page can reload or navigate while DevTools stays open. Your content script is destroyed and recreated; the panel is not.

1chrome.devtools.network.onNavigated.addListener((url) => {
2  clearRows();
3  status.textContent = `Watching ${new URL(url).host}`;
4  port.postMessage({ type: "watch:start" });          // re-arm the new content script
5});

Execution context: the panel document. onNavigated is the panel’s signal that the page under inspection has changed — without it, the panel shows stale data from the previous page and the new page’s content script never receives its start message.

A mutation, from page to panelA DOM mutation is observed by the content script, sent to the service worker, forwarded over the panel's port keyed by tab id, and rendered in the panel.Page DOM changesinspected tab 42Content scriptMutationObserverruntime.sendMessagesender.tab.id = 42brokered by the workerpanels.get(42)port mapport.postMessageto the panelappendRow()developer sees it
The worker routes by tab id — the one identity the panel and the content script share.

Debugging the debugger

DevTools extensions are unusual in that the tool you would normally use to debug them is the thing they live inside. Each context has its own inspector, and knowing where to find each saves a great deal of confusion.

The panel document is inspected by right-clicking inside the panel and choosing Inspect, which opens a second DevTools window attached to the first — often called “DevTools on DevTools”. Console output from panel.js appears there, not in the page’s console. The devtools page is harder to reach because it is invisible; logging from it is easiest to read by forwarding messages to the panel, or by inspecting it from chrome://inspect/#other. The service worker is inspected from chrome://extensions as usual, and the content script from the inspected page’s own DevTools with the extension’s context selected in the console’s context dropdown.

A practical habit: prefix every log with its context — [panel], [devtools], [sw], [content] — so that when you inevitably look in the wrong console, the absence of a prefix tells you so immediately. The same discipline applies to extensions generally and is described in logging across contexts without losing messages.

Theming and fitting in with DevTools

A DevTools panel sits next to the browser’s own panels, and one that looks different is jarring in a tool developers use for hours. Two things help it belong.

Follow the DevTools theme. chrome.devtools.panels.themeName returns "default" or "dark". Set a class on the panel’s root and style from it.

1document.documentElement.dataset.theme = chrome.devtools.panels.themeName === "dark" ? "dark" : "light";

Execution context: the panel document, at startup. DevTools’ theme is independent of the operating system’s, so prefers-color-scheme is the wrong signal here — a developer may run a light OS with dark DevTools.

Match density and typography. DevTools uses small, dense type and compact rows. A panel with 16-pixel body text and generous padding feels like a web page dropped into a tool. Use the system UI font at around 12 pixels, tight row heights, and monospace for values.

Be quiet when hidden. A panel that keeps processing while another DevTools tab is active is wasting the developer’s CPU during the moment they are profiling something else. Pause observers on onHidden and resume on onShown.

Which context can do whatThe devtools page, panel document, service worker and content script compared on devtools API access, inspected-page DOM access, extension storage, and lifetime.Contextdevtools.* APIsPage DOMLifetimedevtools_pageYesNoWhile DevTools is openPanel documentYesVia eval onlyWhile DevTools is openService workerNoNoEvictableContent scriptNoYesPer page load
No single context can do everything — the architecture exists because the capabilities are split.

Cross-browser variation

  • Chrome / Edge: full chrome.devtools.panels, inspectedWindow and network APIs. themeName reports the DevTools theme.
  • Firefox: supports devtools_page, devtools.panels.create, inspectedWindow.eval and network.onNavigated. Some panel APIs such as sidebars in the Elements panel differ; test on Firefox if you ship there.
  • Safari: Web Inspector extensions are supported from Safari 16 with a subset of the API. Panel creation and inspectedWindow.eval work; network APIs are more limited.
  • All three: DevTools extensions cannot inspect other extensions’ pages or browser-internal pages. inspectedWindow.tabId is the reliable identity across all three.

Verification

  1. Load the extension, open DevTools on any page, and confirm a “Reader” tab appears.
  2. From the panel’s own DevTools (right-click inside the panel → Inspect), confirm the tab id:
1chrome.devtools.inspectedWindow.tabId;
2// 42

Execution context: the panel document’s console, opened via “DevTools on DevTools”. This is how you debug the panel itself — an important skill for this kind of extension.

  1. Trigger DOM changes on the page and confirm rows appear in the panel; reload the page and confirm the panel clears and resumes.
  2. Switch the DevTools theme and reopen DevTools; confirm the panel follows.

FAQ

Why can’t my content script call chrome.devtools?

The devtools.* namespace exists only in the devtools page and the panel document. Everything else talks to the panel through messages.

Does the devtools page run for every tab?

It runs once per open DevTools window. Opening DevTools on three tabs loads three devtools pages, each with its own inspectedWindow.tabId.

Is inspectedWindow.eval allowed under MV3’s CSP?

Yes — it is an explicit DevTools API, not eval in your extension’s context. The expression runs in the inspected page. It is still subject to review scrutiny; keep expressions fixed and narrowly scoped.

Can I add a sidebar pane to the Elements panel instead of a whole panel?

Yes — chrome.devtools.panels.elements.createSidebarPane adds a pane next to Styles and Computed, and onSelectionChanged tells you when the developer selects a different element. For tools that annotate the selected node, a sidebar pane fits the workflow better than a separate top-level panel.

Other UI/UX Patterns & Interactive Components Resources