Saltar al contenido

Crear una extensión de Chrome con Claude y TypeScript

Extrae el contenido de la página, coordina la petición en el worker y muestra el resultado en la interfaz.

Extensiones de Chrome3 min de lectura

Ingeniero de software. Desarrollo extensiones de Chrome, aplicaciones web y herramientas de IA aplicada.

Contenido

Los resumidores de páginas, asistentes de escritura y explicadores de código suelen compartir un patrón: extraer contenido, enviarlo a un modelo y mostrar el resultado.

Esta guía construye un resumidor con la API de Claude. Veremos dónde coordinar la petición, cómo transmitir resultados y cómo manejar límites y errores. Es un ejemplo de arquitectura, no una garantía de que los identificadores de modelo históricos sigan disponibles.

Necesitas conocer la arquitectura de extensiones, los mensajes entre componentes y la configuración básica de TypeScript.

Dónde coordinar la llamada a la API

En este diseño la coordina el service worker, por dos motivos:

  1. El popup se cierra. Una petición ligada a su documento puede interrumpirse al cerrarlo. El worker separa el trabajo de la interfaz, aunque también puede terminar o agotar tiempos de espera. Las tareas largas necesitan estado persistido y recuperación.
  2. Separación de responsabilidades. La red queda fuera del contexto de la página. El usuario puede inspeccionar el worker: no oculta una clave del desarrollador. Los scripts de contenido tienen variables aisladas por defecto, pero comparten el DOM. Conserva las credenciales de tu servicio en un backend.

El flujo:

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

Paso 1: extraer el contenido de la página

El script de contenido obtiene texto legible:

// 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,
  };
}

El límite de 12 000 caracteres es una decisión práctica del ejemplo. Ajústalo según la tarea, el modelo y el presupuesto. Informa al usuario cuando recortes contenido relevante.

Paso 2: consultar Claude desde el 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 };
}

Dónde guardar la clave del ejemplo

Este ejemplo usa una clave aportada por el usuario. chrome.storage.local es almacenamiento del cliente, no una caja fuerte. Limita el acceso a contextos de confianza y no incluyas una clave propia del desarrollador en el paquete.

La página sencilla de opciones:

// 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!";
});

Así cada persona asume su consumo de API. Evita un backend propio para esta demostración, pero exige explicar la exposición de la clave y qué texto se enviará al proveedor.

Puedes usar wrappers de almacenamiento tipado para concretar el esquema. Los tipos no cifran ni protegen la clave.

Qué significa anthropic-dangerous-direct-browser-access

La petición directa del ejemplo incluye esta cabecera para reconocer el acceso desde el cliente. No convierte la clave en secreta. Si usas credenciales del desarrollador, haz la petición desde tu backend. Anthropic desactiva por defecto el acceso desde navegadores en su SDK por ese riesgo; consulta sus requisitos.

Paso 3: mostrar el resultado en el 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>
  );
}

Paso 4: transmitir la respuesta de forma progresiva

Para respuestas largas, mostrar fragmentos mientras llegan puede mejorar la espera. Usa un puerto entre el popup y el 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" });
  });
});

El manifiesto del resumidor

{
  "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"
}

host_permissions incluye el destino de la API. Los scripts declarados tienen sus propios patrones matches; esos patrones también implican acceso a páginas y pueden generar avisos. Revisa la guía de permisos.

Qué revisar antes de producción

Límites de uso. Añade un intervalo entre solicitudes y controles en el servidor si financias el consumo.

Caché. Guarda resultados si procede para no resumir la misma página repetidamente. Considera también que el contenido de una URL puede cambiar.

Errores. Distingue una clave inválida (401), un límite de peticiones (429) y un fallo del proveedor (500).

Coste y disponibilidad del modelo. Calcula el coste con las tarifas del modelo elegido. El identificador de los ejemplos es histórico: sustitúyelo por uno disponible y prueba el comportamiento. Si la clave es del usuario, paga él; si usas un proxy, necesitas límites propios.

Cómo continuar

  1. Prepara la tienda con la guía de publicación.
  2. Revisa el proceso de construir una extensión con Claude Code en un fin de semana.
  3. Lee las decisiones de un producto completo en cómo construí ResistGate.

Esta nota forma parte de la serie Extensiones de Chrome: de cero a producto, donde explico lo que aprendí construyendo ResistGate y Amethyst, desde el primer popup hasta la publicación.

Un pequeño voto de confianza

¿Valió la pena leer esta nota?