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.

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 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 usage tool 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
LocationScope
.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:

PropertyUse here
ctx.locationCurrent directory and project metadata
ctx.optionsValues from the plugin's options object
ctx.sessionRead sessions and message context
ctx.storageDurable JSON storage scoped to the plugin
ctx.toolRegister tools and hook tool execution
ctx.eventSubscribe to server events
ctx.appOpenCode 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

  1. Confirm the plugin is loaded:

    opencode plugin list
    
  2. Reload configuration without restarting the server:

    opencode reload
    

    Or run /reload in the TUI.

  3. 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/plugin API 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 calls context.ui.toast.show.
  • The database schema can change. V2 uses session_message; pre-upgrade history lives in the old message and part tables. 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.transform remains 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