Chrome Extension AI Writing Assistant — Developer Guide
26 min readBuild an AI-Powered Writing Assistant Extension
A practical Chrome extension that helps users write better on any web page. It detects text fields, offers prompt templates (grammar, tone, conciseness), calls OpenAI or Claude APIs, streams suggestions in a side panel, and inserts improved text back into the page.
Uses @theluckystrike/webext-storage for all storage operations and
@theluckystrike/webext-messaging for inter-component communication.
Step 1: Manifest and Project Structure
{
"manifest_version": 3,
"name": "AI Writing Assistant",
"version": "1.0.0",
"description": "Improve your writing on any web page with AI suggestions.",
"permissions": ["activeTab", "sidePanel", "storage", "contextMenus"],
"background": { "service_worker": "background.js" },
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["content.js"]
}],
"side_panel": { "default_path": "sidepanel.html" },
"options_page": "options.html",
"icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }
}
- activeTab – temporary access to the current tab on user action
- sidePanel – persistent UI alongside the page
- storage – API key, templates, and usage data
- contextMenus – right-click “Improve Writing” on selected text
Step 2: Side Panel UI
Create sidepanel.html with a text input area, template buttons, and output display:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Writing Assistant</title>
<link rel="stylesheet" href="sidepanel.css">
</head>
<body>
<div id="app">
<header>
<h1>Writing Assistant</h1>
<span id="token-badge" title="Tokens used today">0 / 10,000</span>
</header>
<section id="input-section">
<label for="input-text">Your text:</label>
<textarea id="input-text" rows="6" placeholder="Paste or select text on the page..."></textarea>
</section>
<section id="template-section">
<label>Prompt template:</label>
<div id="template-buttons">
<button class="template-btn active" data-template="grammar">Fix Grammar</button>
<button class="template-btn" data-template="concise">Make Concise</button>
<button class="template-btn" data-template="formal">Formal Tone</button>
<button class="template-btn" data-template="casual">Casual Tone</button>
<button class="template-btn" data-template="custom">Custom</button>
</div>
<textarea id="custom-prompt" rows="2" placeholder="Custom instructions..." style="display:none;"></textarea>
</section>
<div id="action-bar">
<button id="improve-btn">Improve Writing</button>
<button id="insert-btn" disabled>Insert into Page</button>
</div>
<section id="output-section">
<label>Suggestion:</label>
<div id="output-text" class="output-area"></div>
</section>
<footer><a href="#" id="open-options">Settings</a></footer>
</div>
<script src="sidepanel.js"></script>
</body>
</html>
The token badge changes color at 80% (orange) and 100% (red) of the daily budget.
Template buttons toggle an active class and show/hide the custom prompt textarea.
Step 3: Content Script – Text Field Detection
content.js detects <textarea>, <input type="text">, and contenteditable
elements. It handles three message types:
(function () {
"use strict";
function getSelectedText() {
const sel = window.getSelection();
return sel ? sel.toString().trim() : "";
}
function replaceSelection(newText) {
const el = document.activeElement;
if (el && (el.tagName === "TEXTAREA" || (el.tagName === "INPUT" && el.type === "text"))) {
const start = el.selectionStart;
const end = el.selectionEnd;
el.value = el.value.substring(0, start) + newText + el.value.substring(end);
el.selectionStart = start;
el.selectionEnd = start + newText.length;
el.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
if (el && el.isContentEditable) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return false;
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(newText));
sel.collapseToEnd();
return true;
}
return false;
}
// @theluckystrike/webext-messaging pattern: structured message types
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
switch (message.type) {
case "GET_SELECTED_TEXT":
sendResponse({ text: getSelectedText() });
break;
case "INSERT_TEXT":
sendResponse({ success: replaceSelection(message.text) });
break;
default:
sendResponse({ error: "Unknown message type" });
}
return true;
});
// Notify extension when user selects text (debounced)
let timeout = null;
document.addEventListener("mouseup", () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
const text = getSelectedText();
if (text.length > 0) {
chrome.runtime.sendMessage({ type: "TEXT_SELECTED", text }).catch(() => {});
}
}, 200);
});
})();
The replaceSelection function dispatches an input event after modifying standard
inputs so frameworks like React detect the change. For contenteditable, it uses
the Selection/Range API to replace content at the cursor position.
Step 4: Context Menu – “Improve Writing”
Right-clicking selected text shows an “Improve Writing” option that opens the side panel with the selection pre-filled. This is registered in the background script:
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: "improve-writing",
title: "Improve Writing",
contexts: ["selection"]
});
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === "improve-writing" && info.selectionText) {
await chrome.sidePanel.open({ tabId: tab.id });
setTimeout(() => {
chrome.runtime.sendMessage({
type: "CONTEXT_MENU_TEXT",
text: info.selectionText
}).catch(() => {});
}, 500);
}
});
Step 5: Background Service Worker – API Calls
The full background.js handles context menus, API routing, and usage tracking.
It supports both OpenAI and Anthropic as configurable providers:
const PROMPT_TEMPLATES = {
grammar: "Fix all grammar, spelling, and punctuation errors. Return only the corrected text.",
concise: "Rewrite to be more concise while preserving meaning. Return only the rewritten text.",
formal: "Rewrite in a formal, professional tone. Return only the rewritten text.",
casual: "Rewrite in a casual, friendly tone. Return only the rewritten text.",
custom: ""
};
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({ id: "improve-writing", title: "Improve Writing", contexts: ["selection"] });
// @theluckystrike/webext-storage: initialize defaults
chrome.storage.local.get(["tokenBudget"], (data) => {
if (!data.tokenBudget) {
chrome.storage.local.set({ tokenBudget: 10000, tokensUsedToday: 0,
budgetDate: new Date().toDateString(), apiProvider: "openai" });
}
});
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === "improve-writing" && info.selectionText) {
await chrome.sidePanel.open({ tabId: tab.id });
setTimeout(() => {
chrome.runtime.sendMessage({ type: "CONTEXT_MENU_TEXT", text: info.selectionText }).catch(() => {});
}, 500);
}
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "IMPROVE_TEXT") {
handleImproveText(message).then(sendResponse).catch(e => sendResponse({ error: e.message }));
return true;
}
if (message.type === "GET_USAGE") {
getUsageData().then(sendResponse);
return true;
}
if (message.type === "RESET_USAGE") {
chrome.storage.local.set({ tokensUsedToday: 0, budgetDate: new Date().toDateString() });
sendResponse({ success: true });
}
});
async function getUsageData() {
return new Promise((resolve) => {
chrome.storage.local.get(["tokenBudget", "tokensUsedToday", "budgetDate"], (data) => {
if (data.budgetDate !== new Date().toDateString()) {
chrome.storage.local.set({ tokensUsedToday: 0, budgetDate: new Date().toDateString() });
resolve({ used: 0, budget: data.tokenBudget || 10000 });
} else {
resolve({ used: data.tokensUsedToday || 0, budget: data.tokenBudget || 10000 });
}
});
});
}
async function handleImproveText({ text, template, customPrompt }) {
const usage = await getUsageData();
if (usage.used >= usage.budget) throw new Error("Daily token budget exceeded. Adjust in Settings.");
const config = await new Promise(r => chrome.storage.local.get(["apiKey", "apiProvider"], r));
if (!config.apiKey) throw new Error("No API key configured. Open Settings to add your key.");
const prompt = template === "custom" ? customPrompt : (PROMPT_TEMPLATES[template] || PROMPT_TEMPLATES.grammar);
const result = config.apiProvider === "anthropic"
? await callAnthropic(config.apiKey, prompt, text)
: await callOpenAI(config.apiKey, prompt, text);
const tokens = Math.ceil((text.length + result.length) / 4);
const newUsed = usage.used + tokens;
await chrome.storage.local.set({ tokensUsedToday: newUsed });
return { improvedText: result, usage: { used: newUsed, budget: usage.budget } };
}
async function callOpenAI(apiKey, systemPrompt, userText) {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "system", content: systemPrompt }, { role: "user", content: userText }],
max_tokens: 2048, temperature: 0.3
})
});
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error?.message || `API error ${res.status}`); }
return (await res.json()).choices[0].message.content.trim();
}
async function callAnthropic(apiKey, systemPrompt, userText) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
body: JSON.stringify({
model: "claude-sonnet-4-20250514", max_tokens: 2048, system: systemPrompt,
messages: [{ role: "user", content: userText }]
})
});
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error?.message || `API error ${res.status}`); }
return (await res.json()).content[0].text.trim();
}
Step 6: Options Page – API Key Configuration
options.html lets users set their provider, API key, and daily token budget.
Keys are stored in chrome.storage.local, which is sandboxed to the extension.
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Settings</title><link rel="stylesheet" href="options.css"></head>
<body>
<div class="container">
<h1>Writing Assistant Settings</h1>
<section>
<h2>API Configuration</h2>
<label for="api-provider">Provider:</label>
<select id="api-provider">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic (Claude)</option>
</select>
<label for="api-key">API Key:</label>
<div class="key-row">
<input type="password" id="api-key" placeholder="sk-..." autocomplete="off">
<button id="toggle-key">Show</button>
</div>
<p class="hint">Stored locally, never sent to third parties.</p>
</section>
<section>
<h2>Token Budget</h2>
<label for="token-budget">Daily limit:</label>
<input type="number" id="token-budget" min="1000" max="1000000" step="1000" value="10000">
<div class="usage-row">
<span>Today: <span id="usage-count">0</span> / <span id="usage-budget">10,000</span></span>
<button id="reset-usage">Reset</button>
</div>
</section>
<button id="save-btn">Save Settings</button>
<span id="save-status"></span>
</div>
<script src="options.js"></script>
</body>
</html>
options.js – loads settings, saves them, and toggles key visibility:
document.addEventListener("DOMContentLoaded", () => {
const provider = document.getElementById("api-provider");
const keyInput = document.getElementById("api-key");
const toggleBtn = document.getElementById("toggle-key");
const budgetInput = document.getElementById("token-budget");
const usageCount = document.getElementById("usage-count");
const usageBudget = document.getElementById("usage-budget");
// @theluckystrike/webext-storage: load settings
chrome.storage.local.get(["apiProvider", "apiKey", "tokenBudget", "tokensUsedToday"], (d) => {
if (d.apiProvider) provider.value = d.apiProvider;
if (d.apiKey) keyInput.value = d.apiKey;
if (d.tokenBudget) budgetInput.value = d.tokenBudget;
usageCount.textContent = (d.tokensUsedToday || 0).toLocaleString();
usageBudget.textContent = (d.tokenBudget || 10000).toLocaleString();
});
toggleBtn.addEventListener("click", () => {
keyInput.type = keyInput.type === "password" ? "text" : "password";
toggleBtn.textContent = keyInput.type === "password" ? "Show" : "Hide";
});
document.getElementById("reset-usage").addEventListener("click", () => {
chrome.runtime.sendMessage({ type: "RESET_USAGE" }, () => { usageCount.textContent = "0"; });
});
document.getElementById("save-btn").addEventListener("click", () => {
const status = document.getElementById("save-status");
const settings = { apiProvider: provider.value, apiKey: keyInput.value.trim(),
tokenBudget: parseInt(budgetInput.value, 10) || 10000 };
if (!settings.apiKey) { status.textContent = "Enter an API key."; return; }
chrome.storage.local.set(settings, () => {
status.textContent = "Saved!";
setTimeout(() => { status.textContent = ""; }, 2000);
});
});
});
Step 7: Streaming Response Display
sidepanel.js wires everything together – template selection, API requests via
the background worker, and a character-by-character streaming animation:
document.addEventListener("DOMContentLoaded", () => {
const inputText = document.getElementById("input-text");
const outputText = document.getElementById("output-text");
const improveBtn = document.getElementById("improve-btn");
const insertBtn = document.getElementById("insert-btn");
const tokenBadge = document.getElementById("token-badge");
const customPrompt = document.getElementById("custom-prompt");
let activeTemplate = "grammar";
let lastResult = "";
// Template selection
document.querySelectorAll(".template-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelector(".template-btn.active")?.classList.remove("active");
btn.classList.add("active");
activeTemplate = btn.dataset.template;
customPrompt.style.display = activeTemplate === "custom" ? "block" : "none";
});
});
// Receive text from context menu or content script
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "CONTEXT_MENU_TEXT" || msg.type === "TEXT_SELECTED") {
inputText.value = msg.text;
}
});
// Improve button
improveBtn.addEventListener("click", async () => {
const text = inputText.value.trim();
if (!text) { outputText.textContent = "Enter or select text first."; return; }
improveBtn.disabled = true;
improveBtn.textContent = "Improving...";
insertBtn.disabled = true;
outputText.innerHTML = '<span class="cursor"></span>';
try {
const res = await chrome.runtime.sendMessage({
type: "IMPROVE_TEXT", text, template: activeTemplate,
customPrompt: customPrompt.value.trim()
});
if (res.error) { outputText.textContent = "Error: " + res.error; return; }
await streamText(res.improvedText);
lastResult = res.improvedText;
insertBtn.disabled = false;
if (res.usage) updateBadge(res.usage.used, res.usage.budget);
} catch (e) {
outputText.textContent = "Error: " + e.message;
} finally {
improveBtn.disabled = false;
improveBtn.textContent = "Improve Writing";
}
});
// Insert button -- sends improved text to content script
insertBtn.addEventListener("click", async () => {
if (!lastResult) return;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) return;
const res = await chrome.tabs.sendMessage(tab.id, { type: "INSERT_TEXT", text: lastResult });
insertBtn.textContent = res?.success ? "Inserted!" : "No active field";
setTimeout(() => { insertBtn.textContent = "Insert into Page"; }, 1500);
});
// Settings link
document.getElementById("open-options").addEventListener("click", (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
// Streaming text animation
async function streamText(text) {
outputText.textContent = "";
for (let i = 0; i < text.length; i += 3) {
outputText.textContent += text.slice(i, i + 3);
await new Promise(r => setTimeout(r, 12));
}
}
function updateBadge(used, budget) {
tokenBadge.textContent = `${used.toLocaleString()} / ${budget.toLocaleString()}`;
tokenBadge.classList.remove("warning", "exceeded");
if (used >= budget) tokenBadge.classList.add("exceeded");
else if (used >= budget * 0.8) tokenBadge.classList.add("warning");
}
// Load initial usage
chrome.runtime.sendMessage({ type: "GET_USAGE" }, (r) => {
if (r) updateBadge(r.used, r.budget);
});
});
For true streaming, use stream: true in the OpenAI request and forward
server-sent event chunks via chrome.runtime.sendMessage. The simulated approach
here keeps the code simpler while providing a similar UX.
Step 8: Insert Improved Text Back into the Page
The insertion flow:
- User clicks “Insert into Page” in the side panel.
- Side panel sends
INSERT_TEXTmessage to the content script viachrome.tabs.sendMessage. - Content script’s
replaceSelection()replaces the current selection in the active field. - An
inputevent is dispatched so React/Vue/Angular detect the change. - The side panel shows “Inserted!” or “No active field” feedback.
The content script handles <textarea>, <input type="text">, and contenteditable
elements. Shadow DOM and custom editors (e.g., CodeMirror, ProseMirror) may need
additional handling.
Step 9: Prompt Templates
Templates are defined in PROMPT_TEMPLATES in background.js. Adding a new
template requires two changes:
- Add the key and prompt to
PROMPT_TEMPLATES:
translate_es: "Translate to Spanish. Return only the translation."
- Add a button in
sidepanel.html:
<button class="template-btn" data-template="translate_es">Spanish</button>
No other code changes needed – the system is fully data-driven.
Step 10: Usage Tracking and Token Budget
Token estimation uses a simple heuristic (~1 token per 4 characters for English).
The daily budget resets automatically by comparing budgetDate to today’s date.
Storage layout (@theluckystrike/webext-storage):
| Key | Type | Description |
|---|---|---|
apiKey |
string | User’s API key |
apiProvider |
string | "openai" or "anthropic" |
tokenBudget |
number | Daily limit (default: 10000) |
tokensUsedToday |
number | Tokens consumed today |
budgetDate |
string | Date string for daily reset |
The budget is enforced in handleImproveText before any API call. The token badge
provides visual feedback: green (normal), orange (>80%), red (exceeded).
Testing
- Load unpacked at
chrome://extensions/with Developer mode enabled. - Open Settings, enter your API key (OpenAI or Anthropic).
- Navigate to any page with a text field.
- Select text and click “Improve Writing” in the side panel.
- Try the context menu: right-click selected text, choose “Improve Writing.”
- Click “Insert into Page” to replace the selection.
Troubleshooting:
- “No API key” – save a key in Settings.
- “Budget exceeded” – increase the limit or click Reset in Settings.
- Insert fails – ensure the text field is focused before clicking Insert.
Architecture
content.js sidepanel.js
(text detection, <---> (UI, templates,
selection, insert) streaming display)
^ |
| v
+-------- background.js -------+
(API proxy, usage tracking,
context menu, message routing)
|
v
OpenAI / Anthropic API
All messaging follows @theluckystrike/webext-messaging conventions with typed
message objects ({ type, ...payload }). All storage uses
@theluckystrike/webext-storage patterns via chrome.storage.local.
-e
—
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.