I built a Chrome extension for Claude in 45 minutes (here's what I learned)

Published: (March 3, 2026 at 05:17 AM EST)
6 min read
Source: Dev.to

Source: Dev.to

What I built

A Chrome extension that sits on claude.ai and shows a live token estimate as you type. It displays:

  • Token count
  • Percentage of context used
  • Rough cost

The badge updates on every keystroke and also tracks how many tokens you’ve sent across a session.

TL;DR – Built in ~45 minutes from a blank folder to a working unpacked extension.


Manifest V3 is not optional anymore

Chrome is killing Manifest V2 extensions. The migration has been “coming soon” for three years, but it’s actually happening now. Manifest V3 is what you have to use.

The main thing that trips people up: background scripts are now service workers. They have no DOM access, can’t use setInterval for long‑running tasks (the worker gets suspended), and can’t hold persistent state in memory.

My background script ended up being only 15 lines because it basically can’t do anything else:

// background.js – service worker
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type === 'MODEL_CHANGED') {
    chrome.tabs.query({ url: 'https://claude.ai/*' }, (tabs) => {
      tabs.forEach((tab) => {
        chrome.tabs.sendMessage(tab.id, msg);
      });
    });
    sendResponse({ ok: true });
  }
});

It simply relays model‑change messages from the popup to the content script. Anything stateful lives in chrome.storage.local instead.

MutationObserver, not polling

The first instinct is to poll with setInterval and check the input every 200 ms. That would have worked fine in MV2, but in MV3 it’s a bad idea—the service worker can be suspended at any time, and setInterval in a content script feels wrong when there’s a better way.

claude.ai is a React app. The message input gets mounted and unmounted as you navigate between conversations. If you grab the element on load, it might not exist yet, and a saved reference can become stale after a re‑render.

MutationObserver handles this cleanly:

const observer = new MutationObserver(() => {
  const el = findInputElement();
  if (el) attachToInput(el);
});

observer.observe(document.body, { childList: true, subtree: true });

Every time the DOM changes, we check whether the editor has appeared and, if so, attach listeners. attachToInput guards against double‑attaching:

let inputEl = null;

function attachToInput(el) {
  if (inputEl === el) return; // already watching this element
  inputEl = el;
  // attach input handlers…
}

This pattern works for any extension targeting a modern SPA.

Targeting the editor

Claude’s message input is a contenteditable div, not a textarea. You read its contents via el.innerText, not el.value.

Finding it reliably is the tricky part. Class names in React apps change constantly—they’re often hashed or generated—so don’t rely on them. Instead, use attribute‑based selectors. Claude adds a data-testid attribute to the editor, which is meant for testing and tends to stay stable:

function findInputElement() {
  // Primary: contenteditable div used by Claude
  const ce = document.querySelector('div[contenteditable="true"][data-testid]');
  if (ce) return ce;

  // Fallbacks: common patterns if the primary selector breaks
  const fallback = document.querySelector(
    'div[contenteditable="true"].ProseMirror,' +
    'div[contenteditable="true"][class*="composer"],' +
    'div[contenteditable="true"][placeholder]'
  );
  return fallback || document.querySelector('div[contenteditable="true"]');
}

Multiple fallbacks, least‑specific last, give the extension a better chance of surviving UI changes.

Token counting without the API

You can’t call the Anthropic tokenizer directly from a Chrome extension. Making API calls would be overkill and too slow for a live counter.

A simple approximation works well for English text: words × 1.3. It isn’t exact—code snippets and special characters skew it—but it’s accurate enough to differentiate “≈ 800 tokens” from “≈ 8 000 tokens”, which is the whole point.

function countTokens(text) {
  if (!text || !text.trim()) return 0;
  const words = text.trim().split(/\s+/).length;
  return Math.round(words * 1.3);
}

For the popup I also show a rough cost estimate. Claude 3.5 Sonnet costs $3 / million input tokens, so a 1 000‑token message costs about $0.003. Handy for anyone watching their API spend.

The badge

Injecting UI into someone else’s page is always a bit messy. I use fixed positioning, a high z-index, and hope it doesn’t clash with the site’s CSS.

The badge hides when the input is empty and shows as soon as you start typing. It updates on both input and keyup events because input doesn’t always fire on programmatic changes:

el.addEventListener('input', handler, { passive: true });
el.addEventListener('keyup', handler, { passive: true });

passive: true tells the browser these handlers won’t call preventDefault(), so scrolling isn’t blocked. A minor perf win, but worth the habit.

The manifest

The whole extension setup is about 25 lines:

{
  "manifest_version": 3,
  "name": "Claude Token Counter",
  "version": "1.0.0",
  "description": "Live token counter for claude.ai",
  "permissions": ["storage", "tabs"],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "content_scripts": [
    {
      "matches": ["https://claude.ai/*"],
      "js": ["content.js"],
      "css": ["styles.css"],
      "run_at": "document_idle"
    }
  ],
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

That’s it! The extension works, respects MV3 constraints, and gives you a live view of how many tokens you’ve spent on Claude. Happy prompting!

Chrome Extension Manifest (MV3) – Alternative Example

{
  "manifest_version": 3,
  "name": "Claude Token Counter",
  "description": "Shows token usage for Claude AI chats.",
  "version": "0.1",
  "icons": {
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "content_scripts": [
    {
      "matches": ["https://claude.ai/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "background": {
    "service_worker": "background.js"
  },
  "permissions": ["storage"],
  "host_permissions": ["https://claude.ai/*"]
}

Note: host_permissions is separate from permissions in Manifest V3. I spent a minute figuring out why my content scripts were silently failing—both are required.

Fullscreen Controls (Demo)

Enter fullscreen mode
Exit fullscreen mode

What Surprised Me

Session Tracking

I wanted to accumulate a running total of tokens sent across a conversation. The tricky part is knowing when a message is actually sent.

There’s no clean event for “user submitted message.” The workaround is to watch for the Enter key (without Shift, since Shift + Enter creates a newline) and the send‑button click:

document.addEventListener(
  'keydown',
  (e) => {
    if (
      e.key === 'Enter' &&
      !e.shiftKey &&
      inputEl &&
      document.activeElement === inputEl
    ) {
      // Slight delay, let Claude's UI process it first
      setTimeout(onMessageSent, 100);
    }
  },
  true
);

Why the 100 ms Delay?

The delay is a hack. Without it, onMessageSent runs before the input clears, causing the counter to reset at the wrong moment. With the delay, the timing lines up correctly. It’s fragile but works.

Status

  • Extension: Submitted to the Chrome Web Store; currently under review (process can take a few days to a couple of weeks).
  • Source Code: You can also load it unpacked via chrome://extensions/ with Developer Mode enabled.
  • Web Version: Same token‑counting logic (words × 1.3) without any installation required.
0 views
Back to Blog

Related posts

Read more »