I use six AI tools every day. Claude, ChatGPT, Codex, Cursor, Grok, Perplexity. Each one has limits, and each one hides them somewhere different. So I built a Chrome extension to pull all of it into one view.
The idea was trivial. The implementation taught me three things about Manifest V3 that I wish someone had put in a blog post. So here is that post.
The extension is called AI Karma Tracker. This is not a sales pitch, it is a build log. The link is at the bottom if you want it.
The naive version, and why it failed instantly
My first mental model was simple. A service worker wakes up on an alarm, fetches each provider's usage endpoint, parses the JSON, updates storage. Clean, centralized, easy to test.
Here is roughly what I wrote:
// background.ts (the version that did not work)
async function pollCursor() {
const res = await fetch("https://www.cursor.com/api/usage", {
credentials: "include",
});
return res.json();
}
It returned 401. Every time.
I spent an embarrassing amount of time assuming my endpoint was wrong. It was not. The request was unauthenticated, even though I was clearly logged into Cursor in the same browser.
Lesson one: MV3 service workers do not carry your SameSite cookies
Here is the part that is easy to miss. In Manifest V3, your background logic runs in a service worker. When that worker makes a cross site fetch, the request originates from the extension context, not from the provider's own tab.
Most of these usage endpoints are protected by session cookies marked SameSite=Lax or SameSite=Strict. Those cookies are attached when the request looks like it comes from the site itself. A fetch fired from an extension service worker does not meet that bar, so the cookie is silently omitted, and the server hands you a 401.
credentials: "include" does not save you here. The cookie is not being blocked by CORS. It is simply never being sent.
The fix is to stop fetching from the worker and start fetching from where the cookies actually live: a content script running in the page, in the same origin as the provider.
// content script, injected on the provider's domain
// runs in the page's origin, so first-party cookies ride along
const res = await fetch("/api/usage", { credentials: "include" });
const data = await res.json();
chrome.runtime.sendMessage({ type: "USAGE", provider: "cursor", data });
Same request, completely different result, because now it is a first party call. The 401 disappeared the moment I moved the fetch into the page.
Lesson two: the tab is on cursor.com, but your code says www.cursor.com
This one cost me a second evening.
After moving the fetch into the content script, one provider still failed intermittently. The pattern made no sense until I looked at the actual tab. Sometimes the user is on cursor.com, sometimes on www.cursor.com. If I hardcode the host, a relative or absolute mismatch turns a first party request back into a cross origin one, and the cookie drops again.
So I stopped hardcoding hosts and started deriving the origin from wherever the code is actually running:
private base(): string {
try {
if (typeof location !== "undefined" && /(^|\.)cursor\.com$/.test(location.hostname)) {
return location.origin;
}
} catch {
// not in a page context, fall through
}
return "https://www.cursor.com";
}
The rule I took away: in an extension, never assume the host. Read location.origin from the context you are in and build your URLs from that. The difference between cursor.com and www.cursor.com is the difference between 200 and 401.
Lesson three: you can gate a paid feature without running a server
I wanted a Pro tier, but I did not want to run a backend just to check licenses. Every server is a thing that can go down, leak, or cost money while I sleep.
I sell through Gumroad, and Gumroad has a license verification endpoint. So the extension talks to it directly. No server of mine in the middle.
const res = await fetch("https://api.gumroad.com/v2/licenses/verify", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
product_id: PRODUCT_ID,
license_key: key,
increment_uses_count: "false",
}),
});
const { success, purchase } = await res.json();
Two gotchas worth knowing if you go this route.
First, for any product created after early 2023, Gumroad expects product_id, not the old product_permalink. The permalink path silently stops matching and you will chase a phantom bug.
Second, "the key is valid" is not the same as "the subscription is active". You have to inspect the purchase object yourself and treat it as inactive if it was refunded, chargebacked, disputed, or cancelled. A valid key on a refunded membership should not unlock anything.
To avoid hitting the network on every popup open, I cache the result locally with an HMAC signature so the cached verdict is tamper evident. Verify occasionally, trust the signed cache in between.
The shape that actually worked
Put together, the architecture inverted from my first sketch. The service worker is no longer the thing that talks to providers. It coordinates. The content scripts, living in each provider's origin, are the ones that actually read usage, because they are the only place the cookies work.
Provider tab, content script, first party fetch, message back to the worker, worker writes to chrome.storage.local, popup renders. Everything that touches a session stays inside that session's origin. Nothing about a user's usage ever leaves their machine, which is also a nice privacy property to get for free out of the correct design.
If you want to poke at it
The extension is live on the Chrome Web Store: AI Karma Tracker
Project site with the rest of the details: Karma Tracker
If you are building anything that reads first party data from inside MV3, I hope the three lessons above save you the two evenings they cost me. And if you have hit a cleaner pattern for the SameSite cookie problem, I would genuinely like to hear it in the comments.
Top comments (0)