Zero-Cost Opus: Pre-Computing LLM Outputs with Claude Desktop and MCP

How I offloaded 10 LLM call types from paid Haiku API to subscription-included Opus by having Claude Desktop pre-compute results on a 2-hour schedule via MCP tools.

Zero-Cost Opus: Pre-Computing LLM Outputs with Claude Desktop and MCP

Last week I wrote about cutting my Life Hub LLM costs from $9.67/month to under $2 through staleness guards and cooldown fixes. That post ended with a line about "the remaining $2 being the floor": the irreducible cost of 10+ features that genuinely need an LLM to synthesize data into narrative.

I found the trapdoor in that floor.

The Realization

Life Hub makes about 10 distinct types of LLM calls: daily briefs, weekly summaries, council advisor reviews, news briefings, pillar narratives, correlation interpretations, and more. Every single one hits the Anthropic API using Claude Haiku, the cheapest model. A few hundred tokens per call, a few calls per day, it adds up.

Meanwhile, my Claude Pro subscription includes unlimited Opus (the most capable model in the lineup) through Claude Desktop. I'm paying for it regardless. It sits there, waiting for me to type something.

The question became: what if Claude Desktop could do the synthesis work before Life Hub needs it?

The Architecture

Claude Desktop has a built-in scheduled tasks feature. It can run prompts on a cron-like schedule, and it can connect to MCP servers, including my Life Hub MCP server, which exposes 150+ tools for reading and writing life data.

The design is simple:

Claude Desktop (Mac, subscription-included Opus)
  └── Scheduled task every 2h
      ├── Reads life context via MCP tools
      ├── Synthesizes narratives with Opus
      └── Writes results back via MCP save tools

Life Hub (K8s pod)
  └── Before any LLM call: check for fresh pre-computed result
      ├── Fresh (<6h) → return pre-computed Opus output, skip API call
      └── Stale (>6h) → fall back to Haiku (automatic)

The 6-hour staleness threshold is deliberate. My Mac sleeps overnight, reboots occasionally, loses network sometimes. When Claude Desktop can't run its scheduled task, the gap grows. At 6 hours, Life Hub stops trusting the cache and generates its own output via Haiku. The system degrades gracefully: you get slightly worse output (Haiku instead of Opus) but never no output.

What Gets Pre-Computed (and What Doesn't)

Not every LLM call can be pre-computed. The split is clean:

Pre-computable (10 scopes): these are periodic syntheses that summarize data that changes slowly:

  • Daily brief, weekly brief, monthly brief
  • Council advisor reviews (daily + weekly)
  • News briefing
  • Weekly retrospective
  • Pillar score narratives
  • Correlation interpretations
  • Next actions reasoning

Not pre-computable: these are event-driven or interactive:

  • Trigger commentary (fires when a threshold is breached: 19 types)
  • Thought classification (fires on capture)
  • Journal sentiment analysis (fires on entry)
  • Advisor chat and live council (interactive)
  • Masterpiece day planning (on-demand)

The distinction is timing. If the output is a function of accumulated data that changes every few hours, pre-compute it. If it's a reaction to a specific user action, it has to be live.

The Cache Hierarchy

Life Hub already had two caching layers for LLM outputs. I added a third on top:

  1. PreComputedCache table: Opus-quality output, written by Claude Desktop every 2 hours. Checked first.
  2. In-memory result cache: 6-hour TTL, prevents duplicate API calls within a pod's lifetime.
  3. DigestSummary table: Daily persistence in SQLite for some generators (briefs, correlations).
  4. Live Haiku API call: Last resort. Only fires when all cache layers miss.

In practice, the pre-computed layer catches almost everything during waking hours. The in-memory cache handles repeated requests within a session. The DigestSummary table survives pod restarts. Haiku only fires when my Mac has been off for 6+ hours: essentially just overnight and the occasional reboot.

Implementation

The implementation touched 11 files across the Life Hub codebase. Here's the interesting parts.

The Cache Table

A new SQLAlchemy model with a unique constraint on scope (only the latest result per scope matters):

class PreComputedCache(Base):
    __tablename__ = "precomputed_cache"
    __table_args__ = (UniqueConstraint("scope", name="uq_precomputed_scope"),)

    id: Mapped[int] = mapped_column(primary_key=True)
    scope: Mapped[str] = mapped_column(nullable=False)
    content: Mapped[str] = mapped_column(nullable=False)
    generated_at: Mapped[str] = mapped_column(nullable=False)
    model: Mapped[str] = mapped_column(nullable=False, default="opus")

No migration needed: SQLAlchemy's create_all() handles it on startup. One of the benefits of SQLite in a single-user app.

The Freshness Check

async def get_precomputed(scope: str) -> str | None:
    """Return cached content if fresh, else None (triggers fallback)."""
    max_age_hours = settings.PRECOMPUTE_STALE_HOURS  # default: 6

    async with get_session() as session:
        row = (await session.execute(
            select(PreComputedCache).where(PreComputedCache.scope == scope)
        )).scalar_one_or_none()

    if row is None:
        return None

    generated = datetime.fromisoformat(row.generated_at)
    age_hours = (datetime.now(UTC) - generated).total_seconds() / 3600
    if age_hours < max_age_hours:
        logger.info("Pre-computed cache hit [%s] (%.1fh old)", scope, age_hours)
        return row.content

    logger.info("Pre-computed cache stale [%s] (%.1fh > %dh)", scope, age_hours, max_age_hours)
    return None

The PRECOMPUTE_STALE_HOURS env var makes the threshold tunable without code changes. Returns the cached content or None. The caller doesn't need to know anything about the caching mechanism.

Adding Staleness Checks to Generators

Each of the 10 pre-computable generators got a 3-line addition at the top of their function, before any existing cache check or API call:

async def generate_daily_brief(session: AsyncSession) -> str:
    # Check pre-computed cache (Claude Desktop scheduler)
    from app.ai_engine import get_precomputed
    precomputed = await get_precomputed("daily_brief")
    if precomputed:
        return precomputed

    # ... existing cache check, data compilation, Haiku call ...

Seven files, same pattern. The pre-computed check runs first because it's the highest-quality output (Opus vs Haiku) and zero marginal cost. If stale, it falls through to everything that was already there.

The MCP Tool

Claude Desktop needs a way to write results back. A new save_precomputed MCP tool handles the upsert:

@mcp.tool()
async def save_precomputed(scope: str, content: str, model: str = "opus") -> dict:
    """Save pre-computed LLM output for a given scope.
    Scopes: daily_brief, weekly_brief, monthly_brief, council_daily,
    council_weekly, news, retrospective, pillar_narratives,
    correlations, next_actions."""

The tool validates the scope against a whitelist and upserts into the PreComputedCache table. Simple and constrained: it can only write to known scopes.

The Claude Desktop Side

Claude Desktop connects to Life Hub's MCP server via mcp-remote, a Node.js bridge that translates between stdio (what Claude Desktop speaks) and Streamable HTTP (what Life Hub's MCP endpoint speaks). The config lives in claude_desktop_config.json:

{
  "mcpServers": {
    "life-hub": {
      "command": "/opt/homebrew/bin/npx",
      "args": [
        "-y", "mcp-remote",
        "https://life-hub.naviauxlab.com/mcp/",
        "--header", "Authorization:Bearer "
      ],
      "env": {
        "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
      }
    }
  }
}

The scheduled task runs every 2 hours with a prompt that instructs Claude to read context from 11 MCP tools, synthesize narratives for each scope, and write them back using the appropriate save tools.

Debugging the MCP Connection

Getting the MCP connection working was the hardest part, harder than the actual caching code.

Problem 1: Trailing slash. Life Hub mounts its MCP server at /mcp. FastAPI's Starlette layer auto-redirects /mcp to /mcp/ with a 307. mcp-remote doesn't follow redirects. Fix: add the trailing slash to the URL in the config.

Problem 2: Node.js version. This one was maddening. My Mac has Node 25 via Homebrew at /opt/homebrew/bin/node. It also has an ancient Node 18.16.0 at /usr/local/bin/node from a forgotten install. Claude Desktop's default PATH puts /usr/local/bin first.

Even after pointing the command at Homebrew's npx, the child process that npx spawns (mcp-remote) still inherited the default PATH and found Node 18 first. The undici HTTP library in mcp-remote uses the File global, which only exists in Node 20+. The error was clear once you saw it:

ReferenceError: File is not defined
    at .../undici/lib/web/webidl/index.js:537:48
Node.js v18.16.0

The fix: add "env": {"PATH": "/opt/homebrew/bin:..."} to the MCP server config so the child process finds Node 25 first. Also had to nuke the stale npx cache at ~/.npm/_npx/ that had modules compiled against Node 18.

The First Successful Run

After the debugging, the scheduled task ran and produced exactly what I'd hoped for:

  • 3 digest summaries: daily, weekly, and monthly briefs, synthesized by Opus from real data
  • 4 council advisor comments: Sister Miriam on gratitude, Jax Thorne on workspace entropy, Alistair Vance catching a data integrity issue (connection pillar scores high but connection goals logged at 0%), Coach Miller on screen time
  • Weekly retrospective: journal sentiment correlation with mood (r=0.945), sleep quality signals, weekday-weekend habit gap analysis

The quality difference between Opus and Haiku is noticeable. The Opus-generated council comments were more specific, referenced exact numbers, and made connections I hadn't seen in the Haiku outputs. Alistair Vance's observation (that the connection pillar led at 8.5 but all three connection goals were at 0% logged) was the kind of insight that makes the whole system worthwhile. The pillar score is high because of social events and relationship interactions that are passively tracked, but the goals themselves (which require deliberate logging) show zero progress. The data is technically correct and practically misleading. Opus caught it. Haiku probably wouldn't have.

The Bigger Picture

This pattern (subscription-included AI doing background pre-computation for self-hosted apps) feels like the beginning of something. The economics are compelling:

  • Claude Pro costs a fixed monthly fee regardless of how much Desktop-side compute I use
  • Every pre-computed result that displaces a Haiku call saves real API money
  • The output quality is better because Opus is a more capable model
  • The latency is better because the result is already in the database when the user requests it

The app becomes a thinner client. Instead of "gather data → call LLM → render," the hot path becomes "check cache → render." The LLM work happens in the background, on someone else's schedule (Claude Desktop's), using compute I've already paid for.

There are limits. Interactive features (chat, live analysis, on-demand generation) can't be pre-computed. Event-driven features ("your mood just dropped 3 points, here's what might be going on") need to fire in real time. But for the periodic synthesis work that makes up the majority of LLM calls in a dashboard app, this works.

The staleness fallback is what makes it production-viable. Pre-computation is best-effort. If the Mac is asleep, if the network drops, if Claude Desktop has a bad day, Life Hub keeps working. The output might be Haiku instead of Opus, but it's still output. No single point of failure, no hard dependency on the scheduler running.

What's Next

Monitoring. I want to track pre-computed cache hit rates in the LLMUsage table: how often does Life Hub serve pre-computed content vs. falling back to Haiku? What's the effective cost reduction? Right now I know it works because I watched the first run. In a week, I want data.

I'm also curious about expanding the pattern. Life Hub isn't the only app that could benefit from background AI pre-computation. Any self-hosted tool with an MCP interface could receive pre-computed intelligence from Claude Desktop on a schedule. The MCP protocol makes the integration clean: read tools for context, write tools for results. The scheduled task is just a prompt.

The tools are here. The subscription model creates an arbitrage opportunity between "AI you've already paid for" and "AI you pay per token for." This is one way to exploit it.