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.
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
usagetool 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:
| Location | Scope |
|---|---|
.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 commandsproject,directory,worktree— paths and project metadata
Hooks a usage tracker needs
| Hook | Purpose here |
|---|---|
event | Subscribe to session lifecycle events (session.created, session.idle, session.error) |
tool | Register 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:
- Capture — when a request 429s, the
session.errorevent fires with the error text - Snapshot — record tokens/requests consumed since the window started; that's the estimated limit
- Measure — time until the next successful call reveals the window length (5-hour rolling? daily?)
- 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.
Related Articles & Guides
Multi-Model Workflows with OpenCode
Learn to switch between Gemini, GLM, DeepSeek, and MiniMax mid-session in OpenCode. Match each provider to task type and cut costs without sacrificing quality.
OpenCode Session History & Recovery
Where OpenCode stores session history, and how to query the SQLite database to recover past work, trace tool calls, find regressions, and audit model costs.
DeepSeek Harness Plugins & Extending
Understand the DeepSeek Harness plugin system: Cordis, profiles, bundles, configuration, and how to build a custom tool. Everything is a plugin.