Claude API Browser Extension Development Guide
Building a browser extension that calls the Claude API lets you add AI features — summarization, rewriting, translation, Q&A — directly into any webpage the user is viewing. The two things that make this different from a normal web app are where your API key lives and how your extension talks to a remote API from a content script or background worker without running into CORS or manifest restrictions. This guide covers both, plus the actual request/response code you need to ship a working MV3 extension.
If you've only built server-side integrations before, the browser extension environment introduces constraints you won't see elsewhere: no server to hide secrets, strict Content Security Policies, and a permission model that treats every network call as something the user must explicitly allow.
Extension architecture: where does the API call happen?
In Manifest V3, you have three places code can run:
- Content scripts — injected into the page, can read the DOM, but run in a sandboxed context with no direct access to
fetchagainst arbitrary origins unless the host is declared. - Background service worker — the right place for network calls. It has broader permissions and doesn't get torn down mid-request (mostly — more on that below).
- Popup/options page — good for settings UI, API key input, and triggering actions.
The standard pattern: content script captures selected text or page content → sends a message to the background worker → background worker calls the Claude API → response streams back to the content script for rendering.
// manifest.json (relevant excerpt)
{
"manifest_version": 3,
"permissions": ["storage", "activeTab", "scripting"],
"host_permissions": ["https://api.subtoapi.app/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html"
}
}
Declaring host_permissions for the API host is required — without it, fetch calls from the background worker will be blocked by the browser's own cross-origin policy, independent of any CORS headers the API returns.
Where to put the API key
Never ship your Claude API key or any production key hardcoded in the extension bundle. Browser extensions are effectively public client-side code — anyone can unzip the .crx/.xpi and read every string in it. Two practical options:
- Bring-your-own-key: let each user paste their own API key into the extension's options page, store it with
chrome.storage.local, and call the API directly from the background worker. - Thin proxy backend: route requests through a small server you control that holds the real key, and have the extension authenticate to that server instead.
For most indie extensions, option 1 is simpler and avoids running infrastructure. This is also where a service like SubToAPI is useful: instead of asking users to manage a raw provider key, you issue them a scoped sub_live_... key that only works against the Claude-compatible endpoint, with usage tracked per key so you can see which users are driving cost without building your own metering system.
// options.js — save the user's key
document.getElementById("save").addEventListener("click", () => {
const key = document.getElementById("apiKey").value.trim();
chrome.storage.local.set({ subtoapiKey: key }, () => {
document.getElementById("status").textContent = "Saved.";
});
});
Making the request from the background worker
// background.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type !== "ASK_CLAUDE") return;
chrome.storage.local.get("subtoapiKey", async ({ subtoapiKey }) => {
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${subtoapiKey}`
},
body: JSON.stringify({
model: "claude-sonnet-4",
max_tokens: 500,
messages: [{ role: "user", content: msg.prompt }]
})
});
const data = await res.json();
sendResponse(data);
});
return true; // keep the message channel open for async response
});
// content-script.js
chrome.runtime.sendMessage(
{ type: "ASK_CLAUDE", prompt: `Summarize this page: ${document.body.innerText.slice(0, 4000)}` },
(data) => {
const summary = data.content?.[0]?.text ?? "No response";
renderSummaryBox(summary);
}
);
The full request/response schema is documented at /docs/messages — it mirrors the standard Claude message format, so if you've used the Claude API directly before, nothing here should surprise you.
Streaming inside a service worker
Streaming responses token-by-token makes an extension feel much more responsive, especially for summarization or rewriting tasks where users are staring at a sidebar waiting for text. The challenge is that MV3 service workers can be killed by the browser after ~30 seconds of inactivity, which can interrupt a long stream.
Mitigations:
- Keep the worker "alive" by sending periodic
chrome.runtime.sendMessagepings during an active stream. - Stream directly from the content script instead of the background worker when the page's CSP allows it — this avoids the service worker lifecycle problem entirely.
- Use
ReadableStreamparsing on server-sent events, as described in /docs/streaming.
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${subtoapiKey}`
},
body: JSON.stringify({
model: "claude-sonnet-4",
max_tokens: 500,
stream: true,
messages: [{ role: "user", content: prompt }]
})
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("data: ")) {
const payload = JSON.parse(line.slice(6));
appendToSidebar(payload);
}
}
}
Tool use for page-aware actions
If your extension needs Claude to take structured actions — extract a table, fill a form, generate a JSON summary for your own UI — define a tool schema and let the model call it instead of parsing free text. Tool definitions work the same way in a browser extension as in any other client; see /docs/tools for the schema and response format. This is far more reliable than regex-parsing a text response when you need consistent, structured output in the extension's UI.
Rate limits, cost control, and multi-user extensions
If you're distributing an extension to many users rather than just using it yourself, think about cost before you publish:
- Issue one application key per user rather than sharing a single key across your whole user base — this keeps usage and billing isolated and makes it trivial to revoke access for one person without affecting everyone else.
- Set per-key budgets or alerts so a runaway loop in your content script (e.g., re-triggering on every DOM mutation) doesn't generate an unexpected bill.
- Cache repeated requests client-side with
chrome.storage.localwhen the input (e.g., the same page URL) hasn't changed.
SubToAPI handles the key issuance and usage metadata part of this — you create a sub_live_... key per user or team seat from the dashboard, and usage shows up per key without you building a separate metering layer. Start with the free trial at /signup, check /pricing for plan details, and follow /docs/quickstart to get your first request working before wiring it into the extension.
questions
Do I need a backend server to call the Claude API from a browser extension? No. If users supply their own API key via the options page, the background service worker can call the API directly, as long as the host is listed in host_permissions in your manifest.
Why does my fetch call fail with a CORS-like error in the background worker? It's usually a missing host_permissions entry in manifest.json, not a server-side CORS issue — MV3 enforces its own host permission model independent of the API's CORS headers.
Can I stream Claude responses into a content script in real time? Yes — parse the server-sent events stream with ReadableStream in either the background worker or content script; just be aware service workers can be terminated during long idle gaps, so pinging the worker during an active stream helps avoid interruptions.