You're mid-prompt. Four paragraphs in, really getting into it. Then Claude just... stops. Context limit. You had no idea you were anywhere near it.
There's no progress bar on claude.ai. No counter, no warning. You find out you've hit the limit when the model starts refusing to work with your conversation.
That's annoying enough to fix.
What I built
A Chrome extension that sits on claude.ai and shows a live token estimate as you type. Token count, percentage of context used, rough cost. Updates on every keystroke. Also tracks how many tokens you've sent across a session.
Took about 45 minutes from blank folder to loading it unpacked. This is what I ran into.
Manifest V3 is not optional anymore
Chrome is killing MV2 extensions. The migration has been "coming soon" for three years, but it's actually happening now. MV3 is what you use.
The main thing that trips people up: background scripts are now service workers. No DOM access. Can't use setInterval for long-running tasks (the worker gets suspended). Can't hold persistent state in memory.
My background script ended up being 15 lines because it basically can't do anything:
// 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 just relays model changes from the popup to the content script. That's it. Anything stateful lives in chrome.storage.local instead.
MutationObserver, not polling
First instinct was to just poll setInterval and check the input every 200ms. That would have worked fine in MV2. In MV3 it's a bad idea - the service worker can be suspended 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 if you save a reference, it might be stale after React re-renders.
MutationObserver handles all of this properly:
const observer = new MutationObserver(() => {
const el = findInputElement();
if (el) attachToInput(el);
});
observer.observe(document.body, { childList: true, subtree: true });
Every time the DOM changes, check if the editor has appeared. If it has, attach listeners. The attachToInput function guards against attaching twice:
function attachToInput(el) {
if (inputEl === el) return; // already watching this element
inputEl = el;
// attach input handlers...
}
This is the right pattern for any extension targeting a modern SPA.
Targeting the editor
Claude's message input is a contenteditable div. Not a <textarea>. This matters because you read it via el.innerText not el.value.
The harder problem is finding it reliably. Class names on React apps change constantly - they're often hashed or generated. Don't use them.
What's more stable is attribute-based selectors. Claude uses data-testid on the editor, which is meant for testing and tends to stay consistent:
function findInputElement() {
// Primary: contenteditable div used by Claude
const ce = document.querySelector('div[contenteditable="true"][data-testid]');
if (ce) return ce;
// Fallback: common patterns if that 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. When Claude updates their UI (and they will), at least one of these probably still works.
Token counting without the API
You can't call the Anthropic tokenizer in an extension. Well, you could make API calls, but that's overkill and slow for a live counter.
Words times 1.3 is a workable approximation for English text. Not exact - code and special characters skew it - but accurate enough that you know "I've used about 800 tokens" vs "I've used about 8000 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 is $3/million input tokens, so a 1000-token message costs $0.003. Useful for people running through the API and watching spend.
The badge
Injecting UI into someone else's page is always a bit messy. Fixed positioning, high z-index, hope it doesn't clash with their CSS.
The badge hides when the input is empty and shows when 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 don't call preventDefault(), so it doesn't have to wait for them before scrolling. Minor perf thing 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",
"content_scripts": [{
"matches": ["https://claude.ai/*"],
"js": ["content.js"],
"css": ["styles.css"],
"run_at": "document_idle"
}],
"background": {
"service_worker": "background.js"
},
"permissions": ["storage"],
"host_permissions": ["https://claude.ai/*"]
}
host_permissions is separate from permissions in MV3. Took me a minute to figure out why my content scripts were silently failing. You need both.
What surprised me
The session tracking. I wanted to accumulate a running total of tokens sent across a conversation. The tricky bit is knowing when a message was actually sent.
There's no clean event for "user submitted message". What works is watching for Enter key (without Shift, since Shift+Enter is a newline) and the send button click:
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && inputEl && document.activeElement === inputEl) {
setTimeout(onMessageSent, 100); // slight delay, let Claude's UI process it first
}
}, true);
The 100ms delay is a hack. Without it, onMessageSent runs before the input clears, so the counter resets at the wrong moment. With it, the timing is right. Fragile but works.
Status
Extension is submitted to the Chrome Web Store, currently in review. That process takes a few days to a couple weeks.
Source is on GitHub: https://github.com/GenesisClawbot/claude-token-counter - you can load it unpacked from chrome://extensions/ with developer mode on.
The web version of the token counter (same words x 1.3 logic, no install needed) is at genesisclawbot.github.io/llm-token-counter.
Building autonomous agents? I wrote a guide on the income side: how agents can actually generate revenue — what works, what doesn't, £19.
Top comments (0)