Building a Token Usage Tracker Plugin for OpenCode
Build a complete OpenCode 2 plugin that reports token usage per provider and model for the current session, using the V2 plugin API and the local SQLite database.
Building a Token Usage Tracker Plugin for OpenCode
OpenCode has no built-in view of what you're spending per session. Each provider reports usage differently — some report cost accurately, some report $0 for subscription models, and free-model quotas are rarely documented anywhere. This tutorial builds a complete OpenCode 2 plugin that reports token usage for the session you're in, broken down by provider and model.
By the end you'll have a working plugin that:
- Registers a
usagetool the agent can call on demand - Aggregates input, output, and cached tokens per provider and model
- Reads the local history database without touching the server's writes
- Survives restarts as a normal local plugin
Why Track Usage at All
Three problems the TUI does not solve:
1. Cost reporting is inconsistent. Every assistant message carries a cost and tokens field — but only if the provider reports them. Subscription plans such as OpenCode Go report $0 because usage counts against a quota, not per-token billing. 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, but providers rarely publish the numbers. You only discover them when a request fails.
3. There is no per-session spend view. The data exists in the local SQLite database, but nothing aggregates it by provider or model for the session you are in.
The key insight: the local history database already contains everything. No provider APIs needed.
OpenCode 1 plugins do not run in V2:
OpenCode 2 replaced the plugin API. V1 plugins exported an async function that returned a hook map; V2 plugins use Plugin.define({ id, setup(ctx) }) and register hooks and tools on context domains. Moving a V1 file is not enough — it has to be ported.
How OpenCode 2 Plugins Work
A V2 plugin default-exports a definition with a stable id and a setup(ctx) function. setup runs when the plugin loads; it registers tools, hooks, commands, and subscriptions, and may return a cleanup function.
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "usage-tracker",
async setup(ctx) {
// initialization runs once when the plugin loads
},
})
Local plugins live in a directory, not a single file:
.opencode/plugins/usage-tracker/
└── index.ts
| Location | Scope |
|---|---|
.opencode/plugins/ | Project-level |
~/.config/opencode/plugins/ | Global (all projects) |
Register it in opencode.jsonc:
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
{
"package": "./plugins/usage-tracker",
"options": {}
}
]
}
A configured local path must name a directory containing index.ts. A path to a single .ts file is skipped with a warning.
The context object
The plugin context is both an OpenCode client and the plugin API:
| Property | Use here |
|---|---|
ctx.location | Current directory and project metadata |
ctx.options | Values from the plugin's options object |
ctx.session | Read sessions and message context |
ctx.storage | Durable JSON storage scoped to the plugin |
ctx.tool | Register tools and hook tool execution |
ctx.event | Subscribe to server events |
ctx.app | OpenCode version and metadata |
Plugins run as scripts, not LLM calls
Everything a plugin does — event handling, database queries, formatting — executes as ordinary code inside the OpenCode server process, which runs on Bun. 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 2 stores conversation history in SQLite:
~/.local/share/opencode/opencode.db
Find the exact path at any time with:
opencode debug paths db
V2 writes assistant messages to session_message. The data column is JSON, and each assistant row carries exactly the fields a usage tracker needs:
{
"agent": "build",
"model": { "providerID": "opencode-go", "id": "deepseek-v4.1-flash" },
"cost": 0,
"tokens": {
"input": 4297,
"output": 305,
"reasoning": 51,
"cache": { "read": 40960, "write": 0 }
},
"time": { "created": 1778008715140 }
}
session_message.type is assistant for these rows. The provider and model are nested under model, so aggregating by provider is a single query.
Query the Database
Bun ships a built-in SQLite driver — no npm dependencies:
import { Database } from "bun:sqlite"
const db = new Database(dbPath, {
readonly: true,
})
const rows = db
.query(
`SELECT json_extract(data, '$.model.providerID') AS provider,
json_extract(data, '$.model.id') AS model,
count(*) AS requests,
coalesce(sum(json_extract(data, '$.tokens.input')), 0) AS input,
coalesce(sum(json_extract(data, '$.tokens.output')), 0) AS output,
coalesce(sum(json_extract(data, '$.tokens.cache.read')), 0) AS cache_read,
coalesce(sum(json_extract(data, '$.cost')), 0) AS cost
FROM session_message
WHERE session_id = ?
AND type = 'assistant'
AND json_extract(data, '$.model.id') IS NOT NULL
GROUP BY provider, model
ORDER BY output DESC`
)
.all(sessionId)
Open the database read-only. It belongs to the running server, and a write lock from your plugin would interfere with it.
On macOS and Linux the default path is derived from the home directory. Honor OPENCODE_DB when it is set to an absolute path (relative overrides resolve under OpenCode's data directory):
import { homedir } from "node:os"
import { join } from "node:path"
const dbPath =
process.env.OPENCODE_DB ?? join(homedir(), ".local/share/opencode/opencode.db")
Register the usage Tool
A custom tool is registered with a synchronous transform. The tool definition needs a JSON Schema for its input; the executor receives the parsed input and a context object that includes the current sessionID.
await ctx.tool.transform((editor) => {
editor.add({
name: "usage",
description: "Report token usage for the current session by provider and model.",
input: {
type: "object",
properties: {},
additionalProperties: false,
},
async execute(_input, context) {
const rows = query.all(context.sessionID)
return { content: format(rows) }
},
})
})
Calling the tool from the agent:
> use the usage tool
Or ask for it directly in a prompt: "Call usage and tell me which model is dominating this session."
Tool executors also receive context.signal and context.progress. Pass the signal to cancellable work such as fetch; use progress for long-running tasks.
Format the Report
A compact table is easier to read in the transcript than raw JSON:
function format(rows: Row[]): string {
if (rows.length === 0) return "No assistant messages in this session yet."
const total = rows.reduce(
(sum, row) => ({
input: sum.input + row.input,
output: sum.output + row.output,
cost: sum.cost + row.cost,
}),
{ input: 0, output: 0, cost: 0 }
)
const lines = rows.map(
(row) =>
`${row.provider}/${row.model} ${row.requests} req ` +
`${row.input.toLocaleString()} in ${row.output.toLocaleString()} out ` +
`$${row.cost.toFixed(4)}`
)
lines.push(
`total ${total.input.toLocaleString()} in ` +
`${total.output.toLocaleString()} out $${total.cost.toFixed(4)}`
)
return lines.join("\n")
}
Subscription providers report cost: 0. Use the token columns for those; use cost when you bring your own API key.
Add a Persistent Counter (Optional)
If you also want to know which tools dominate a session, hook tool execution and store counts with ctx.storage. The storage is durable, JSON, and scoped to your plugin ID:
await ctx.tool.hook("execute.after", async (event) => {
const key = `tools/${event.tool}`
const current = ((await ctx.storage.get(key)) as number | undefined) ?? 0
await ctx.storage.set(key, current + 1)
})
Read the counters back in the usage tool:
const page = await ctx.storage.scan({ prefix: "tools/", limit: 100 })
const toolLines = page.entries
.sort((a, b) => (b.value as number) - (a.value as number))
.map((entry) => `${entry.key.replace("tools/", "")}: ${entry.value}`)
Keep the hook cheap. It runs after every tool call, and a slow storage write will be felt in every step.
The Complete Plugin
import { Plugin } from "@opencode/plugin"
import { Database } from "bun:sqlite"
import { homedir } from "node:os"
import { join } from "node:path"
type Row = {
provider: string
model: string
requests: number
input: number
output: number
cache_read: number
cost: number
}
const dbPath =
process.env.OPENCODE_DB ?? join(homedir(), ".local/share/opencode/opencode.db")
export default Plugin.define({
id: "usage-tracker",
async setup(ctx) {
const db = new Database(dbPath, { readonly: true })
const query = db.query(
`SELECT json_extract(data, '$.model.providerID') AS provider,
json_extract(data, '$.model.id') AS model,
count(*) AS requests,
coalesce(sum(json_extract(data, '$.tokens.input')), 0) AS input,
coalesce(sum(json_extract(data, '$.tokens.output')), 0) AS output,
coalesce(sum(json_extract(data, '$.tokens.cache.read')), 0) AS cache_read,
coalesce(sum(json_extract(data, '$.cost')), 0) AS cost
FROM session_message
WHERE session_id = ?
AND type = 'assistant'
AND json_extract(data, '$.model.id') IS NOT NULL
GROUP BY provider, model
ORDER BY output DESC`
)
await ctx.tool.transform((editor) => {
editor.add({
name: "usage",
description: "Report token usage for the current session by provider and model.",
input: {
type: "object",
properties: {},
additionalProperties: false,
},
async execute(_input, context) {
const rows = query.all(context.sessionID) as Row[]
if (rows.length === 0) {
return { content: "No assistant messages in this session yet." }
}
const total = rows.reduce(
(sum, row) => ({
input: sum.input + row.input,
output: sum.output + row.output,
cost: sum.cost + row.cost,
}),
{ input: 0, output: 0, cost: 0 }
)
const lines = rows.map(
(row) =>
`${row.provider}/${row.model} ${row.requests} req ` +
`${row.input.toLocaleString()} in ${row.output.toLocaleString()} out ` +
`$${row.cost.toFixed(4)}`
)
lines.push(
`total ${total.input.toLocaleString()} in ` +
`${total.output.toLocaleString()} out $${total.cost.toFixed(4)}`
)
return { content: lines.join("\n") }
},
})
})
return () => db.close()
},
})
Verify the Plugin
-
Confirm the plugin is loaded:
opencode plugin list -
Reload configuration without restarting the server:
opencode reloadOr run
/reloadin the TUI. -
Ask the agent to call
usage. It should return one line per model plus a total.
If the tool is missing, check the server log for plugin load errors. The log is at ~/.local/share/opencode/log/opencode.log; filter for role=server.
Notes and Limits
- V1 plugins do not run. The old
@opencode-ai/pluginAPI and returned hook map are gone. Port the implementation, not just the file. - TUI plugins are separate. Server plugins cannot show toasts directly. To surface usage in the terminal UI, add a TUI plugin (
@opencode/plugin/tui) that reads the same data and callscontext.ui.toast.show. - The database schema can change. V2 uses
session_message; pre-upgrade history lives in the oldmessageandparttables. If a query returns nothing, check which schema your install is on. - Code Mode changes tool exposure. MCP tools are exposed as functions inside Code Mode by default; a custom plugin tool registered with
ctx.tool.transformremains a normal tool.
For plugin configuration options and the full opencode.jsonc reference, see Configuration Reference. For switching between the providers this plugin tracks, see Multi-Model Workflows. For the database schema, see Session History & Recovery.
Sources
Related Articles & Guides
Multi-Model Workflows with OpenCode
Learn to switch between Gemini, GLM, DeepSeek, and MiniMax mid-session in OpenCode 2. Match each provider to task type and cut costs without sacrificing quality.
Codex CLI Tutorial: One Bounded Repo Task, End to End
Run one complete Codex CLI task — inspect, plan, change, review the diff, verify with a test, then resume or stop — without widening scope.
AI Coding Tools — Prompt Engineering & Configuration
Complete guides for Claude Code, OpenCode, Gemini CLI, Cursor, GitHub Copilot, and more. Tool-specific prompt patterns, configuration files, and setup guides for every major AI coding assistant.