●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Gemini API × Chrome Extension — Build a Browser-Native AI Assistant with Streaming and Conversation History
Learn how to build a Chrome Extension powered by Gemini API. Create a Manifest V3 side panel AI assistant with page summarization, translation, and code analysis features.
As an indie developer, I spend far more hours in the browser than I would like to admit. Reading articles, tracing code, hopping between docs in another language — and in those gaps the same wish keeps surfacing: "just summarize this part," or "tell me in one line what this code is doing."
Pasting into a separate AI chat tab every single time quietly breaks my focus. So I decided to let an AI live inside the browser itself, and built a side-panel assistant with the Gemini API and a Chrome Extension. What follows is that design and implementation — including the spots where I got stuck — shown as the working code I actually use day to day at Dolice.
This guide is for developers comfortable with basic JavaScript who want to try Chrome Extension development, or experienced extension authors looking to add AI features to an existing project.
Chrome Extension Architecture
A Chrome Extension built with Gemini API consists of three distinct layers, each with a specific responsibility.
Service Worker (Background Script): In Manifest V3, Service Workers replace the legacy Background Pages. All Gemini API HTTP requests are handled here, keeping your API key secure and isolated from page-level scripts.
Content Script: This script runs in the context of web pages, accessing the DOM to extract text content, detect code blocks, and highlight elements. It acts as a bridge between the page and the background.
Side Panel UI: Available since Chrome 114, the Side Panel provides a dedicated space for the chat interface and result display. All user interaction happens here, separate from the web page content.
This separation of concerns ensures API key security, clean DOM access, and a polished user experience — each handled by the right layer.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Building summarize, translate, and code-analysis into a single Manifest V3 side-panel UI
✦Streaming responses token-by-token with streamGenerateContent and a chrome.runtime port
✦Keeping conversation history in chrome.storage.session, trimmed to the last 20 turns
✦Lessons from real use: Service Worker idling, ~40% input-token savings, and 429 backoff
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
We'll use the gemini-2.5-flash model for our extension. The free tier is generous enough for personal use in a browser extension, but the exact per-minute and per-day request limits are revised from time to time. Check the current values in Google AI Studio, and design so that a lower ceiling won't break you (see the backoff and token-saving sections below).
Project Structure and Manifest V3 Configuration
Set up your project with the following directory structure:
A quick note on the permissions: sidePanel enables the side panel UI, activeTab grants access to the current tab's content, storage handles API key and conversation persistence, and contextMenus allows right-click integration.
Connecting to Gemini API in the Service Worker
The Service Worker handles all communication with the Gemini API. The API key is stored in chrome.storage.local to prevent exposure to content scripts or web pages.
// background.jsconst GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";// Retrieve API key securelyasync function getApiKey() { const result = await chrome.storage.local.get("geminiApiKey"); return result.geminiApiKey;}// Send request to Gemini APIasync function callGeminiApi(prompt, systemInstruction) { const apiKey = await getApiKey(); if (!apiKey) { throw new Error("API key not configured"); } const requestBody = { system_instruction: { parts: [{ text: systemInstruction }] }, contents: [ { parts: [{ text: prompt }] } ], generationConfig: { temperature: 0.7, maxOutputTokens: 2048 } }; const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }); if (!response.ok) { const error = await response.json(); throw new Error( `API error: ${error.error?.message || response.statusText}` ); } const data = await response.json(); return data.candidates[0].content.parts[0].text;}// Message listener setupchrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === "summarize") { callGeminiApi( request.content, "Summarize the following web page content clearly and concisely. " + "Organize the key points as bullet items, then provide " + "a one-sentence takeaway." ) .then((result) => sendResponse({ success: true, data: result })) .catch((err) => sendResponse({ success: false, error: err.message })); return true; // Enable async response } if (request.action === "translate") { callGeminiApi( request.content, `Translate the following text into ${request.targetLang}. ` + "Produce natural, readable output." ) .then((result) => sendResponse({ success: true, data: result })) .catch((err) => sendResponse({ success: false, error: err.message })); return true; } if (request.action === "analyzeCode") { callGeminiApi( request.content, "Analyze the following code. Explain what it does, highlight " + "good patterns, suggest improvements, and flag potential bugs " + "or security concerns." ) .then((result) => sendResponse({ success: true, data: result })) .catch((err) => sendResponse({ success: false, error: err.message })); return true; }});// Context menu setupchrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.create({ id: "gemini-summarize", title: "Summarize with Gemini", contexts: ["page"] }); chrome.contextMenus.create({ id: "gemini-analyze-selection", title: "Analyze selection with Gemini", contexts: ["selection"] });});chrome.contextMenus.onClicked.addListener(async (info, tab) => { if (info.menuItemId === "gemini-summarize") { chrome.tabs.sendMessage(tab.id, { action: "getPageContent" }); } if (info.menuItemId === "gemini-analyze-selection") { const result = await callGeminiApi( info.selectionText, "Analyze the selected text and organize the key points." ); chrome.runtime.sendMessage({ action: "showResult", data: result }); }});
Note the return true inside the chrome.runtime.onMessage.addListener callback — this is essential for keeping the message channel open while waiting for the async Gemini API response.
Extracting Page Content with Content Scripts
The Content Script extracts text from the current page and relays it to the Service Worker for processing.
// content.js// Extract primary text content from the pagefunction extractPageContent() { // Prefer article tag content const article = document.querySelector("article"); if (article) { return article.innerText.trim(); } // Fall back to main tag const main = document.querySelector("main"); if (main) { return main.innerText.trim(); } // Last resort: body text, capped at 8,000 characters const body = document.body.innerText; return body.substring(0, 8000).trim();}// Detect and extract code blocksfunction extractCodeBlocks() { const codeElements = document.querySelectorAll("pre code, .highlight code"); return Array.from(codeElements).map((el) => ({ language: el.className.replace("language-", ""), code: el.textContent.trim() }));}// Listen for messages from the backgroundchrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === "getPageContent") { const content = extractPageContent(); const title = document.title; const url = window.location.href; // Forward to side panel via background chrome.runtime.sendMessage({ action: "summarize", content: `Title: ${title}\nURL: ${url}\n\n${content}` }); } if (request.action === "getSelectedText") { const selection = window.getSelection().toString(); sendResponse({ text: selection }); } if (request.action === "getCodeBlocks") { const codeBlocks = extractCodeBlocks(); sendResponse({ codeBlocks }); }});
The extractPageContent() function uses a cascading priority: <article> first, then <main>, then <body>. This strategy filters out navigation bars, ads, and footer content, giving Gemini cleaner input for more accurate summaries.
Building the Side Panel Interface
The Side Panel provides the chat interface where users interact with the AI assistant.
Notice the distinction between chrome.runtime.sendMessage (communicates with the Service Worker) and chrome.tabs.sendMessage (communicates with Content Scripts). Mixing these up is a common source of bugs in extension development.
Fine-Tuning the Summarization Engine
Page summarization is the flagship feature. Here's how to use System Instructions to produce consistently high-quality summaries.
// System prompt definitions to add in background.jsconst SYSTEM_PROMPTS = { summarize: `You are an expert research assistant.Summarize the web page content in this format:**Overview**: 1-2 sentence summary**Key Points**: 3-5 bullet items**Takeaway**: One sentence on why this mattersIf technical terms appear, add brief clarifications.`, translate: `You are an experienced translator.Preserve the original nuance while producing natural outputin the target language. Return only the translation.`, analyzeCode: `You are a senior software engineer.Analyze the code and provide:**What It Does**: Overview of the code's purpose**Good Patterns**: Notable design decisions**Improvements**: Suggestions for performance or readability**Watch Out**: Potential bugs or security concerns`};
The maxOutputTokens is set to 2048 by default. For summarizing technical documentation where more detail is needed, consider increasing this to 4096.
Adding Translation and Code Analysis
The translation feature works with selected text, while code analysis auto-detects <pre><code> blocks on the page.
Here's an example of what Gemini returns when analyzing code on a GitHub page:
**What It Does**
An Express.js REST API server providing user authentication
and CRUD operations.
**Good Patterns**
Middleware pattern is used effectively, with authentication
logic properly separated.
**Improvements**
Adding a centralized error-handling middleware would reduce
code duplication across route handlers.
**Watch Out**
The bcrypt round count is set to 8, which is low for
production use. Consider 12 or higher.
Streaming the response so it appears gradually
Summaries and translations create a few seconds of silence while you wait for the full output. That silence is what "slow" actually feels like. By using streamGenerateContent instead of generateContent and rendering each fragment as it arrives, the time to the first character drops and the waiting feels far lighter.
The tricky part of streaming is the path from the Service Worker to the side panel. Since sendResponse can only reply once, it does not fit incremental updates. Use a long-lived port via chrome.runtime.connect instead.
// background.js — streaming responseconst STREAM_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent";async function streamGemini(prompt, systemInstruction, onChunk) { const apiKey = await getApiKey(); if (!apiKey) throw new Error("API key not configured"); const res = await fetch(`${STREAM_URL}?alt=sse&key=${apiKey}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ system_instruction: { parts: [{ text: systemInstruction }] }, contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: 0.7, maxOutputTokens: 2048 } }) }); if (!res.ok) throw new Error(`API error: ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE arrives as "data: {...}" lines; carry the incomplete trailing line over const lines = buffer.split("\n"); buffer = lines.pop(); for (const line of lines) { if (!line.startsWith("data:")) continue; const json = line.slice(5).trim(); if (!json) continue; try { const parsed = JSON.parse(json); const text = parsed.candidates?.[0]?.content?.parts?.[0]?.text; if (text) onChunk(text); } catch { // JSON split across lines is completed on the next chunk } } }}
The crucial line is buffer = lines.pop(). SSE data: lines sometimes arrive split at chunk boundaries, and unless you carry the last incomplete line over to the next read, JSON parsing fails constantly. I personally chased a "the second half goes missing sometimes" bug until I added exactly this line.
On the side-panel side, append each fragment to the bubble. Tweak the earlier addMessage so it returns the element it creates (add return div; at the end) so we can keep appending.
Now calling askStreaming(content, "summarize") from the summarize or translate buttons streams the generation straight onto the screen.
Keeping conversation history so context does not break
A one-off summary needs no history, but answering follow-ups like "make that shorter" or "expand the third point" requires including the prior exchange in contents. Store the history in chrome.storage.
// background.js — request with conversation historyasync function loadHistory(sessionId) { const key = `history:${sessionId}`; const r = await chrome.storage.session.get(key); return r[key] || [];}async function saveTurn(sessionId, role, text) { const key = `history:${sessionId}`; const history = await loadHistory(sessionId); history.push({ role, parts: [{ text }] }); // cap at the last 20 turns to avoid input-token bloat const trimmed = history.slice(-20); await chrome.storage.session.set({ [key]: trimmed }); return trimmed;}async function chatWithHistory(sessionId, userText) { const history = await saveTurn(sessionId, "user", userText); const apiKey = await getApiKey(); const res = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contents: history }) }); const data = await res.json(); const reply = data.candidates[0].content.parts[0].text; await saveTurn(sessionId, "model", reply); return reply;}
There is a reason for choosing chrome.storage.session here. session is an in-memory area cleared when the browser closes, while local persists to disk. Conversations easily pick up sensitive details from the page you are reading, so I recommend session as the default and writing to local only when the user explicitly saves.
Capping at 20 turns is a practical call too. Sending the entire old context every time inflates input tokens and makes responses slower and more expensive. For a summarization assistant, 20 turns was plenty for the continuity to feel seamless.
Holding down tokens and rate limits in practice
To keep enjoying the free tier, keeping each request small pays off. extractPageContent() already caps the body at 8,000 characters, but for summarization you can trim further up front. Keeping only headings and the opening of each paragraph cut input tokens by about 40% on long articles (measured across ten technical blog posts of my own).
// content.js — lightweight extraction for summariesfunction extractForSummary() { const root = document.querySelector("article") || document.querySelector("main") || document.body; const parts = []; root.querySelectorAll("h1, h2, h3, p").forEach((el) => { const t = el.innerText.trim(); if (!t) return; // keep the first two sentences of paragraphs; keep headings as-is parts.push(/^H[1-3]$/.test(el.tagName) ? t : t.split(".").slice(0, 2).join(".")); }); return parts.join("\n").slice(0, 6000);}
For rate limits, exponential backoff that assumes 429 is the standard move. Even in a small extension, adding it prevents the "hammer the button and it goes dead" failure.
// background.js — exponential backoffasync function withBackoff(fn, max = 4) { for (let i = 0; i < max; i++) { try { return await fn(); } catch (err) { const transient = /429|503/.test(String(err.message)); if (!transient || i === max - 1) throw err; const wait = Math.min(2 ** i * 1000, 8000); // 1s, 2s, 4s, 8s await new Promise((r) => setTimeout(r, wait)); } }}
Wrapping callGeminiApi in this withBackoff quietly absorbs transient 429 and 503 responses, while permanent errors (401 or 400) are re-thrown immediately so you never wait for nothing.
What I learned running this in daily use
Here are a few things I noticed using this on my own machine every day — points the docs do not spell out.
The Service Worker shuts down after tens of seconds of idle. A long summary that seems to "lose its connection" mid-way threw me off at first. The worker stays alive while a streaming port is open, but as insurance, keep the work idempotent — built so that if it does get cut off, pressing again converges on the same result.
Putting the API key in chrome.storage.local is only the minimum bar for isolating it from content scripts. On a shared machine it is not enough. I have settled on using this only on a personal device that assumes a password manager.
Always-on injection across <all_urls> adds a small hit to initial render on heavy pages. It is fine while the impact is tiny, but injecting on demand with chrome.scripting.executeScript is lighter — which is what running it myself day to day taught me. You rarely need it resident on every page from the start.
Debugging and Testing
Debugging Chrome Extensions involves a few specific workflows.
Loading the extension: Navigate to chrome://extensions, enable "Developer mode", and click "Load unpacked" to select your project folder.
Service Worker debugging: Click the "Service Worker" link on the extension's detail page to open DevTools. Use console.log and the Network tab to inspect API requests.
Content Script debugging: Open DevTools on any page and select the content script context from the Console panel dropdown.
Common errors to watch for:
Uncaught (in promise) Error: Could not establish connection — this happens when sending messages to tabs where Content Scripts haven't been injected (e.g., chrome:// URLs). Always check the tab URL before sending messages
net::ERR_INSUFFICIENT_RESOURCES — Service Workers go idle and terminate automatically. If API calls fail after idle periods, use chrome.alarms to periodically wake the worker
If you're new to the Gemini API, the Gemini API Quickstart Guide is a great place to build foundational knowledge. For those who prefer TypeScript, the Gemini API TypeScript Complete Beginner's Guide covers type-safe development patterns.
A browser-resident AI assistant is open-ended once the foundation is in place. The natural next step is to wire conversation history into real follow-up questions so it holds up across multiple pages. Once that works, voice input and richer right-click analysis of selected text can be added from the same building blocks.
Load it from chrome://extensions, and try running a page summary with streaming first. The moment that first character comes back, your everyday browser will feel a little smarter.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.