Claude API Browser Extension Example (Manifest V3)
Building a browser extension that calls the Claude API means solving a problem that doesn't exist in a typical web or server app: you can't safely put a long-lived API key inside code that ships to every user's machine. This article walks through a working Manifest V3 extension example that talks to Claude, explains where the key should actually live, and shows how to stream responses into a popup or side panel.
If you've searched for a "Claude API browser extension example," you're probably trying to figure out three things: how to structure the extension (manifest, background worker, content script), how to make the actual API call without exposing credentials, and how to handle streaming output in a UI that isn't a normal web page. We'll cover all three below with real code.
Why extensions can't call Claude directly with a raw key
Anthropic's API key is meant for server-side use. A Chrome or Firefox extension's background script runs in every installed copy of your extension — if you bundle sk-ant-... into it, anyone can extract it from the packed .crx or .xpi file in minutes. Content Security Policy in Manifest V3 also restricts remote code execution, so you can't just load a script that injects the key at runtime either.
The practical fix is a thin backend that holds the real credential and issues scoped, revocable keys to each client. That's exactly what SubToAPI does: you connect your Claude access once, and your extension talks to https://api.subtoapi.app/v1/messages using an application key (sub_live_...) instead of your Anthropic key. If that key ever leaks from a user's browser, you revoke it from the dashboard without touching your main account.
Extension architecture
A Claude-powered extension typically has three pieces:
- manifest.json — declares permissions, background worker, and popup/content script
- background.js (service worker) — makes the network call to the Claude API endpoint
- popup.js or content script — collects user input and renders the response
The background worker makes the call, not the content script, because content scripts run in the context of the page the user is visiting and are more exposed to that page's own scripts.
manifest.json
{
"manifest_version": 3,
"name": "Claude Assistant",
"version": "1.0.0",
"permissions": ["storage", "activeTab"],
"host_permissions": ["https://api.subtoapi.app/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html"
}
}
The host_permissions entry is required in Manifest V3 for fetch calls to a specific origin from the background worker.
background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== "ASK_CLAUDE") return;
fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${SUBTOAPI_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "claude-sonnet-4",
max_tokens: 500,
messages: [{ role: "user", content: message.prompt }]
})
})
.then((res) => res.json())
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err.message }));
return true; // keep the message channel open for async response
});
popup.js
document.getElementById("send").addEventListener("click", () => {
const prompt = document.getElementById("input").value;
chrome.runtime.sendMessage({ type: "ASK_CLAUDE", prompt }, (response) => {
if (response.ok) {
document.getElementById("output").textContent =
response.data.content[0].text;
} else {
document.getElementById("output").textContent = "Error: " + response.error;
}
});
});
This gives you a minimal but functional popup: type a question, send it to the background worker, get a response. The SUBTOAPI_KEY constant should come from chrome.storage.local, not be hardcoded — more on that below.
Streaming responses in a popup or side panel
A blocking fetch().then() call feels slow for anything longer than a sentence. Streaming makes the extension feel responsive, which matters a lot in a 300px-wide popup where users expect instant feedback.
async function streamToPopup(prompt, onToken) {
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${SUBTOAPI_KEY}`,
"Content-Type": "application/json"
},
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();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
onToken(chunk);
}
}
Manifest V3 service workers can go idle, so for long streams keep the connection in a persistent context (like a side panel page) rather than a popup that closes when the user clicks away. The full event format and chunk structure are documented at /docs/streaming if you want to parse content_block_delta events properly instead of raw chunks.
Where the API key should actually live
Don't hardcode the key in background.js. Instead:
- Prompt the user to paste their application key once, on install or first use
- Store it with
chrome.storage.local.set({ subtoApiKey }) - Read it in the background worker before each request
chrome.storage.local.get(["subtoApiKey"], ({ subtoApiKey }) => {
// use subtoApiKey in the Authorization header
});
This keeps the raw key out of your source bundle, which matters because extension source is trivially unpacked by anyone who installs it. If you're distributing the extension to a team rather than individual users, SubToAPI's per-seat keys (available on the Team and Scale plans) let each teammate use their own key tied to one shared Claude plan, which makes usage tracking and revocation per-person instead of per-install.
Getting a key and testing the flow
Sign up at /signup, generate an application key from the dashboard, and test the exact request your extension will make with curl before wiring it into the background worker:
curl https://api.subtoapi.app/v1/messages \
-H "Authorization: Bearer $SUBTOAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4",
"max_tokens": 300,
"messages": [{"role": "user", "content": "Summarize this page in two sentences."}]
}'
Once that returns a clean response, drop the same headers and body into background.js and you have a working extension. The full request/response schema, including tool use for extensions that need to call page-level functions, is in /docs/messages, and a general setup walkthrough is at /docs/quickstart. Pricing for Solo, Team, and Scale plans is on /pricing.
FAQ
Can a Chrome extension call the Claude API directly from a content script? Technically yes with the right permissions, but it's a bad idea — content scripts share context with the page and are easier to inspect. Make the call from the background service worker instead and pass results to the content script or popup via chrome.runtime.sendMessage.
How do I avoid shipping my API key inside the extension package? Never bundle the key in source. Store it in chrome.storage.local after the user enters it once, or route calls through a server-side proxy like SubToAPI so the extension only ever holds a scoped, revocable application key.
Does streaming work the same way in a browser extension as in a web app? The underlying fetch and ReadableStream APIs work the same, but service workers in Manifest V3 can be terminated when idle. For long streams, run the fetch from a side panel or an offscreen document rather than a popup that might close mid-stream.