mkdir tokentop-provider-replicate
cd tokentop-provider-replicate
bun init
bun add @tokentop/plugin-sdkimport { createProviderPlugin } from '@tokentop/plugin-sdk';
import type { ProviderPlugin, PluginContext, ProviderFetchContext } from '@tokentop/plugin-sdk';Provider plugins fetch usage data from AI model provider APIs.
interface ProviderPlugin extends BasePlugin {
type: 'provider';
capabilities: ProviderCapabilities;
auth: ProviderAuth;
pricing?: ProviderPricing;
fetchUsage(ctx: ProviderFetchContext): Promise<ProviderUsageData>;
refreshToken?(oauth: OAuthCredentials): Promise<RefreshedCredentials>;
healthCheck?(ctx: PluginContext): Promise<{ ok: boolean; message?: string }>;
}capabilities: {
usageLimits: true, // Provider has rate limits (e.g. Anthropic 5h/7d windows)
apiRateLimits: false, // Provider reports API rate limit headers
tokenUsage: true, // Provider reports token counts
actualCosts: false, // Provider reports actual dollar costs
}Plugins own their credential discovery. Core provides sandboxed helpers via ctx.authSources:
auth: {
async discover(ctx) {
// 1. Try OpenCode OAuth (preferred for usage tracking APIs)
const entry = await ctx.authSources.opencode.getProviderEntry('replicate');
if (entry?.accessToken) {
return credentialFound(oauthCredential(entry.accessToken, {
refreshToken: entry.refreshToken,
expiresAt: entry.expiresAt,
}));
}
// 2. Try environment variable
const key = ctx.authSources.env.get('REPLICATE_API_TOKEN');
if (key) {
return credentialFound(apiKeyCredential(key));
}
// 3. Try external config file
const config = await ctx.authSources.files.readJson<{ token: string }>(
`${ctx.authSources.platform.homedir}/.config/replicate/auth.json`
);
if (config?.token) {
return credentialFound(apiKeyCredential(config.token, 'external'));
}
return credentialMissing('No Replicate credentials found');
},
isConfigured: (credentials) => !!credentials.apiKey || !!credentials.oauth?.accessToken,
}| Source | Method | Description |
|---|---|---|
| Environment | ctx.authSources.env.get(name) |
Read env var (sandboxed to permissions.env.vars) |
| Files | ctx.authSources.files.readText(path) |
Read file as string |
| Files | ctx.authSources.files.readJson<T>(path) |
Read and parse JSON file |
| Files | ctx.authSources.files.exists(path) |
Check if file exists |
| OpenCode | ctx.authSources.opencode.getProviderEntry(key) |
Read from OpenCode's auth storage |
| Platform | ctx.authSources.platform.os |
'darwin', 'linux', or 'win32' |
| Platform | ctx.authSources.platform.homedir |
User's home directory |
import {
apiKeyCredential,
oauthCredential,
credentialFound,
credentialMissing,
credentialExpired,
credentialInvalid,
credentialError,
isTokenExpired,
} from '@tokentop/plugin-sdk';
apiKeyCredential('sk-123'); // { apiKey: 'sk-123', source: 'env' }
apiKeyCredential('sk-123', 'external'); // { apiKey: 'sk-123', source: 'external' }
oauthCredential('access-tok', { // { oauth: { accessToken, refreshToken, ... }, source: 'external' }
refreshToken: 'refresh-tok',
expiresAt: Date.now() + 3600000,
});
credentialFound(creds); // { ok: true, credentials: creds }
credentialMissing('No API key found'); // { ok: false, reason: 'missing', message: '...' }
isTokenExpired(expiresAt, 5 * 60 * 1000); // true if within 5min buffer of expiryfetchUsage() receives a ProviderFetchContext:
interface ProviderFetchContext {
credentials: Credentials; // Resolved credentials from auth.discover()
http: PluginHttpClient; // Sandboxed fetch (domain allowlisted)
logger: PluginLogger; // Scoped logger ([plugin:my-provider])
config: Record<string, unknown>;// Plugin's user config values
signal: AbortSignal; // Cancelled on shutdown
options?: {
timePeriod?: 'session' | 'daily' | 'weekly' | 'monthly';
};
}interface ProviderUsageData {
planType?: string; // e.g. "Pro", "Teams", "Free"
allowed?: boolean;
limitReached?: boolean;
limits?: {
primary?: UsageLimit; // Main rate limit
secondary?: UsageLimit; // Secondary rate limit
items?: UsageLimit[]; // All limit windows
};
tokens?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
credits?: { hasCredits: boolean; unlimited: boolean; balance?: string };
cost?: {
actual?: CostBreakdown;
estimated?: CostBreakdown;
source: 'api' | 'estimated';
};
fetchedAt: number;
error?: string;
}Plugins can provide pricing metadata so core can estimate costs:
pricing: {
modelsDevProviderId: 'replicate', // ID to query models.dev API
mapModelId(modelId) { // Map internal model IDs to pricing IDs
return modelId.split(':')[0];
},
staticPrices: { // Fallback when models.dev is unavailable
'meta/llama-3': { input: 0.65, output: 2.75 },
},
}import {
createProviderPlugin,
apiKeyCredential,
credentialFound,
credentialMissing,
} from '@tokentop/plugin-sdk';
export default createProviderPlugin({
id: 'replicate',
type: 'provider',
version: '1.0.0',
meta: {
name: 'Replicate',
description: 'Replicate API usage and cost tracking',
brandColor: '#3b82f6',
homepage: 'https://replicate.com',
},
permissions: {
network: { enabled: true, allowedDomains: ['api.replicate.com'] },
env: { read: true, vars: ['REPLICATE_API_TOKEN'] },
},
capabilities: {
usageLimits: false,
apiRateLimits: true,
tokenUsage: false,
actualCosts: true,
},
auth: {
async discover(ctx) {
const token = ctx.authSources.env.get('REPLICATE_API_TOKEN');
if (token) return credentialFound(apiKeyCredential(token));
return credentialMissing('Set REPLICATE_API_TOKEN environment variable');
},
isConfigured: (creds) => !!creds.apiKey,
},
async fetchUsage(ctx) {
const resp = await ctx.http.fetch('https://api.replicate.com/v1/account', {
headers: { Authorization: `Bearer ${ctx.credentials.apiKey}` },
signal: ctx.signal,
});
if (!resp.ok) {
return { fetchedAt: Date.now(), error: `HTTP ${resp.status}` };
}
const data = await resp.json() as { spend: { total: number } };
return {
fetchedAt: Date.now(),
cost: {
actual: { total: data.spend.total, currency: 'USD' },
source: 'api',
},
};
},
});Agent plugins parse coding agent sessions to track token usage across models.
interface AgentPlugin extends BasePlugin {
type: 'agent';
agent: { name: string };
capabilities: AgentCapabilities;
auth?: { discover(ctx): Promise<CredentialResult>; isConfigured(creds): boolean };
isInstalled(ctx: PluginContext): Promise<boolean>;
parseSessions(options: SessionParseOptions, ctx: AgentFetchContext): Promise<SessionUsageData[]>;
getProviders?(ctx: AgentFetchContext): Promise<AgentProviderConfig[]>;
startActivityWatch?(ctx: PluginContext, callback: ActivityCallback): void;
stopActivityWatch?(ctx: PluginContext): void;
}interface SessionUsageData {
sessionId: string;
sessionName?: string;
providerId: string; // e.g. "anthropic", "openai"
modelId: string; // e.g. "claude-sonnet-4-20250514"
tokens: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
timestamp: number;
sessionUpdatedAt?: number;
projectPath?: string;
}Plugins return flat arrays of SessionUsageData rows. Core handles aggregation, costing, and windowed breakdowns.
Themes are pure data -- no async logic, no methods.
interface ThemePlugin extends BasePlugin {
type: 'theme';
theme: {
colorScheme: 'light' | 'dark';
colors: ThemeColors;
components?: ThemeComponents;
isDefault?: boolean;
priority?: number;
};
}import { createThemePlugin } from '@tokentop/plugin-sdk';
export default createThemePlugin({
id: 'catppuccin',
type: 'theme',
name: 'Catppuccin',
version: '1.0.0',
meta: { description: 'Soothing pastel theme' },
permissions: {},
theme: {
colorScheme: 'dark',
colors: {
bg: '#1e1e2e', fg: '#cdd6f4', border: '#585b70', borderFocused: '#cba6f7',
primary: '#cba6f7', secondary: '#89b4fa', accent: '#f38ba8', muted: '#6c7086',
success: '#a6e3a1', warning: '#f9e2af', error: '#f38ba8', info: '#89dceb',
headerBg: '#181825', headerFg: '#cdd6f4',
statusBarBg: '#181825', statusBarFg: '#6c7086',
tableBg: '#1e1e2e', tableHeaderBg: '#313244', tableHeaderFg: '#cba6f7',
tableRowBg: '#1e1e2e', tableRowAltBg: '#181825', tableRowFg: '#cdd6f4',
tableSelectedBg: '#45475a', tableSelectedFg: '#cdd6f4',
},
},
});Notification plugins deliver alerts when events occur (budget thresholds, provider errors, etc.).
interface NotificationPlugin extends BasePlugin {
type: 'notification';
configSchema?: Record<string, ConfigField>;
initialize(ctx: NotificationContext): Promise<void>;
notify(ctx: NotificationContext, event: NotificationEvent): Promise<void>;
test?(ctx: NotificationContext): Promise<boolean>;
supports?(event: NotificationEvent): boolean;
destroy?(): Promise<void>;
}type NotificationEventType =
| 'budget.thresholdCrossed'
| 'budget.limitReached'
| 'provider.fetchFailed'
| 'provider.limitReached'
| 'provider.recovered'
| 'plugin.crashed'
| 'plugin.disabled'
| 'app.started'
| 'app.updated';
type NotificationSeverity = 'info' | 'warning' | 'critical';import { createNotificationPlugin } from '@tokentop/plugin-sdk';
export default createNotificationPlugin({
id: 'slack-webhook',
type: 'notification',
name: 'Slack Webhook',
version: '1.0.0',
permissions: {
network: { enabled: true, allowedDomains: ['hooks.slack.com'] },
},
configSchema: {
webhookUrl: { type: 'string', label: 'Webhook URL', required: true },
},
async initialize(ctx) {
if (!ctx.config.webhookUrl) {
throw new Error('Slack webhook URL is required');
}
},
supports(event) {
return event.severity === 'warning' || event.severity === 'critical';
},
async notify(ctx, event) {
await fetch(ctx.config.webhookUrl as string, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `[${event.severity.toUpperCase()}] ${event.title}: ${event.message}`,
}),
});
},
});All plugin types share optional lifecycle hooks:
| Hook | When | Use Case |
|---|---|---|
initialize(ctx) |
After load + validation | Open connections, allocate resources |
start(ctx) |
After init, on app start | Begin polling, start watchers |
stop(ctx) |
Before destroy, on disable | Pause work, stop watchers |
destroy(ctx) |
Before unload, on shutdown | Close connections, flush buffers |
onConfigChange(config, ctx) |
When user changes settings | React to config updates |
meta: {
name: 'My Plugin', // Display name in settings and provider cards
description: 'What it does', // Shown in plugin list
brandColor: '#d97757', // Hex color for charts and UI accents
homepage: 'https://...', // Link in settings UI
icon: '◆', // Single character for compact displays
}brandColor replaces hardcoded color logic in the TUI -- your plugin gets its own visual identity automatically.
Expose user-configurable settings via configSchema:
configSchema: {
apiEndpoint: {
type: 'string',
label: 'API Endpoint',
description: 'Base URL for the provider API',
default: 'https://api.example.com',
},
requestTimeout: {
type: 'number',
label: 'Timeout (ms)',
min: 1000,
max: 60000,
default: 10000,
},
region: {
type: 'select',
label: 'Region',
options: [
{ value: 'us', label: 'US' },
{ value: 'eu', label: 'EU' },
],
default: 'us',
},
}Core renders these in the settings UI and persists values in the user's config file.
import { createTestContext, createTestProviderFetchContext } from '@tokentop/plugin-sdk/testing';
import { apiKeyCredential } from '@tokentop/plugin-sdk';
import plugin from '../src/index.ts';
// Test credential discovery
const ctx = createTestContext({
env: { REPLICATE_API_TOKEN: 'r8_test_token' },
});
const result = await plugin.auth.discover(ctx);
assert(result.ok);
assert(result.credentials?.apiKey === 'r8_test_token');
// Test fetchUsage
const fetchCtx = createTestProviderFetchContext(
apiKeyCredential('r8_test_token'),
{
httpMocks: {
'https://api.replicate.com/v1/account': {
status: 200,
body: { spend: { total: 12.34 } },
},
},
},
);
const usage = await plugin.fetchUsage(fetchCtx);
assert(usage.cost?.actual?.total === 12.34);| Factory | Creates |
|---|---|
createTestContext(opts) |
Full PluginContext with mocked everything |
createTestProviderFetchContext(creds, opts) |
ProviderFetchContext with pre-set credentials |
createTestAgentFetchContext(opts) |
AgentFetchContext |
createMockLogger() |
Logger that captures entries in .entries[] |
createMockHttpClient({ mocks }) |
HTTP client that returns canned responses |
createMockStorage(initial) |
In-memory KV store |
Before publishing to npm, you'll want to run your plugin inside tokentop to see it working end-to-end. There are three ways to load a local plugin.
Point tokentop at your plugin's directory (or file) for a single run:
# Load a directory-based plugin (resolves entry point automatically)
ttop --plugin ./my-plugin
# Load a single-file plugin
ttop --plugin ./my-theme.ts
# Load multiple plugins at once
ttop --plugin ./my-provider --plugin ./my-theme.tsThis is the fastest way to iterate. The flag is repeatable and accepts both absolute and relative paths.
For plugins you're actively developing, add the path to your tokentop config so it loads every time you start ttop:
// ~/.config/tokentop/config.json
{
"plugins": {
"local": [
"~/development/my-tokentop-provider",
"~/development/my-tokentop-theme/src/index.ts"
]
}
}Paths support tilde expansion (~/...) and can be absolute or relative to the config directory. Each entry can be a directory or a direct file path.
Drop your plugin (file or directory) into the default plugins directory:
~/.config/tokentop/plugins/
├── my-theme.ts # Single-file plugin
└── my-provider/ # Directory-based plugin
├── package.json
└── src/
└── index.ts
tokentop auto-discovers everything in this directory on startup. Files must end in .ts or .js. Directories are resolved using the entry point rules below.
When you point tokentop at a directory, it resolves the entry point in this order:
package.json—mainfield, thenexports["."]src/index.tssrc/index.jsindex.tsindex.jsdist/index.js
For most plugins, having "main": "src/index.ts" in your package.json is all you need.
# 1. Create your plugin
mkdir tokentop-provider-replicate && cd tokentop-provider-replicate
bun init
bun add @tokentop/plugin-sdk
# 2. Write your plugin in src/index.ts (see examples above)
# 3. Run your tests with the SDK test harness
bun test
# 4. Load it in tokentop to verify end-to-end
ttop --plugin .
# 5. When it works, publish to npm
npm publish- Validation errors appear in the console. If your plugin fails to load, tokentop logs the specific validation error (missing fields, wrong
apiVersion, etc.). - Disable a plugin without removing it. Add its
idtoconfig.plugins.disabledto skip loading without deleting the path fromplugins.local. - Combine methods freely. Auto-discovered plugins,
plugins.localpaths, and--pluginflags all merge together. Duplicates are handled gracefully.
| Plugin Type | Name Pattern |
|---|---|
| Provider | @tokentop/provider-<name> |
| Agent | @tokentop/agent-<name> |
| Theme | @tokentop/theme-<name> |
| Notification | @tokentop/notification-<name> |
{
"name": "@tokentop/provider-replicate",
"version": "1.0.0",
"type": "module",
"main": "src/index.ts",
"files": ["src", "dist"],
"dependencies": {
"@tokentop/plugin-sdk": "^0.1.0"
}
}- SDK semver (
0.1.0): npm package version, normal semver rules apiVersion(integer2): plugin contract version, checked by core at load time
The createProviderPlugin() / createAgentPlugin() / etc. helpers automatically stamp apiVersion: CURRENT_API_VERSION on your plugin. You don't set it manually.
If core supports apiVersion: 2 and a plugin declares apiVersion: 3, core rejects it with a clear compatibility error.