Claude API Chrome Extension Integration Guide
Why Chrome extension + Claude API is tricky
Building a Chrome extension that calls Claude directly runs into two hard problems immediately: where do you store the API key, and how do you handle cross-origin requests from a content script or popup. Chrome extensions run client-side code that ships to every user's machine, so any key you bundle into the extension is extractable by anyone who unpacks the .crx file or inspects the background script. At the same time, Manifest V3's service worker model and host permissions change how you're allowed to make outbound HTTP calls compared to a traditional web app.
This guide covers the practical integration pattern: manifest configuration, where to put the network call, how to avoid exposing credentials, and how to handle streaming output inside a popup or side panel UI.
The core architecture
There are really only two safe architectures for a Claude-powered Chrome extension:
- Extension → your backend → Claude API. The extension never sees a raw Anthropic key. Your backend holds the credential, applies rate limiting, and proxies requests.
- Extension → a hosted API gateway with scoped, revocable keys. You still keep the long-lived provider credential off the client, but you skip writing your own proxy server.
Embedding an sk-ant-... key directly in background.js or popup.js is not an option for anything you plan to publish to the Chrome Web Store. Extension code is distributed as plain JavaScript; obfuscation doesn't stop extraction, and a leaked key billed to your account is a real financial risk.
This is exactly the gap a service like SubToAPI is built for: it turns your Claude access into a standard HTTPS API with its own application keys (sub_live_...) that you can issue per extension, per user, or per environment, and revoke independently without touching your main Anthropic credentials.
Setting up the manifest
A Manifest V3 extension needs host_permissions for whichever domain it talks to. If you're calling your own backend or a gateway, declare that origin explicitly:
{
"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"
}
}
Calling api.anthropic.com directly from extension code also requires host_permissions for that domain, but as noted above, you shouldn't be holding an Anthropic key client-side in the first place.
Making the request from the background service worker
Service workers in MV3 don't persist state between invocations, so keep requests short-lived and stateless. Fetch calls work the same as in a regular page context:
// background.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type !== "ASK_CLAUDE") return;
fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${msg.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
messages: [{ role: "user", content: msg.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
});
The popup or content script sends a message, the service worker does the fetch, and the response is relayed back. This keeps the popup's UI code free of network logic and makes it easy to swap backends later.
Storing the key safely in the extension itself
Even when you're using a scoped key like sub_live_... instead of a raw provider credential, don't hardcode it in source. Let the user paste their own key into an options page and store it with chrome.storage.local:
chrome.storage.local.set({ subtoapiKey: userEnteredKey });
chrome.storage.local.get(["subtoapiKey"], ({ subtoapiKey }) => {
chrome.runtime.sendMessage({
type: "ASK_CLAUDE",
apiKey: subtoapiKey,
prompt: "Summarize this page"
});
});
This pattern means each user brings their own key, your extension never bundles a shared credential, and if a key leaks it can be revoked from the dashboard without rebuilding the extension. See /docs/quickstart for generating keys and /docs/messages for the full request schema.
Handling streaming in a popup
Streaming responses are what make an extension feel responsive rather than frozen for several seconds while Claude generates a long answer. The complication is that popup windows close when they lose focus, which kills any in-flight connection. Two practical approaches:
- Use a side panel (
chrome.sidePanelAPI, Chrome 114+) instead of a popup — it stays open while the user interacts with the page. - Run the streaming fetch in the background service worker, append chunks to
chrome.storage.session, and have the popup poll or listen forchrome.storage.onChanged.
const response = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: prompt }]
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value);
chrome.storage.session.set({ partialResponse: text });
}
Full streaming event details, including how content_block_delta events are structured, are in /docs/streaming.
Content scripts and page context
If your extension needs to read the active page (summarize an article, extract text for a prompt), do that extraction in a content script, pass the extracted text to the background worker via chrome.runtime.sendMessage, and keep all Claude API calls in the background worker. Content scripts run in the page's context and are more exposed to interference from the page itself, so they shouldn't hold API credentials or make the actual network call.
Tool use for browser automation extensions
If your extension lets Claude take actions on the page — filling forms, clicking elements, navigating — model that as tool use rather than free-text parsing. Define tools for click_element, fill_input, read_dom, pass them in the tools array of your request, and execute the returned tool calls inside the content script. Details on the request/response shape are in /docs/tools.
Checklist before publishing
- No Anthropic or application key hardcoded in shipped source
host_permissionsscoped to only the domains you call- Streaming handled via side panel or background worker, not a popup that can close mid-stream
- Per-user keys stored in
chrome.storage.local, not synced storage, if they shouldn't leave the device - Rate limit and error handling for 429s surfaced to the user, not silently retried forever
A gateway layer like SubToAPI simplifies most of this because key issuance, revocation, and usage metadata are handled in a dashboard rather than custom backend code — see /pricing for plan details or /signup to generate a test key.
Questions
Can I call the Claude API directly from a content script without a backend? Technically yes if you add host permissions, but you'd need to embed a credential in the extension, which is extractable by any user. Use a background worker calling a proxy or gateway instead.
Does Manifest V3 block streaming responses? No. fetch with a readable stream works fine in a service worker; the challenge is UI lifecycle, since popups close on blur. Use a side panel or persist chunks to chrome.storage.session.
How do I let each user bring their own Claude access instead of sharing one key? Add an options page where users paste their own API key (e.g., a sub_live_... key from SubToAPI), store it with chrome.storage.local, and reference it in every request instead of a bundled credential.