Building AI Agents as Chrome Extensions: Automate Browser Tasks with LLMs (2026)
The browser is the most used application on the planet, and most of the work that happens inside it is repetitive: filling forms, extracting data, navigating flows, clicking through UI. AI agents change that equation entirely. Instead of scripting rigid automation, you describe what you want and let a language model figure out the steps.
Chrome extensions sit at the perfect intersection of AI capability and browser access. They have persistent background processes, privileged DOM access across any tab, and direct communication with external APIs. They are, in effect, the ideal operating layer for an AI browser agent.
This guide walks through building a real AI-powered browser automation agent as a Chrome extension — from architecture to a working auto-fill example.
What Are AI Browser Agents?
A traditional browser automation script follows fixed instructions: click selector X, type value Y, wait for element Z. It breaks the moment a page changes layout, uses different class names, or adds a CAPTCHA.
An AI browser agent reasons about the page. It reads the DOM, understands the context, plans a sequence of actions, executes them, and adapts when something unexpected happens. The LLM acts as the brain — the extension provides the hands.
The core loop looks like this:
- Observe — capture page state (DOM snapshot, visible text, interactive elements)
- Plan — send the observation to an LLM with a task description, receive an action plan
- Act — execute DOM interactions based on the plan
- Verify — check the result and loop back if needed
Extensions are perfect hosts for this loop because they persist across navigation, can inject scripts into any page, and have access to Chrome APIs that normal web pages cannot touch.
Architecture: Three Layers Working Together
A well-structured AI agent extension separates concerns cleanly across three components.
┌─────────────────────────────────────────┐
│ Browser Tab (Page) │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Content Script │ │
│ │ - DOM observation │ │
│ │ - Element interaction │ │
│ │ - Page state serialization │ │
│ └──────────────┬───────────────────┘ │
└─────────────────┼───────────────────────┘
│ chrome.runtime messages
┌─────────────────▼───────────────────────┐
│ Background Service Worker │
│ - Task queue management │
│ - LLM API calls │
│ - Action orchestration │
│ - Session state │
└─────────────────┬───────────────────────┘
│ fetch / WebSocket
┌─────────────────▼───────────────────────┐
│ LLM API (OpenAI / Claude) │
│ - Task planning │
│ - DOM interpretation │
│ - Action generation │
└─────────────────────────────────────────┘Your manifest.json needs the right permissions to wire this together:
{
"manifest_version": 3,
"name": "AI Browser Agent",
"version": "1.0.0",
"permissions": [
"activeTab",
"scripting",
"storage",
"tabs"
],
"host_permissions": [
"https://api.openai.com/*",
"https://api.anthropic.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup.html"
}
}DOM Observation and Interaction Layer
The content script is your agent’s sensory system. Its job is to produce a structured snapshot of the current page that an LLM can reason about, and to execute actions the background worker sends back.
A naive approach — dumping the full DOM — will blow past any LLM context window immediately. Instead, extract a semantic summary: interactive elements with their labels, current values, and positions.
// content.js
function extractPageState() {
const interactive = [];
const selectors = 'input, textarea, select, button, a[href], [role="button"]';
document.querySelectorAll(selectors).forEach((el, index) => {
const rect = el.getBoundingClientRect();
// Skip off-screen elements
if (rect.width === 0 || rect.height === 0) return;
interactive.push({
id: index,
tag: el.tagName.toLowerCase(),
type: el.type || null,
name: el.name || el.id || null,
label: getLabel(el),
value: el.value || el.innerText?.slice(0, 100) || null,
placeholder: el.placeholder || null,
required: el.required || false,
visible: isVisible(el),
});
});
return {
url: window.location.href,
title: document.title,
interactive,
bodyText: document.body.innerText.slice(0, 2000),
};
}
function getLabel(el) {
// Check aria-label first
if (el.ariaLabel) return el.ariaLabel;
// Check associated label element
if (el.id) {
const label = document.querySelector(`label[for="${el.id}"]`);
if (label) return label.innerText.trim();
}
// Check placeholder or name as fallback
return el.placeholder || el.name || el.innerText?.slice(0, 50) || null;
}
function isVisible(el) {
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
}
// Execute actions from the background worker
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'GET_PAGE_STATE') {
sendResponse({ state: extractPageState() });
}
if (message.type === 'EXECUTE_ACTION') {
executeAction(message.action)
.then(result => sendResponse({ success: true, result }))
.catch(err => sendResponse({ success: false, error: err.message }));
return true; // async response
}
});
async function executeAction(action) {
const elements = document.querySelectorAll(
'input, textarea, select, button, a[href], [role="button"]'
);
const visibleEls = Array.from(elements).filter(el => {
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const target = visibleEls[action.elementId];
if (!target) throw new Error(`Element ${action.elementId} not found`);
switch (action.type) {
case 'fill':
target.focus();
target.value = action.value;
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
break;
case 'click':
target.click();
break;
case 'select':
target.value = action.value;
target.dispatchEvent(new Event('change', { bubbles: true }));
break;
default:
throw new Error(`Unknown action type: ${action.type}`);
}
return { elementId: action.elementId, type: action.type };
}Task Planning with LLM APIs
The background service worker is where intelligence lives. It receives the page state, calls the LLM with the user’s task, parses the response, and dispatches actions back to the content script.
The key to reliable LLM planning is a well-structured system prompt and asking for structured output (JSON). Here is an example using the OpenAI API:
// background.js
const SYSTEM_PROMPT = `You are a browser automation agent.
Given a page state (URL, title, interactive elements) and a user task,
return a JSON array of actions to complete the task.
Each action must follow this schema:
{
"type": "fill" | "click" | "select",
"elementId": <number from the elements list>,
"value": <string, only for fill and select>,
"reason": <brief explanation>
}
Rules:
- Only use elementId values that appear in the provided elements list
- For fill actions, provide realistic values matching the field context
- Prefer the most direct path to completing the task
- Return ONLY valid JSON, no markdown, no explanation outside the array`;
async function planActions(pageState, task, apiKey) {
const prompt = `Task: ${task}
Page: ${pageState.url}
Title: ${pageState.title}
Interactive elements:
${JSON.stringify(pageState.interactive, null, 2)}
Page context (first 1000 chars):
${pageState.bodyText.slice(0, 1000)}`;
const response = 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',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: prompt },
],
temperature: 0.1, // low temperature for deterministic actions
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
throw new Error(`LLM API error: ${response.status}`);
}
const data = await response.json();
const content = data.choices[0].message.content;
const parsed = JSON.parse(content);
// Handle both { actions: [...] } and raw array responses
return Array.isArray(parsed) ? parsed : parsed.actions || [];
}
// Main agent loop
async function runAgent(tabId, task) {
const { apiKey } = await chrome.storage.local.get('apiKey');
if (!apiKey) throw new Error('No API key configured');
// Step 1: Get page state from content script
const stateResponse = await chrome.tabs.sendMessage(tabId, { type: 'GET_PAGE_STATE' });
const pageState = stateResponse.state;
// Step 2: Plan actions with LLM
const actions = await planActions(pageState, task, apiKey);
// Step 3: Execute actions sequentially
const results = [];
for (const action of actions) {
await new Promise(resolve => setTimeout(resolve, 300)); // brief pause between actions
const result = await chrome.tabs.sendMessage(tabId, {
type: 'EXECUTE_ACTION',
action,
});
results.push(result);
if (!result.success) {
console.warn('Action failed:', action, result.error);
break;
}
}
return { actions, results };
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'RUN_AGENT') {
chrome.tabs.query({ active: true, currentWindow: true }, async ([tab]) => {
try {
const result = await runAgent(tab.id, message.task);
sendResponse({ success: true, result });
} catch (err) {
sendResponse({ success: false, error: err.message });
}
});
return true;
}
});Building a Simple Auto-Fill Agent
With the architecture in place, the popup UI to trigger the agent is straightforward:
<!-- popup.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { width: 320px; padding: 16px; font-family: system-ui; }
textarea { width: 100%; height: 80px; margin: 8px 0; box-sizing: border-box; }
button { width: 100%; padding: 10px; background: #2563eb; color: white;
border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
button:disabled { background: #94a3b8; }
#status { margin-top: 8px; font-size: 13px; color: #475569; }
</style>
</head>
<body>
<h3 style="margin: 0 0 12px">AI Browser Agent</h3>
<textarea id="task" placeholder="Describe what to do on this page...
Example: Fill the contact form with my name John Doe, email john@example.com, and message asking about pricing."></textarea>
<button id="run">Run Agent</button>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>// popup.js
document.getElementById('run').addEventListener('click', async () => {
const task = document.getElementById('task').value.trim();
if (!task) return;
const btn = document.getElementById('run');
const status = document.getElementById('status');
btn.disabled = true;
status.textContent = 'Planning actions...';
const response = await chrome.runtime.sendMessage({ type: 'RUN_AGENT', task });
if (response.success) {
const count = response.result.actions.length;
status.textContent = `Done. Executed ${count} action${count !== 1 ? 's' : ''}.`;
} else {
status.textContent = `Error: ${response.error}`;
}
btn.disabled = false;
});This is enough for a working prototype. Ask it to fill a contact form, and it will read the page, identify the fields, generate fill actions, and execute them — no selectors written by hand.
Safety: Permissions, Consent, and Rate Limiting
AI agents with DOM write access are powerful, which means they require deliberate safety design.
Minimal permissions. Use activeTab instead of <all_urls> host permissions wherever possible. activeTab only grants access to the tab the user is currently interacting with and only after they invoke the extension. This limits blast radius significantly.
Explicit user consent per run. Never run the agent automatically in the background without a clear user trigger. Every action sequence should start from a deliberate user action (clicking the extension button, confirming a prompt). Show the planned actions before executing them for higher-stakes tasks.
Rate limiting API calls. A runaway agent loop can burn through API quota fast. Implement a simple counter in storage.local:
async function checkRateLimit() {
const { callCount = 0, resetAt = 0 } = await chrome.storage.local.get(['callCount', 'resetAt']);
const now = Date.now();
if (now > resetAt) {
await chrome.storage.local.set({ callCount: 1, resetAt: now + 60_000 });
return true;
}
if (callCount >= 10) {
throw new Error('Rate limit: max 10 LLM calls per minute');
}
await chrome.storage.local.set({ callCount: callCount + 1 });
return true;
}Never transmit sensitive data without awareness. The page state you send to the LLM API can contain form values, including passwords if the user has already typed them. Scrub type="password" fields from your extraction before sending to any external API.
// In extractPageState(), filter password fields
if (el.type === 'password') return; // skip entirelySandbox LLM responses. Treat the action list from the LLM as untrusted input. Validate every action against your schema before executing it. Reject actions with out-of-range elementId values, unexpected type values, or suspiciously long value strings.
Real-World Use Cases
Form filling at scale. Developers testing multi-step registration flows, QA engineers validating form validation rules, and power users who fill the same contact forms repeatedly all benefit from an agent that understands the form semantically rather than by brittle CSS selectors.
Data extraction pipelines. An agent can navigate paginated search results, click into each item, extract structured data, and write it to a Google Sheet via the Sheets API — replacing hours of manual copying.
End-to-end testing assistant. Instead of maintaining Playwright scripts that break on every UI update, an AI agent reads the current DOM and figures out how to complete a flow. It is more expensive per run but dramatically cheaper to maintain.
Accessibility auditing. An agent can traverse a page systematically, attempt to interact with every control, and report back which elements lack proper labels, ARIA roles, or keyboard support — acting as a first-pass accessibility reviewer.
Intelligent reading modes. Rather than just stripping ads, an agent can understand article structure, extract the main content, generate a summary, and highlight the three most important paragraphs — all driven by LLM reasoning over the DOM.
What Comes Next
The architecture above is intentionally minimal. Production agents add:
- Multi-step planning with verification — after each action, re-observe the DOM and confirm the expected state change occurred before proceeding
- Memory across sessions — store user preferences and common form values in
storage.syncso the agent learns over time - Tool calling / function calling — expose DOM actions as structured tools in the LLM API call rather than parsing free-form JSON, which is more reliable with models that support it natively
- Vision models — send a screenshot alongside the DOM snapshot for pages that rely heavily on visual layout rather than semantic HTML
The browser has always been the primary interface for knowledge work. AI agents turn Chrome extensions from passive tools into active collaborators that understand goals, not just instructions. The foundation is simpler than it looks — a content script that reads and writes the DOM, a service worker that thinks, and an LLM API that plans. Start there, then layer in reliability and safety as you go.
Share this article
Build better extensions with free tools
Icon generator, MV3 converter, review exporter, and more — no signup needed.
Related Articles
I Built the Same Chrome Extension With 5 Different Frameworks. Here's What Actually Happened.
WXT vs Plasmo vs CRXJS vs Extension.js vs Bedframe. Real benchmarks, honest opinions, and the framework with 12K stars that's quietly dying.
5 Best Email Marketing Services to Grow Your Chrome Extension (2026)
Compare the top email marketing platforms for SaaS and Chrome extension developers. MailerLite, Mailchimp, Brevo, ActiveCampaign, and Drip reviewed.
15 Best Practices to Build a Browser Extension That Users Love (2026 Guide)
Master browser extension development in 2026. Manifest V3, security, performance, and UX best practices to build extensions users love.