← Blog

How to Build a Claude Chrome Extension (Manifest V3)

2026-10-29 · 5 min read · SubToAPI Team

Building a Claude Chrome extension means wiring together three Manifest V3 pieces — a background service worker, a popup or side panel UI, and optionally a content script — and connecting them to Claude's API without leaking your API key to anyone who unpacks the extension. This guide walks through the full architecture, a working manifest, the fetch/streaming code, and the security tradeoffs you need to decide on before shipping.

The short version: you write a manifest.json declaring permissions and host access, a background service worker that owns the API call, a popup that sends user input to that worker via chrome.runtime.sendMessage, and you never hardcode a long-lived secret key directly in client-visible JS if you can avoid it. Below is the full setup.

Project structure

A minimal Claude Chrome extension needs these files:

claude-extension/
  manifest.json
  background.js
  popup.html
  popup.js
  content.js (optional)

manifest.json

Manifest V3 requires explicit host permissions for any domain you call from the extension, and a background service worker instead of the old persistent background page.

{
  "manifest_version": 3,
  "name": "Claude Assistant",
  "version": "1.0.0",
  "permissions": ["storage", "activeTab", "scripting"],
  "host_permissions": ["https://api.subtoapi.app/*"],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}

If you call the Anthropic API directly instead, you'd need https://api.anthropic.com/* in host_permissions. Either way, the API domain must be declared or the fetch will be blocked by CSP.

Background service worker: owning the API call

The service worker is the only place that should hold your API key. Popups and content scripts send it a message; it does the network call and returns the result.

// background.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type !== "ASK_CLAUDE") return;

  (async () => {
    const res = await fetch("https://api.subtoapi.app/v1/messages", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${await getApiKey()}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        model: "claude-sonnet-4",
        max_tokens: 1024,
        messages: [{ role: "user", content: msg.prompt }]
      })
    });
    const data = await res.json();
    sendResponse(data);
  })();

  return true; // keep the message channel open for async response
});

async function getApiKey() {
  const { subtoapiKey } = await chrome.storage.local.get("subtoapiKey");
  return subtoapiKey;
}

Store the key with chrome.storage.local (set once from an options page), not in a constant in the bundled JS — bundled constants are trivially visible in the extension's source files.

Popup UI

<!-- popup.html -->
<textarea id="prompt" rows="4"></textarea>
<button id="send">Ask Claude</button>
<div id="output"></div>
<script src="popup.js"></script>
// popup.js
document.getElementById("send").addEventListener("click", () => {
  const prompt = document.getElementById("prompt").value;
  chrome.runtime.sendMessage({ type: "ASK_CLAUDE", prompt }, (response) => {
    document.getElementById("output").textContent =
      response.content?.[0]?.text ?? "Error";
  });
});

Streaming responses in a popup

Streaming is trickier inside an extension because sendMessage/sendResponse is request-response, not a stream. The common pattern is to open a long-lived port connection instead:

// background.js
chrome.runtime.onConnect.addListener((port) => {
  port.onMessage.addListener(async (msg) => {
    const res = await fetch("https://api.subtoapi.app/v1/messages", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${await getApiKey()}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ ...msg.payload, stream: true })
    });

    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      port.postMessage({ chunk: decoder.decode(value) });
    }
    port.postMessage({ done: true });
  });
});

On the popup side, connect with chrome.runtime.connect() and append chunks as they arrive. Full event formats for streamed messages are documented at /docs/streaming.

Content scripts: adding Claude to a webpage

If the extension needs to read page content (summarize an article, rewrite selected text), the content script extracts text and passes it to the background worker — it should never call the API directly, since content scripts run in page context and are more exposed to the page's own CSP and inspection.

// content.js
document.addEventListener("mouseup", () => {
  const selection = window.getSelection().toString();
  if (selection.length > 0) {
    chrome.runtime.sendMessage({ type: "ASK_CLAUDE", prompt: `Summarize: ${selection}` });
  }
});

The API key problem

This is the part most tutorials skip. Any key stored in chrome.storage.local is readable by anyone with access to the browser profile, and Chrome extensions distributed as unpacked/CRX files can be unzipped and inspected. There's no way to fully hide a secret inside client-side extension code.

Three practical mitigations:

  1. Use a scoped key with low blast radius. A SubToAPI application key (sub_live_...) is independent from your Anthropic account credentials — if it leaks, you revoke it from the dashboard in seconds without touching your main Claude access.
  2. Monitor usage per key. SubToAPI's dashboard shows per-key usage metadata, so an exposed key leaking into a public extension listing shows up as an anomalous spike you can catch early.
  3. Proxy through your own backend when the extension has real users. For internal tools and small teams, calling the API straight from the service worker is fine. For a publicly distributed extension, route calls through a thin backend you control, and let that backend hold the key — the extension talks to your server, not directly to Claude.

Getting started only takes a signup and a key: create an account at /signup, generate a key from the dashboard, and follow /docs/quickstart for the request format before wiring it into background.js.

Questions

Do I need a backend server to build a Claude Chrome extension? No — the service worker can call the API directly for personal or internal-team extensions. For a publicly published extension, add a backend proxy so your key isn't bundled with the distributed code.

Why does the API call fail with a CSP or blocked-request error? Manifest V3 requires the API's domain listed in host_permissions. If you're calling https://api.subtoapi.app, add it explicitly; a missing or wildcard-only permission is the most common cause of silent fetch failures.

Can I stream Claude's response into the popup in real time? Yes, but use a chrome.runtime.connect() port rather than sendMessage, since streaming needs a persistent channel to forward chunks as they arrive — see the background/popup port example above and /docs/streaming for the full event format.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →