You are three tabs deep into a research rabbit hole and every article is 2,000 words of preamble around one useful paragraph. A browser extension that reads the current page and hands you a five-bullet summary fixes that. The pieces are small, and Manifest V3 gives you everything you need without a build step.
This is a deliverable-focused build. By the end you will have a working extension with a manifest, a background service worker, and a side panel that summarizes whatever tab you are on. The one part everyone underestimates is turning a live page into text an LLM can actually reason over, so we will spend real time there.
What We're Actually Building
Three files do the work:
manifest.jsondeclares permissions and wires up the UI.- A side panel (or popup) with a "Summarize" button and a place to show the result.
- A background service worker that grabs the page content, cleans it, and calls the LLM.
The flow is: click the button, get the active tab's URL, convert that page to clean markdown, send the markdown to a model, stream the summary back into the panel. No frameworks required.
The Manifest
Manifest V3 is stricter than V2, but for this the config is short. Note side_panel, the activeTab permission, and host_permissions for whatever APIs you call.
{
"manifest_version": 3,
"name": "Page Summarizer",
"version": "1.0.0",
"permissions": ["activeTab", "sidePanel", "scripting"],
"host_permissions": ["https://api.link.sc/*", "https://api.anthropic.com/*"],
"background": { "service_worker": "background.js", "type": "module" },
"side_panel": { "default_path": "panel.html" },
"action": { "default_title": "Summarize this page" }
}
One line of setup lets clicking the toolbar icon open the panel:
// background.js
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
Getting Clean Content Is the Hard Part
Here is where most tutorials wave their hands. You have two options for getting the page text, and they are not equal.
Option one: read the DOM directly. You inject a content script and grab document.body.innerText. This works, and it is free, but you get everything: the nav bar, the cookie banner, the "related articles" rail, the footer with 200 links. You are paying tokens for all of it, and the model has to wade through the noise to find the article. For pages rendered entirely client-side you at least get the hydrated text, which is a genuine advantage.
Option two: fetch the URL and convert to clean markdown. Instead of scraping the messy DOM, you send the URL to a fetch API that strips the chrome and returns just the content as markdown. This is what I reach for, because the quality of the summary is downstream of the quality of the input, and markdown is the format models reason over best. If you want the full reasoning on why raw HTML is a bad input, we wrote it up in the best way to convert HTML to markdown for LLMs.
Here is option two with link.sc, which folds fetch, JavaScript rendering, and markdown conversion into one call:
// background.js
async function getPageMarkdown(url) {
const res = await fetch("https://api.link.sc/v1/fetch", {
method: "POST",
headers: {
"x-api-key": "lsc_your_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({ url, format: "markdown" }),
});
const { content } = await res.json();
return content;
}
The nice side effect: because the fetch happens server-side, it renders JavaScript and handles pages that a plain innerText grab would return empty. If you have hit that wall before, scraping JavaScript-rendered websites covers why the pre-hydration HTML is so often useless.
Calling the LLM
With clean markdown in hand, the summary call is boring in the best way. This example uses Claude via the Messages API. We stream the response so the summary appears as it is generated instead of after a long pause.
// background.js
async function summarize(markdown) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": "sk-ant-your_key_here",
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [{
role: "user",
content: `Summarize this page in five bullets, then one sentence on who should read it.\n\n${markdown}`,
}],
}),
});
const data = await res.json();
return data.content.find((b) => b.type === "text").text;
}
Tie the two together behind the button:
// panel.js
document.getElementById("go").addEventListener("click", async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const out = document.getElementById("output");
out.textContent = "Reading the page...";
const md = await getPageMarkdown(tab.url);
out.textContent = "Summarizing...";
out.textContent = await summarize(md);
});
That is the whole extension. Load it via chrome://extensions with developer mode on, click "Load unpacked," and you have a summarizer on every tab.
The Objection You Should Raise Before Shipping
"Wait, my API key is sitting in the extension source." Correct, and this matters. For a personal tool that never leaves your machine, a key in the code is fine. The moment you consider publishing to the Chrome Web Store, that key is readable by anyone who unpacks your extension, and they will spend your quota.
The fix is a thin proxy. Point the extension at a small serverless function you own (a Cloudflare Worker, a Lambda), keep the real keys as server-side secrets, and have the proxy forward the request. Your extension ships with a URL, not a credential. This also lets you add per-user rate limits and swap providers without republishing.
A few smaller things worth handling:
- Long pages blow the context window. A 30,000-word page will not fit. Either truncate to the first N thousand tokens or chunk and summarize the chunks, then summarize the summaries. Token optimization for feeding web data to LLMs walks through the tradeoffs.
- Restricted pages.
chrome://pages, the Web Store, and some corporate intranets cannot be read. Detect the URL scheme and show a friendly message instead of a stack trace. - PDFs and paywalls. A server-side fetch handles many of these more gracefully than a DOM grab, but do not expect miracles behind a hard login wall.
Where to Take It
The build above is the floor, not the ceiling. Add a dropdown for summary length. Add a "explain like I'm five" mode. Cache summaries by URL so re-opening a tab is instant. Or skip the raw HTTP calls entirely and route the whole thing through an MCP server so the same tool works in Claude and Cursor too, which we set up in connect Claude to the web with the link.sc MCP server.
The core lesson holds no matter how far you take it: clean input first, clever prompt second. Get the page into good markdown and even a short prompt gives you a summary worth reading.
Want clean markdown from any URL in one call to power your extension? Grab a free link.sc API key and start with 500 credits a month.