DEV Community

Cover image for Giving an AI Agent a Sense of What It Spends
JOOJO DONTOH
JOOJO DONTOH

Posted on

Giving an AI Agent a Sense of What It Spends

Intro

Friends and family, its me again. Last time I rambled about BYOC. Today I want to talk about something that bit me in the wallet which is tokens, the cost and what I built to help me clarify this for Alfred.

Before we start I'd like to briefly define a few things around tokens

  • A token is a chunk of text. Its easy to just assume a token is a word or a letter but it is somewhere in between. "cat" might be one token, "tokenisation" might be three. Models do not read characters, they read these chunks, so the token is the unit everything is measured and billed in.

  • For an LLM, tokens are both the input and the output. Your prompt gets turned into input tokens, the model's reply comes back as output tokens, and the two are usually priced differently, with output costing more. Everything the model "sees" and "says" is counted this way.

  • Token usage gets fuzzy fast. You cannot eyeball it from the text, because the same sentence tokenises differently on different models. (Opus 4.7 and later even shipped a new tokenizer that can produce up to 35% more tokens for the same fixed text.) Then prompt caching adds cached reads and cache writes that are priced on their own scale, retries quietly double a call, and a single "agent" turn can fan out into many calls under the hood. So "how many tokens did that cost me" is rarely a number you can guess.

  • The Claude API itself is refreshingly blunt about it. You send your messages to the model, you get a reply back, and that reply carries the token counts already filled in: the input tokens, the output tokens, and the cache figures. You do not have to count anything. The model hands you the receipt with every response.

  • And the billing is pure pay-as-you-go. There is no monthly bucket of "requests" like a SaaS seat. Every token is metered, priced per million tokens, and added to your bill. Cost scales directly and linearly with usage, which is wonderful when you are small and terrifying the first time something runs in a loop.

Which brings me to how I learned this properly. I have a personal agent, Alfred, and one of the things he does is classify my incoming email. At some point I thought it would be neat to backfill. So to basically point the classifier at my inbox and have it label everything, going back about eight years. I wrote a little scheduler, set the start date to roughly 2018, and walked away feeling clever. Nah bro

That job churned quietly through eight years of email, one classification call at a time, and it cost me around thirty dollars a day until I happened to open the billing dashboard and see the line going up like a staircase. Nothing was broken. The code did exactly what I told it to. I just had no visibility into what "classify everything since 2018" actually meant in tokens until the money had already left.

That is the whole reason for this article.

Problem

The annoying part is that the information exists. It is just never where I need it.

Token visibility

On the fixed subscription plans, tokens are easy to see. If you use Claude Code, you run /usage in the CLI and you get a genuinely nice breakdown:

Current session     ████████░░░░░░  5% used   · resets 4pm (Asia/Kuala_Lumpur)
Current week        ███████████░░░  13% used  · resets Jun 30, 1pm

What's contributing to your limits usage?
  89% of your usage was at >150k context
  41% came from subagent-heavy sessions
  24% came from sessions active for 8+ hours
  21% was while 4+ sessions ran in parallel
Enter fullscreen mode Exit fullscreen mode

A few things worth knowing about that view:

  1. It is fed by a local stats cache. Claude Code keeps a usage/stats file on your machine that refreshes periodically against a rolling cut-off, then renders these numbers. It is explicitly best-effort and local, it says so itself: it is based on sessions on this machine and does not include your other devices or claude.ai. So it is a snapshot, not a ledger.

  2. It shows limits, not money. The subscription plans are a different pricing model from the API. There is no dollar figure in /usage, because on a fixed plan you are spending against rate limits, not against a per-token bill. What it gives you is rate-limit and quota headroom, which is the thing that actually matters on that plan.

  3. The API is the mirror image. On the API you are spending real money per token, and you can see usage and rate-limit information, but it lives on the Console dashboard. For a long time none of it was reachable programmatically at all.

Now the honest update, because this moved while I was building. Anthropic has since shipped an Admin Usage & Cost API (the /v1/organizations/usage_report/messages and /v1/organizations/cost_report endpoints). But it does not close my gap, for three reasons:

  • It is organisation-level and aggregated, bucketed by workspace, API key, model and service tier. It does not know what a "feature" is inside my app, so it can tell me Workspace X spent money on Haiku, but not that the email classifier did.
  • It is delayed and admin-gated. It needs an Admin key, and that key is a team or enterprise feature, on a plain individual account you cannot even create one without converting your whole organisation to a team first, and the data lands in time buckets, not the instant a call returns. It is built for FinOps reporting, not for an agent watching its own spend in near real time.
  • There is still no prices API. Even with the cost endpoint, if I want to compute cost myself, per call, the actual per-model prices are not something I can fetch as structured data. They live on a pricing page meant for humans.

Cost

The cost story is the same shape. The real dollar figures live on the Console dashboard (and now, in aggregate, behind the Admin cost endpoint). That is fine for a monthly finance review. It is useless for what I actually want, which is:

cost that sits right next to Alfred, attributable per feature and per call, with best-effort precision, so I can see at a glance which of his background flows are quietly burning money, the way the eight-year email backfill did.

Nobody is going to hand me that. So I built it.

Solution

The design lives in two diagrams I drew while figuring this out: a high-level "idea" map, and a clean-architecture map of how it actually wires together. Let me synthesise both here.

The idea, in six steps

Strip away the architecture and the whole thing is a short pipeline. Each call to the model flows left to right:

  1.call   ──►  2.capture   ──►  3.price   ──►  4.append  ──►  5.aggregate ──►  6.show
  the AI        the tokens       tokens         one row        by model /        a dashboard
                from the         × current      append-only    feature /         view, or just
                response's       price,         never edited   time, when        ask Alfred
                receipt          frozen on      (local sqlite) asked             "how much
                                 the record                                       this month?"
Enter fullscreen mode Exit fullscreen mode

Two of those steps carry all the weight, so they get their own rule later: capture has to happen at exactly one place, and price has to be frozen at the moment of the call, not recomputed later.

Keeping the price honest

Step 3 says "current price", and that is the part that keeps all of ALfred's usage cost meaningful. Prices change. As a concrete example, in March 2026 Anthropic removed the old long-context premium that doubled the rate past 200K input tokens. If your cost tracker had that 2x baked in as a constant, every number it produced after that date was wrong.

Since there is no prices API, the design fetches and refreshes prices on its own little loop:

   weekly tick ─┐
                ├─►  fetch the public      ─►  LLM structures   ─►  validate /
   POST refresh ┘    pricing page (text)       it into data         sanity-check
                                                                         │
                                                          ok ───────────►│
                                                                         ▼
                                                              replace the price cache
                                                                         ▲
                                          on ANY error ──► keep last good prices
                                                            (never break tracking)
Enter fullscreen mode Exit fullscreen mode

The choice to parse the pricing page with the LLM rather than a regex is deliberate because a pricing page is a moving target of tables and footnotes, and a brittle scraper breaks the week they reword a heading. Handing the raw text to the model and asking for clean structured data is reactive instead of fragile. Then you validate the result, because an LLM can hallucinate a number, and if validation fails you fall back to the last good table. The refresh runs weekly on a schedule and on demand when I ask.

Three principles worth keeping

Everything else is implementation detail, but these three are the load-bearing walls:

  1. Capture at one choke point. Wrap the AI call in a single function that every flow goes through, and do the recording there. If recording is automatic, you can never forget to log a call, and "forgot to log it" is how you end up blind to an eight-year backfill.

  2. Freeze cost when you record. Store the dollar figure at the instant of the call, computed against the price that was live then. Past spend must stay correct even after prices change. You are writing history, not a formula to be re-evaluated.

  3. Fail safe. A broken price refresh must never break cost tracking. Worst case, you keep pricing slightly stale and carry on. The log keeps filling either way.

The shape: ports and adapters

The clean-architecture diagram is just those principles given a skeleton. It is a hexagonal layout, the application core depends only on its own ports (interfaces), and the messy outside world (SQLite, the Anthropic API, the pricing page) plugs in as adapters that implement those ports. The dependencies point inward.

  INTERFACE         HTTP usage-handler            chat tools (Alfred's hands)
  agent-server      GET  /usage/summary/:month    api_usage_summary
                    GET  /usage/trends            api_usage_trends
                    GET  /usage/prices            refresh_api_prices
                    POST /usage/prices/refresh
                          │  calls
                          ▼
  APPLICATION       use cases   GetUsageStats          RefreshPricing
  pure, no IO       ports       UsageReaderPort        PricingStorePort
                                UsageRecorderPort      PricingSourcePort   LlmPort
                          ▲  implemented by (dependency inversion)
                          │
  INFRASTRUCTURE    SqliteUsageLog            SqlitePricingStore (+ bundled fallback)
  adapters          recordUsage + computeCost  AnthropicPricingExtractor
                    AnthropicLlmAdapter        Worker scheduler (weekly tick)
                          │  talks to
                          ▼
  EXTERNAL          api_usage_log   model_pricing   Anthropic Messages API   pricing page
                    (sqlite)        (sqlite)
Enter fullscreen mode Exit fullscreen mode

And three flows move through it, which is the clearest way to read the diagram:

  • Capture flow: a call goes through the choke point, cost is frozen, one row lands in the usage log.
  • Read flow: the dashboard or a chat tool asks for stats, the read use case pulls the log through the reader port and aggregates.
  • Pricing-refresh flow: the weekly tick (or a manual refresh) runs the pricing job, which fetches, structures, validates and swaps the price cache.

Let me walk through the parts that matter, in plain terms.

The ports

The whole core is defined by a handful of contracts, plain promises about what must be possible, with nothing said about how. Nothing here knows SQLite or HTTP exists. It really comes down to two shapes and five small capabilities.

The two shapes:

  • a usage record, which is one logged call: when it happened, which model, which feature label I gave it, the four token counts (input, output, cache read, cache write), the dollar cost frozen at that moment, and a note of which price table produced that cost.
  • a model price, which is the four per-million-token rates for one model (input, output, cache read, cache write) plus a version stamp.

The five capabilities, each one a slot the outside world plugs into:

  • record one usage row.
  • read every row between two dates.
  • price store, which can hand back the current price for a model instantly, and replace the whole table in one go.
  • price source, which can fetch the raw human pricing page as text.
  • structure, which turns that messy text into clean data. That last one is the LLM's job.

That is the entire core. Everything below is just something filling one of those slots, and because the core only ever talks to the slots, I can swap what fills them without touching it. I did exactly that three times while building this, scraper, then LLM, then I looked hard at the official Admin API, and the core never moved.

The choke point (capture + freeze)

This is principle 1 and principle 2 in one place. Every flow in Alfred calls the model through one shared function, never the raw SDK, so recording can never be skipped. The important detail is the feature label that gets passed in, that single string is what later lets me say "the classifier cost X". In pseudo-code:

to call the model (model, feature, messages):
    response = ask the model for a reply
    price    = current price for this model
               (or the bundled fallback, if we have not fetched one yet)

    append one usage row:
        when    = now
        model, feature
        tokens  = the counts from the response's receipt
                  (input, output, cache read, cache write)
        cost    = those tokens priced against `price`, frozen right here
        version = which price table we used

    return the response
Enter fullscreen mode Exit fullscreen mode

And the cost itself is just arithmetic, no cleverness: take each token count, divide by a million, multiply by that token type's per-million rate, and add the four together. The only thing that makes it trustworthy is when it happens, against the price that was live at that instant, written down once and never recomputed.

The log (append-only) and the price table

SQLite is more than enough for this. There are two small tables.

The usage log is write-once, one row per call, never edited. Each row holds an id, a timestamp, the model, the feature label, the four token counts, the frozen cost, and the price version that produced it. It is indexed by time and by feature, because those are the only two ways I ever slice it.

The price table is tiny, one row per model with the four per-million rates plus a version and a fetched-at time. It is not append-only like the log, it gets thrown away and rewritten wholesale every time prices refresh, so it always reflects the latest known prices and nothing older.

Adding it up

Because cost is already frozen per row, "where is my money going this month" is one boring lookup. In plain English: take every row inside this month, group them by feature, and for each feature add up the cost, add up the tokens, and count the calls, then sort with the most expensive feature on top. That is the whole thing. There is no pricing maths at read time, just sums, because the dollar figure is already sitting in every row.

That single lookup is what would have caught my backfill on day one: the email classifier sitting at the top of the list with a number climbing by thirty dollars a day.

The pricing refresh (fail-safe)

This is the loop from earlier, spelled out. The one rule that matters is the catch-all at the bottom, any failure keeps the last good prices.

to refresh pricing:
    try:
        raw        = fetch the public pricing page as text
        structured = ask the LLM to turn it into clean data
        table      = validate and sanity-check that structure
        if the table came back empty, stop and keep what we had
        replace the price cache, all at once, with the new table
        report success

    on ANY error:
        log a warning, keep the last good prices, report failure
Enter fullscreen mode Exit fullscreen mode

The whole design hangs off that last branch. A bad fetch, a reworded page, a hallucinated number that fails the sanity-check, none of it touches the prices already in the store. The ledger keeps filling either way, just against slightly older prices until the next good refresh.

Giving Alfred his own eyes

The last move is what makes this fun. The same read-the-stats path is exposed as a tool the model can call, so Alfred can read his own ledger when I ask him in plain language.

There is barely anything to it. The tool is described to him in one sentence, return this month's API cost and tokens, broken down by feature and model, for an optional month, and when he decides to use it, the call runs straight through the very same read path the dashboard uses: read the log, aggregate, answer. Same numbers, reached a different way.

Now "Alfred, how much have you cost me this month and on what?" is a question he can actually answer, from the same numbers the dashboard shows, sitting right where I already am.

Advantages and benefits

Building this out gave me more than a number on a screen.

  • Cost becomes attributable by functionality. Because every call is tagged with a feature, I can see that classification is cheap per call but runs constantly, while planning is rare but expensive per call. That tells me exactly which flows to tune, cache harder, or move to a smaller model, instead of guessing.

  • The numbers are accurate on purpose, not by luck. Prices are refreshed on a schedule and the cost is computed deterministically and frozen at the moment of the call. There is no fuzzy estimate and no constant that silently goes stale when Anthropic changes a rate. History stays true even as the present moves.

  • Alfred can introspect. Exposing the stats as tools the model can call means the agent can analyse its own usage in conversation, and in time, learn from it. The data is already in a shape he can reason over.

  • It opens the door to self-regulation. Once an agent can read its own spend, it can act on it. The natural next step is letting Alfred flag himself, or throttle a flow, when he notices he is burning money faster than expected. A live backfill that crosses a threshold could pause itself and ask me, rather than running for days.

  • Cost introspection becomes a first-class sense. Most agents are blind to what they cost. Giving Alfred a running, queryable feel for his own spend makes cost something he is aware of while he works, not a bill that arrives later.

  • And the foundation compounds. A clean, append-only ledger with per-feature attribution and honest pricing is the kind of base layer you build a lot of things on. Budgets, alerts, anomaly detection, per-flow optimisation, learned cost models. The possibilities genuinely feel open-ended now that the bones are in place.

Conclusion

The short version of all this: tokens are the unit, cost rides on tokens, and the API bills you for every one of them in real time. The information you need to stay sane about that exists, but it is scattered across a CLI that shows limits not money, a dashboard you have to go and look at, and an Admin API that aggregates at the org level after the fact. None of it sits where an agent actually works.

So I built the missing piece: capture every call at one choke point, freeze its cost against freshly-fetched prices, append it to a local SQLite ledger that is never edited, and read it back by feature, by model, over time, from both a dashboard and Alfred's own tools. The architecture is just three principles wearing a hexagon, capture in one place, freeze on write, fail safe on pricing.

It started because a clever little eight-year email backfill quietly cost me thirty dollars a day while I had no idea. It ended with an agent that can tell me what he costs and, soon, decide to do something about it himself.

As always, this is my read and my build, not gospel. If you have wired up something similar and made different calls, I would like to hear it.

Top comments (1)

Collapse
 
marrouchi profile image
Med Marrouchi

Great post, we need to be aware of token usage cause it can bring costs to spiral in a unpredicted way.