How 25 AI Features Cost Me $9/Month (And How I Fixed It)

Two features accounted for 88% of my LLM costs. Finding them required data, not intuition.

How 25 AI Features Cost Me $9/Month (And How I Fixed It)

My personal life dashboard, Life Hub, has accumulated 25+ features that call Claude under the hood: daily briefs, an advisory council, geopolitical conflict tracking, correlation interpretation, anomaly detection, sentiment analysis, topic extraction, causal inference, and more. Each feature seemed cheap in isolation. A few hundred tokens here, a Claude Haiku (Anthropic's smallest, cheapest model) call there. Then I ran out of API credits.

Finding the Problem

Life Hub tracks every LLM call in an LLMUsage table: feature name, model, input tokens, output tokens, timestamp. I'd built it for observability but never actually looked at it closely until the bill forced my hand.

30 days of data told the story:

  • 7.7 million tokens across 870 calls
  • Estimated cost: ~.67/month
  • Two features accounted for 88% of the spend

The culprits weren't what I expected.

Culprit #1: Conflict Refresh, .85/month (67%)

Life Hub has a geopolitical conflict tracker. It fetches RSS feeds, runs them through Claude to extract relevant headlines, and maps them to tracked regions. The MCP tool refresh_conflicts had no staleness check. It just ran the full pipeline every time it was called.

The problem: the chat agent. With 151 MCP tools available, the chat agent would call refresh_conflicts whenever a user question seemed even tangentially related to world events. Each call fetched RSS feeds and made an LLM analysis pass. 373 calls in 30 days.

The fix was a staleness guard:

async def refresh_conflicts(session, *, force: bool = False) -> dict:
    if not force and not await is_stale(session, max_age_days=1):
        logger.info("[conflicts] Data is fresh (< 24h) — skipping refresh")
        return {"headlines_fetched": 0, "skipped": "fresh"}
    # ... actual refresh logic

The is_stale() check queries a GeoConflictRefresh timestamp table. If the last refresh was less than 24 hours ago, it returns cached data. The force=True parameter lets manual user actions (HTMX button, REST API) bypass the guard. Only the automated/chat paths are throttled.

Culprit #2: Council Review, .70/month (20%)

Life Hub runs a "council of advisors": scheduled daily and weekly reviews where Claude picks 1-2 advisor personas and writes commentary in their voice. A cooldown system prevents duplicate reviews: before running, it checks if an AdvisorComment with trigger_type="daily_review" exists within the last 20 hours.

The bug: when Claude decided nothing was noteworthy and returned an empty array [], the code skipped inserting any AdvisorComment. No record meant the cooldown check found nothing. Next scheduler cycle, it ran again. And again. 335 calls in 30 days, roughly 11 per day instead of 1.

The fix was a sentinel record:

if not comments_data:
    session.add(AdvisorComment(
        advisor_id="system",
        trigger_type=trigger_type,
        context_summary=f"Scheduled {scope} council review — no insights",
        commentary="",
        domains="[]",
        relevance_score=0.0,
        expires_at=(now_utc + timedelta(hours=1)).strftime(
            "%Y-%m-%d %H:%M:%S"
        ),
    ))
    return {"scope": scope, "comments_generated": 0, "advisors": []}

Even when the LLM has nothing to say, the sentinel record ensures the cooldown check works. The record expires after an hour so it doesn't clutter the UI.

Round 2: Model Tiering and Caching

After fixing the two big offenders (~82% of cost eliminated), I audited every remaining LLM call for three more wins worth roughly /bin/zsh.50-1.00/month:

Sonnet where Haiku would do. The correlation interpretation feature was using Claude Sonnet (/M input, /M output) to explain statistical r-values. This is a structured interpretation task. Haiku (/bin/zsh.80/M input, /M output) handles it fine. One line change, 4x cost reduction on that feature. (Pricing as of March 2026.)

Uncached enhancement calls. The enhance_next_actions_ai() function rewrites action reasons to be more personalized. It was calling the LLM on every request with no caching. The actions don't change meaningfully within a day, so I added daily caching. Life Hub already had a DigestSummary table for exactly this pattern: load_summary() → cache hit? return it → cache miss? call LLM → store_summary(). Digest narratives, topic extraction, sentiment analysis, and causal insights all used it. The problem was inconsistency: some features used the pattern, others didn't. The fix wasn't architectural, just applying the existing pattern everywhere.

Duplicate brief generation. The weekly and monthly AI briefs run from a collector CLI on a cron schedule. If the collector ran twice on a Sunday (restart, manual trigger), it would generate two weekly briefs. Added the same load_summary() cache check the daily brief already had.

The force Parameter Pattern

The most transferable design idea from this whole exercise: automated processes should respect guards (staleness checks, cooldowns, caching). User-initiated actions should bypass them.

The conflict refresh guard defaults to skipping work if data is fresh. But when someone clicks the "Refresh" button on the conflicts page, the HTMX handler passes force=True. The REST API does the same. The MCP tool defaults to force=False, which is what the chat agent calls, so the guard protects against the chat agent burning tokens, without making the UI feel unresponsive.

This applies everywhere: the council review cooldown, the brief generation cache, the news briefing. Guard the automatic paths aggressively. Let the human through when they ask.

Results

MetricBeforeAfter
Monthly token usage~7.7M~1.4M (est.)
Monthly cost~.67~.50-2.00
Conflict refresh calls/day~121
Council review calls/day~111-2
Cost reductionn/a~82%

The Actual Lesson

None of these fixes required complex engineering. Caching, guards, model selection: all basic patterns. The hard part was knowing where to look.

The LLMUsage tracking table made the investigation possible. Without per-feature, per-call token tracking, I would have been guessing. "It's probably the chat agent." Wrong, that was 2% of cost. "The daily brief runs too often." Wrong, it was already cached. The data pointed directly at the two features that mattered.

If you're building anything with multiple LLM integrations, track every call. Feature name, model, input tokens, output tokens, timestamp. It's a single table and a few lines per call site. You will need it.