Build an AI Chrome Extension · 45 min · Anthropic Claude API · Chrome Service Worker
Objective
The service worker is where API calls happen — never the content script
Content Security Policy blocks a lot from content scripts -- calls go through the service worker
MV3's strict CSP restricts what content scripts (running inside arbitrary third-party pages) can do, including some network requests. The service worker is the context designed for this: it has the permissions, it's the one place that reads the stored API key, and it's the natural place to coordinate "get page text from content script → call AI → stream result to side panel," since it already sits between all the other contexts in Lesson 1's diagram.
Feature 1: summarize, with streaming
async function summarizePage(pageText: string, apiKey: string, onChunk: (text: string) => void) {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 500,
stream: true,
messages: [{ role: 'user', content: `Summarize this page in 3-4 sentences:\n\n${pageText.slice(0, 8000)}` }],
}),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Parse SSE "data: {...}" lines and extract text deltas -- see full
// implementation for the actual event parsing.
onChunk(chunk);
}
}
pageText.slice(0, 8000) matters here in a way that echoes Course 03's context-window lessons -- a long article easily exceeds what's worth sending for a 3-4 sentence summary, and capping it keeps both cost and latency reasonable. The streaming loop reads raw Server-Sent Events chunks from the response body; a full implementation parses each data: {...} line for content_block_delta events and calls onChunk with just the new text, which is what actually makes the side panel feel responsive instead of waiting several seconds for one big block of text.
Streaming from a service worker has a real limitation worth knowing
Service workers can be terminated by Chrome when idle, including potentially mid-stream on a slow response. For a course-scoped extension this is a rare edge case, not something to over-engineer around -- but if you notice a stream cutting off unexpectedly, this (not a bug in your parsing) is the first thing to check.
Feature 2 & 3: Q&A and structured extraction
Q&A reuses the same streaming pattern with a different prompt (the page text plus the user's question). Extraction is different: it needs a JSON output, not a streamed paragraph.
async function extractStructuredData(pageText: string, apiKey: string) {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1000,
messages: [{
role: 'user',
content: `Extract all prices, dates, names, and emails from this page as JSON: {"prices": [], "dates": [], "names": [], "emails": []}\n\n${pageText.slice(0, 8000)}`,
}],
}),
});
const data = await response.json();
return JSON.parse(data.content[0].text);
}
No stream: true here -- structured extraction needs the complete, valid JSON response before it's useful at all; a partial JSON object mid-stream is useless to JSON.parse. This is a deliberate choice per feature, not an inconsistency: streaming for prose the user reads progressively, a single complete response for data the code needs to parse.
Your "extract data" feature works well on product pages but on a plain text blog post with no prices, dates, names, or emails, it returns a JSON parse error instead of empty arrays. What's the likely cause?
Without explicit instruction, a model asked to "extract X" from a page with no X present may reasonably respond with something like "I don't see any prices on this page" instead of {"prices": [], ...} -- prose that isn't valid JSON. The fix is prompt-level: explicitly instruct the model to always return the full JSON structure with empty arrays for anything not found, never prose, which is the same "ask for the schema explicitly" lesson from Course 06's planning engine.
Fact-check: reuses the selected-text message from lesson 3
The 4th feature calls GET_SELECTION (built in Lesson 3) to get whatever text the user has highlighted on the page, then prompts the model to evaluate the claim's accuracy — using the same streaming pattern as Summarize.
What you're building
ai_features.ts: all 4 features (summarize, Q&A, extract, fact-check) wired to the popup's buttons through the service worker -- sharing a streamCompletion helper for the 3 streamed features and a completeJson helper for extraction -- tested on real pages, verified that summarize/Q&A/fact-check stream progressively into the side panel, and extraction correctly returns empty arrays (not errors) on pages with nothing to extract.