Skip to content

Building an AI-Powered Chrome Extension with Claude and TypeScript

Chrome extensionsMay 13, 2026

Scrape in the content script, call Claude from the service worker, stream results to the popup — same pattern for most AI extensions.

Building an AI-Powered Chrome Extension with Claude and TypeScript

AI-powered extensions are the highest-value category on the Chrome Web Store right now. Page summarizers, writing assistants, code explainers — they all follow the same pattern: scrape page content, send it to an LLM, display the result.

This post builds a complete page summarizer extension using Claude's API. You'll learn where to put the API call (service worker, not popup), how to stream responses, and how to handle the tricky parts like token limits and error states.

Prerequisites: Familiarity with extension architecture, message passing, and a basic TypeScript setup.

Architecture Decision: Where Does the API Call Live?

The API call goes in the service worker, not the popup. Two reasons:

  1. Popups close. If the user clicks away while waiting for a response, the popup is destroyed and the fetch is lost. The service worker keeps running (briefly, but long enough for an API call).
  2. API key security. The service worker's environment is not inspectable by the user via the page's DevTools. Content scripts run inside web pages where malicious page JavaScript could potentially access globals.

The flow:

Popup ──"SUMMARIZE_PAGE"──► Service Worker ──fetch──► Claude API
                                 │
Content Script ◄──"GET_CONTENT"──┘
     │
     └── extracts page text, sends back to service worker

Step 1: Get Page Content via Content Script

The content script extracts readable text from the current page:

// src/content.ts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "GET_CONTENT") {
    const content = extractPageContent();
    sendResponse(content);
  }
  return true;
});

function extractPageContent(): { title: string; text: string; url: string } {
  // Get main content, skip nav/footer/sidebar
  const article = document.querySelector("article, main, [role='main']");
  const container = article ?? document.body;

  // Extract text, strip excessive whitespace
  const text = container.innerText
    .replace(/\n{3,}/g, "\n\n")
    .trim()
    .slice(0, 12000); // ~3k tokens, safe for Claude

  return {
    title: document.title,
    text,
    url: window.location.href,
  };
}

The 12,000 character limit is a pragmatic choice. Claude handles much longer inputs, but most pages have useful content well within this range. Adjust based on your API cost tolerance.

Step 2: Call Claude from the Service Worker

// src/background.ts
import type { ExtensionMessage } from "./types/messages";

const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages";

chrome.runtime.onMessage.addListener(
  (message: ExtensionMessage, sender, sendResponse) => {
    if (message.type === "SUMMARIZE_PAGE") {
      handleSummarize(message.payload.tabId)
        .then(sendResponse)
        .catch((err) => sendResponse({ error: err.message }));
      return true;
    }
  }
);

async function handleSummarize(
  tabId: number
): Promise<{ summary: string } | { error: string }> {
  // 1. Get page content from content script
  const content = await chrome.tabs.sendMessage(tabId, {
    type: "GET_CONTENT",
  });

  if (!content?.text) {
    return { error: "Could not extract page content" };
  }

  // 2. Get API key from storage
  const { apiKey } = await chrome.storage.local.get("apiKey");
  if (!apiKey) {
    return { error: "API key not configured. Set it in extension options." };
  }

  // 3. Call Claude
  const response = await fetch(CLAUDE_API_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": apiKey,
      "anthropic-version": "2023-06-01",
      "anthropic-dangerous-direct-browser-access": "true",
    },
    body: JSON.stringify({
      model: "claude-sonnet-4-5-20250514",
      max_tokens: 1024,
      messages: [
        {
          role: "user",
          content: `Summarize this web page in 3-5 bullet points. Be concise and focus on key takeaways.\n\nTitle: ${content.title}\nURL: ${content.url}\n\nContent:\n${content.text}`,
        },
      ],
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    return { error: `API error: ${response.status}${error}` };
  }

  const data = await response.json();
  return { summary: data.content[0].text };
}

API Key Storage

Store the API key in chrome.storage.local — never hardcode it. Add an options page where users enter their own key:

// src/options.ts
const form = document.getElementById("api-form") as HTMLFormElement;
const input = document.getElementById("api-key") as HTMLInputElement;

// Load existing key
chrome.storage.local.get("apiKey").then(({ apiKey }) => {
  if (apiKey) input.value = apiKey;
});

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  await chrome.storage.local.set({ apiKey: input.value.trim() });
  document.getElementById("status")!.textContent = "Saved!";
});

This approach means users bring their own API key. For a free extension, this is the simplest model — no backend, no auth, no costs on your side.

Store the key with typed storage wrappers if you want type safety on the schema.

The anthropic-dangerous-direct-browser-access Header

Calling the Claude API directly from a browser context (service worker) requires this header. It acknowledges that the API key is stored client-side. For a published extension where each user provides their own key, this is acceptable. For a SaaS product, proxy through your backend instead.

Step 3: Display Results in the Popup

// src/components/Summarizer.tsx
import { useState } from "react";
import { sendMessage } from "../utils/messaging";

export function Summarizer() {
  const [summary, setSummary] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSummarize() {
    setLoading(true);
    setError(null);

    const [tab] = await chrome.tabs.query({
      active: true,
      currentWindow: true,
    });

    if (!tab.id) {
      setError("No active tab");
      setLoading(false);
      return;
    }

    const result = await sendMessage({
      type: "SUMMARIZE_PAGE",
      payload: { tabId: tab.id },
    });

    if ("error" in result) {
      setError(result.error);
    } else {
      setSummary(result.summary);
    }
    setLoading(false);
  }

  return (
    <div style={{ width: 400, padding: 16 }}>
      <button onClick={handleSummarize} disabled={loading}>
        {loading ? "Summarizing..." : "Summarize This Page"}
      </button>

      {error && <p style={{ color: "red" }}>{error}</p>}
      {summary && (
        <div style={{ marginTop: 12, whiteSpace: "pre-wrap" }}>
          {summary}
        </div>
      )}
    </div>
  );
}

Step 4: Streaming Responses (Optional but Better UX)

For longer responses, streaming shows text as it generates. Use chrome.runtime.connect for a long-lived connection between popup and service worker:

// popup.tsx — open a port for streaming
const port = chrome.runtime.connect({ name: "summarize" });

port.postMessage({ type: "SUMMARIZE_STREAM", tabId });

port.onMessage.addListener((msg) => {
  if (msg.type === "CHUNK") {
    appendToSummary(msg.text);
  } else if (msg.type === "DONE") {
    setLoading(false);
  } else if (msg.type === "ERROR") {
    setError(msg.message);
  }
});
// background.ts — stream chunks back
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== "summarize") return;

  port.onMessage.addListener(async (msg) => {
    const content = await chrome.tabs.sendMessage(msg.tabId, {
      type: "GET_CONTENT",
    });
    const { apiKey } = await chrome.storage.local.get("apiKey");

    const response = await fetch(CLAUDE_API_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-api-key": apiKey,
        "anthropic-version": "2023-06-01",
        "anthropic-dangerous-direct-browser-access": "true",
      },
      body: JSON.stringify({
        model: "claude-sonnet-4-5-20250514",
        max_tokens: 1024,
        stream: true,
        messages: [
          {
            role: "user",
            content: `Summarize this page concisely:\n\n${content.text}`,
          },
        ],
      }),
    });

    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 events — extract text deltas
      const lines = chunk.split("\n").filter((l) => l.startsWith("data: "));
      for (const line of lines) {
        const data = JSON.parse(line.slice(6));
        if (data.type === "content_block_delta") {
          port.postMessage({ type: "CHUNK", text: data.delta.text });
        }
      }
    }
    port.postMessage({ type: "DONE" });
  });
});

Manifest for the Summarizer

{
  "manifest_version": 3,
  "name": "Page Summarizer",
  "version": "1.0.0",
  "action": { "default_popup": "src/popup.html" },
  "background": { "service_worker": "src/background.ts" },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["src/content.ts"]
    }
  ],
  "permissions": ["activeTab", "storage", "scripting"],
  "host_permissions": ["https://api.anthropic.com/*"],
  "options_page": "src/options.html"
}

Note host_permissions includes only the API endpoint — not <all_urls>. Content scripts have their own matches field. See the permissions guide for why this distinction matters.

Production Considerations

Rate limiting. Add a cooldown between requests. Users will spam the button.

Caching. Store summaries in chrome.storage.local keyed by URL. Don't re-summarize the same page.

Error handling. 401 (bad key), 429 (rate limit), 500 (API down) — surface each differently in the UI.

Cost. Claude Sonnet on a 3k-token input + 1k-token output ≈ $0.01 per summary. Users with their own keys pay this directly. If you proxy through a backend, watch your bill.

What's Next

  1. Ship the extensionHow to Publish a Chrome Extension to the Web Store covers the full checklist.
  2. Build faster with AI toolsI Built a Chrome Extension Using Claude Code in One Weekend shows the workflow for scaffolding entire extensions with AI.
  3. See a real productHow I Built ResistGate covers the full journey from idea to published extension.

This is part of the Chrome Extensions from Zero to Product series, where I walk through everything I learned building ResistGate and Amethyst — from first popup to published product.

Notes from the build

Get more AI engineering insights

Follow the work: AI tools, browser products, product decisions, and honest lessons from the build.

By subscribing, you agree to receive Orlando's emails. No spam. Unsubscribe anytime.

Building an AI-Powered Chrome Extension with Claude and TypeScript | Orlando Ascanio