DEV Community

Cover image for I Built a Chrome Extension to Track AI Token Usage — Here's How It Works
Anoop Kumar
Anoop Kumar

Posted on

I Built a Chrome Extension to Track AI Token Usage — Here's How It Works

Six weeks ago I got cut off mid-debugging session by Claude's rate limit with no warning. Two hours of context gone. I started looking for a tool that would show me how close I was before it happened. Nothing existed that worked across more than one platform without requiring an API key.

So I built one.

TokenPulse is a Chrome extension (MV3) that injects a live token bar above the input box on Claude, ChatGPT, Gemini, DeepSeek and Grok. It tracks context window usage, rate limits, cost estimates, and daily history — all from your existing browser session, no API key required.

Here's how it works technically.

Architecture overview

Content Scripts (per platform)
        ↓
Background Service Worker
        ↓
Chrome Storage API (local)
        ↓
Popup UI
        ↓
Desktop Notifications
Enter fullscreen mode Exit fullscreen mode

The extension runs a content script on each supported domain. Each script is responsible for:

  1. Reading token usage data from that platform
  2. Injecting the visual bar above the input box
  3. Sending data to the background service worker via chrome.runtime.sendMessage The service worker aggregates data, writes to chrome.storage.local, checks notification thresholds, and serves data to the popup on demand.

How Claude's rate limits are read

Claude is the only platform that exposes real rate limit data through its internal API. When you use claude.ai, the browser session makes requests to a usage endpoint that returns exact utilization percentages and reset timestamps.

The content script intercepts this data by hooking into the platform's network requests using a MutationObserver to detect when Claude updates its state, then reading the cached response.

The response looks roughly like:

{
  five_hour: {
    utilization: 0.82,
    reset_at: "2026-07-15T14:14:00Z"
  },
  seven_day: {
    utilization: 0.34,
    reset_at: "2026-07-21T21:00:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

This gives exact percentages — not estimates. The popup shows these directly.

Client-side token estimation for other platforms

ChatGPT, Gemini, DeepSeek and Grok don't expose usage data the same way. For these, TokenPulse estimates token usage from the conversation DOM.

The estimation approach:

function estimateTokens(text) {
  // ~4 characters per token — standard approximation
  return Math.ceil(text.length / 4)
}

function getConversationTokens() {
  const messages = document.querySelectorAll('[data-message-author-role]')
  let total = 0
  messages.forEach(msg => {
    total += estimateTokens(msg.textContent || '')
  })
  return total
}
Enter fullscreen mode Exit fullscreen mode

Accuracy is approximately ±8% — sufficient for knowing whether you're at 20% or 80% of your context window.

Injecting the token bar

The bar injection uses a MutationObserver to watch for the input box appearing in the DOM, then inserts a container element immediately above it.

const observer = new MutationObserver(() => {
  const inputBox = document.querySelector(PLATFORM_INPUT_SELECTOR)
  if (inputBox && !document.getElementById('tp-bar')) {
    injectBar(inputBox)
  }
})

observer.observe(document.body, {
  childList: true,
  subtree: true
})
Enter fullscreen mode Exit fullscreen mode

The bar is a thin div with a gradient fill that updates via CSS transition whenever new token data arrives:

function updateBar(pct) {
  const fill = document.getElementById('tp-bar-fill')
  if (fill) fill.style.width = `${pct}%`
}
Enter fullscreen mode Exit fullscreen mode

The CSS transition handles the smooth animation — no JavaScript animation loops needed.

Cost estimation

Cost is estimated by multiplying token count by current model pricing:

const PRICING = {
  'claude-sonnet-4': { input: 3.00, output: 15.00 },  // per 1M tokens
  'claude-opus-4':   { input: 15.00, output: 75.00 },
  'gpt-4o':          { input: 2.50, output: 10.00 },
  'gemini-2.0-flash':{ input: 0.10, output: 0.40 },
  'deepseek-v3':     { input: 0.27, output: 1.10 },
}

function estimateCost(tokens, model) {
  const price = PRICING[model]
  if (!price) return 0
  return (tokens / 1_000_000) * price.input
}
Enter fullscreen mode Exit fullscreen mode

Model detection reads the platform's model selector from the DOM.

The notification system

Notifications fire at configurable thresholds (default: 75%, 90%, 100%) using Chrome's notifications API. The threshold system is designed to fire once per crossing and reset when usage drops:

async function shouldNotify(key, currentPct, settings) {
  const thresholds = [75, 90, 100].filter(t => settings[`notify_${t}`])
  const crossed = thresholds.filter(t => currentPct >= t).pop() || 0
  const lastNotified = await getLastNotified()
  const last = lastNotified[key] || 0

  if (crossed <= 0 || crossed <= last) return null

  lastNotified[key] = crossed
  await saveLastNotified(lastNotified)
  return crossed
}
Enter fullscreen mode Exit fullscreen mode

This prevents notification spam — you get one notification when you cross 75%, another when you cross 90%, and they reset when your usage drops back below the threshold.

MV3 service worker constraints

Chrome's Manifest V3 requirement means the background script is a service worker — not a persistent background page. Service workers can be killed by Chrome at any time, which creates two constraints:

No persistent state in memory. Everything goes through chrome.storage.local. The service worker reads from storage on every message handler invocation rather than keeping state in variables.

Async message handling. The chrome.runtime.onMessage listener must handle the async/sync distinction carefully. Fire-and-forget messages (like saving usage data) return false immediately. Messages that need a response return true and call sendResponse after the async work completes.

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'SAVE_USAGE') {
    // Fire and forget — no response needed
    Storage.saveUsage(msg.usage)
    return false
  }

  if (msg.type === 'GET_ALL_DATA') {
    // Async response needed
    Promise.all([
      Storage.getUsage(),
      Storage.getHistory(),
      Storage.getSettings(),
    ]).then(([usage, history, settings]) => {
      sendResponse({ usage, history, settings })
    })
    return true // keep channel open
  }
})
Enter fullscreen mode Exit fullscreen mode

What I learned

MV3 CSP is strict. No inline scripts, no eval, no remote code execution. Every event handler must be attached via addEventListener — no onclick attributes. This caught me early and took a full debugging session to unpack.

Platform DOM structures change without notice. Claude, ChatGPT and Gemini update their frontends regularly. Selectors that work today break in a week. The solution is multiple fallback selectors and defensive ?. access throughout.

Service worker timing is unpredictable. Chrome can kill and restart the service worker between messages. Code that assumes the worker is alive from a previous message will fail intermittently and be very hard to debug.

Local storage is fast enough. I was worried chrome.storage.local would be too slow for real-time updates. In practice, reads complete in under 5ms and the popup feels instant.

The extension is open source at github.com/anu-ship-it/TokenPulse. If you're building a Chrome extension that reads from AI platforms, feel free to look at how the content scripts are structured.

Install TokenPulse free — works on Claude, ChatGPT, Gemini, DeepSeek and Grok with no API key.

Top comments (0)