Building a Token Usage Tracker Plugin for OpenCode

Step-by-step guide to building an OpenCode plugin that tracks token usage per provider, reports session stats, and learns quota limits from rate-limit errors.

August 27, 2026
opencodepluginstoken-usagecost-trackingtutorial

Building a Token Usage Tracker Plugin for OpenCode

OpenCode has no built-in view of what you're spending. Each provider reports usage differently — some report cost accurately, some report $0 for free-tier models, and free-model quotas are rarely documented anywhere. This tutorial builds a plugin that fixes that: it tracks token usage for your current session across every configured provider, and learns undocumented quota limits by watching rate-limit errors.

By the end you'll have a working plugin that:

  • Reports per-provider, per-model token usage for the session you're in
  • Exposes a usage tool the agent can call on demand
  • Self-calibrates quota limits for providers that don't publish them
  • Warns you with a toast before you hit a limit

Why Track Usage at All

Three problems, none of which the TUI solves today:

1. Cost reporting is inconsistent. Every assistant message in OpenCode carries a cost and tokens field — but only if the provider reports them. Anthropic and Google report real cost. Free-tier models (Zen free models, TokenRouter free tier) report $0 even though they consume real quota. A tracker that only reads cost is blind to exactly the models you most want to ration.

2. Free-model quotas are undocumented. Free tiers have real limits — rolling windows of tokens or requests — but providers rarely publish the numbers. You only discover them when a request fails with a 429.

3. There is no spend dashboard. The data exists (OpenCode records every message in a local SQLite database), but nothing aggregates it by provider, model, or time window.

The key insight: the local history database already contains everything. No provider APIs needed.

How OpenCode Plugins Work

A plugin is a JavaScript or TypeScript module that exports an async function. OpenCode loads it at startup, calls it with a context object, and collects the hooks it returns.

Plugin locations — files in these directories load automatically:

LocationScope
.opencode/plugins/Project-level
~/.config/opencode/plugins/Global (all projects)

The basic shape:

export const UsageTracker = async ({ project, client, $, directory, worktree }) => {
  // initialization runs once at startup
  return {
    // hooks go here
  }
}

The context object gives you:

  • client — an OpenCode SDK client for talking to the server (query config, show toasts, write logs)
  • $ — Bun's shell API for running commands
  • project, directory, worktree — paths and project metadata

Hooks a usage tracker needs

HookPurpose here
eventSubscribe to session lifecycle events (session.created, session.idle, session.error)
toolRegister a custom usage tool the agent can call

The event hook receives every server event; you filter by event.type:

return {
  event: async ({ event }) => {
    if (event.type === "session.idle") {
      // session finished — good time to check quota usage
    }
    if (event.type === "session.error") {
      // possibly a 429 rate-limit — calibration data
    }
  },
}

Plugins run as scripts, not LLM calls

Everything a plugin does — event handling, database queries, toast alerts — executes as ordinary code inside the OpenCode server process (Bun runtime). It costs zero tokens. The only token cost is when the agent invokes your custom tool: the tool's output becomes a tool result in context, typically a few hundred tokens, and only when explicitly requested.

Where the Usage Data Lives

OpenCode stores conversation history in SQLite:

~/.local/share/opencode/opencode.db

The message table holds one row per message, with metadata in a JSON data column. Assistant messages include exactly the fields a usage tracker needs:

{
  "role": "assistant",
  "providerID": "tokenrouter",
  "modelID": "qwen/qwen3.8-max-free",
  "cost": 0,
  "tokens": {
    "input": 4297,
    "output": 305,
    "reasoning": 51,
    "cache": { "read": 40960, "write": 0 }
  },
  "time": { "created": 1778008715140, "completed": 1778008721713 }
}

Every message is tagged with providerID and modelID, so aggregating by provider is a single query. Bun ships a built-in SQLite driver — no npm dependencies:

import { Database } from "bun:sqlite"

const db = new Database(`${process.env.HOME}/.local/share/opencode/opencode.db`, {
  readonly: true,
})

const rows = db
  .query(
    `SELECT json_extract(data, '$.providerID') AS provider,
            json_extract(data, '$.modelID')   AS model,
            count(*)                          AS requests,
            sum(json_extract(data, '$.tokens.input'))  AS input_tokens,
            sum(json_extract(data, '$.tokens.output')) AS output_tokens,
            sum(json_extract(data, '$.cost'))          AS cost
     FROM message
     WHERE session_id = ?
       AND json_extract(data, '$.role') = 'assistant'
     GROUP BY provider, model`
  )
  .all(sessionId)

Open the database read-only — it belongs to the running server, and a write lock from your plugin would interfere with it.

Plugin Architecture

Four components, one state file:

usage-tracker.ts
│
├── Session tracker ──── listens to session.created / session.updated
│                        keeps the current session ID in memory
│
├── DB query layer ───── bun:sqlite, read-only
│                        aggregates message.data by provider/model/window
│
├── usage tool ────────── custom tool; formats current-session report
│                        per-provider table + quota status
│
├── Quota engine ──────── session.error hook captures 429s
│                        learns limit + window length per model
│                        rolling-window usage across sessions
│
└── Alert system ──────── on session.idle, compares window usage to limit
                         tui.toast.show at 80% / 100%, deduped

State: ~/.local/share/opencode/usage-tracker.json
       (learned limits, last-alert timestamps)

Data flows one way: events and the database feed the quota engine; the quota engine feeds the tool and the alerts.

Why self-calibrating quotas

For providers that don't publish limits (free tiers especially), the plugin learns them empirically:

  1. Capture — when a request 429s, the session.error event fires with the error text
  2. Snapshot — record tokens/requests consumed since the window started; that's the estimated limit
  3. Measure — time until the next successful call reveals the window length (5-hour rolling? daily?)
  4. Persist — write the learned values to the state file; they survive restarts

After one or two rate-limit hits, the plugin knows the limit well enough to warn at 80% — without anyone ever reading a docs page. Manual overrides in the config supersede learned values whenever you do know the real numbers.

Report scope: current session, all providers

The usage tool reports on the session you're actually in, broken down by every provider that session touched — your main model, the cheap model OpenCode uses for title generation, any subagent models:

Session: ses_xxx — 23 requests

Provider     Model                    Reqs  In      Out     Cache-read  Cost
tokenrouter  qwen/qwen3.8-max-free    21    1.1M    44k     812k        $0.00
opencode     big-pickle (titles)      2     1.2k    89      0           $0.00

Quota status:
tokenrouter  5h window: 412k tokens — 82% of learned limit

Quota windows are time-based, so they aggregate across sessions — but alerts surface in whichever session you're currently working in.

What's Next

The remaining sections build each component: the session tracker and usage tool, the 429 calibration loop, and the toast alerts — ending with the complete plugin file ready to drop into ~/.config/opencode/plugins/.

For plugin configuration options and the full opencode.json reference, see Configuration Reference. For switching between the providers this plugin tracks, see Multi-Model Workflows.