Shows where Claude Code tokens go, audits a setup for settings that waste them, and changes a setting only after you approve the diff.

<img src="docs/logo.svg" width="72" height="72" alt="tokendoctor">
<h1 align="center">tokendoctor</h1>
<strong>See where your Claude Code tokens go, and stop wasting them</strong>
A Claude Code plugin that measures your real spend, audits your setup against the docs, and changes a setting only after you approve the exact diff. Everything runs on your machine.
<a href="https://tokendoctor.dev">Website</a> · <a href="#install">Install</a> · <a href="CHANGELOG.md">Changelog</a>
<img src="docs/demo.gif" alt="tokendoctor in a real Claude Code session: /tokendoctor:sessions, then the daily trend" width="880">
<table> <tr> <td width="33%" valign="top"> <strong>Measure</strong><br> <code>/tokendoctor:sessions</code> reads your local transcripts: main thread against subagents, spend with a very full context, cache rebuilds. A daily history and before/after snapshots show whether a change helped. </td> <td width="33%" valign="top"> <strong>Audit</strong><br> <code>/tokendoctor:audit</code> runs 33 checks against documented Claude Code behaviour. Each finding links to the docs page it rests on. </td> <td width="33%" valign="top"> <strong>Tune</strong><br> <code>/tokendoctor:optimize</code> proposes changes backed by your own numbers and applies the ones you pick, each through a dialog that shows it. </td> </tr> </table>
claude plugin marketplace add rsaryev/tokendoctor
claude plugin install tokendoctor@tokendoctor
Update with claude plugin marketplace update tokendoctor and claude plugin update tokendoctor@tokendoctor, then restart Claude Code. To try it without installing, clone the repository and run claude --plugin-dir /path/to/tokendoctor.
| Command | What it does |
|---|---|
/tokendoctor:sessions [days] | Where tokens went over the last 30 days (or days), with findings. Read-only. |
/tokendoctor:audit | Setting checks by severity, each linked to the docs. Read-only. |
/tokendoctor:optimize [focus] | Proposes up to five changes from your numbers and applies the ones you pick. |
/tokendoctor:apply <finding-id> | Applies one audit fix after a dry run, with a backup of the file. |
/tokendoctor:bench save <label> · compare | Saves a before/after snapshot of token use, or compares the saved ones, per machine and combined. |
optimize changes only settings on the tunable list and /config rows. Every change opens a dialog that shows it and runs only on Apply; with no one to ask, as in a -p run, nothing changes. Hooks and permissions are never on the list.
The plugin's bin/ directory is on the Bash tool's PATH while it is enabled:
tokendoctor-audit [--project <dir>] [--json]
tokendoctor-sessions [--days <n> | --since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--project <dir>] [--by day|week] [--json]
tokendoctor-bench save --label <name> [--machine <name>] [--dir <dir>] [--days <n> | --since <date>] [--until <date>]
tokendoctor-bench compare [--dir <dir>] [--json]
tokendoctor-set --list | <key> <value>|--unset [--scope user|project|local] [--write] # also modelSettings.<model id>.effortLevel
tokendoctor-apply <finding-id> [--scope user|project|local] [--project <dir>] [--write]
The skills are user-invoked only (disable-model-invocation). claude plugin details tokendoctor@tokendoctor estimates about 205 tokens of always-on cost for the five of them. The plugin has no MCP server. Its hooks module adds the optimize tools (mcp__tokendoctor__*) behind ToolSearch, so only their names are listed until /tokendoctor:optimize loads them. Its one command hook runs tokendoctor-record in the background when a session starts. The hook is async and prints nothing, so it adds no tokens. It appends each finished day's totals to ~/.claude/tokendoctor/history.jsonl, so the trend outlives Claude Code's transcript cleanup.
Errors are settings that are wrong or ignored, warnings are likely to cost real money, and info is worth a look. Rules that depend on a Claude Code release are skipped on older versions.
| Finding | Severity | What it catches |
|---|---|---|
settings-unreadable | error | A settings file that exists but is not valid JSON, so nothing in it applies. |
compact-threshold-undocumented | error | autoCompactThreshold is not a Claude Code setting; the real key is autoCompactWindow. Fix removes it. |
compact-env-bare-number | error | CLAUDE_CODE_AUTO_COMPACT_WINDOW below 100,000 (a bare number is tokens, not thousands). |
compact-window-unset | warn | Models with a native 1M window compact only near 967K by default. Fix sets 400,000. |
compact-window-small | warn | A window so small that frequent compaction breaks the prompt cache. |
prompt-caching-disabled | warn | A DISABLE_PROMPT_CACHING* variable is set. |
subagent-model-force | warn | Every subagent, including reasoning ones, is forced onto one model. |
effort-max-in-settings | warn | effortLevel: "max" is not accepted in settings. Fix uses xhigh. |
advisor-inactive | warn | An advisor that will not run (telemetry or feature-flag fetching off, an advisor alias below the main model's, tool disabled). |
fast-mode-enabled | warn | fastMode: true saved as a default, so every session pays the higher fast-mode rate. Fix sets fastModePerSessionOptIn. |
auto-compact-off | warn | DISABLE_AUTO_COMPACT or autoCompactEnabled: false: history grows until the model window fills. Fix unsets the setting. |
tool-search-off | warn/info | ENABLE_TOOL_SEARCH=false loads every MCP tool definition into every request. |
explore-default-model | info | The built-in Explore agent runs on the main model (Opus when that is Fable), and CLAUDE_CODE_SUBAGENT_MODEL does not move it. Fix installs a Haiku, read-only override. |
subagent-model-unset | info | General-purpose subagents inherit an expensive main model. Fix sets CLAUDE_CODE_SUBAGENT_MODEL=sonnet. |
effort-above-default | info | A level saved per model by /effort above that model's default (Opus 5.5 at xhigh, say). Fix removes the saved level. |
effort-user-key-ignored | info | A top-level effortLevel in user settings, which Opus 5.5 and later models do not use. |
main-model-expensive | info | Opus or Fable as the default; suggests Sonnet. |
agents-inherit-model | info | Custom agents with no model run on the main model. |
heavy-dirs-not-denied | info | Large target/, dist/, build/, coverage/, .turbo/ directories not excluded from reads. Fix adds permissions.deny rules. |
large-lockfile-readable | info | Lockfiles over 200 KB that a model can open in full. Fix adds a deny rule. |
instructions-long | info | CLAUDE.md plus its @imports over 200 lines. Path-scoped .claude/rules and skills cut the cost; imports do not. |
compact-instructions-missing | info | No # Compact instructions section to steer summaries. |
context-surface | info | Many skills, plugins or MCP servers loaded in every session. |
duplicate-skills | info | The same skill at user and project level. |
thinking-tokens-env | info | MAX_THINKING_TOKENS is ignored on adaptive-reasoning models. |
prompt-cache-ttl-forced-short | info | FORCE_PROMPT_CACHING_5M removes the one-hour cache some plans get. |
agent-teams-enabled | info | Agent teams multiply token use per teammate. |
mcp-output-limit-raised | info | A raised MAX_MCP_OUTPUT_TOKENS. |
every-tool-hooks | info | Hooks matching * on a tool event (Pre/PostToolUse, PostToolUseFailure, PermissionRequest) start a process per call. Latency, not tokens. Fix drops them after a dry run; read the warning first if a tool shows agent status from them. |
idle-compaction-off | info | idleCompaction: false: a long conversation is not compacted before its one-hour cache expires. Fix unsets it. |
bash-output-raised | info | bashOutputMaxChars or BASH_MAX_OUTPUT_LENGTH above the 30,000 default. |
code-intelligence-missing | info | The project has files for a language with an official language-server plugin (typescript-lsp, pyright-lsp, gopls-lsp, …) and it is not enabled. |
no-statusline | info | No status line. Fix installs tokendoctor-statusline.sh and points statusLine at it (needs jq). One quiet line: Opus 5.5 · api main* · 120k/367k · cache 1h · 5h 51% · week 35% · $24. Tokens are shown against the point where Claude Code compacts; cache 1h is how long the prompt cache stays warm; a limit adds → 100% in 48m only when, at the recent pace (5h: the last hour, week: the last day), it runs out before it resets. The finding also mentions the token-weather mod from anthropics/claude-code-playground as an alternative (needs Claude Code 2.1.287+, shared as-is). |
templates/statusline.sh is the status line script the fix installs into your Claude config folder. templates/agents/ holds three generic subagents (Explore, test-runner, docs-lookup), each pinned to a small model with read-only or narrow tools. A plugin cannot override a built-in agent or ship settings.json keys such as autoCompactWindow, env or permissions, so the apply step copies the template into ~/.claude/agents/ or .claude/agents/ and edits your settings files itself, with a backup.
| Finding | Severity | What it catches |
|---|---|---|
late-compaction | warn | Over 30% of main-thread cost spent with more than 400K tokens in context. |
subagents-on-top-model | warn | Subagents on Opus or Fable, once one type makes over 5% of all cost; the headline counts every type. Forks always run on the main model. |
long-sessions | info | Sessions of 500+ turns, with their share of cost. |
idle-cache-rebuilds | info | The prompt cache rebuilt after it expired during a break. |
images-read-repeatedly | info | The same image read three or more times. |
~/.claude.json (only the MCP server names, to count them) and your skill and agent folders. It cannot see managed (organisation) settings.permissions.deny are best-effort for search tools and do not stop a read through Bash.npm test # node's built-in test runner, no dependencies
npm run test:mod # the hooks module's tests (claude plugin test)
claude plugin validate .
The landing page lives in site/ (cd site && npm install && npm run dev; npm run build prerenders it, node scripts/og.mjs redraws the social card). The demo is a real session recorded with asciinema; site/scripts/RECORDING.md says how to record it again.
Rules are pure functions over a snapshot (lib/rules.mjs); reading the disk lives in lib/collect.mjs and writing in lib/apply.mjs. Adding a rule means adding one entry and its test. Session analysis is in lib/sessions.mjs and lib/history.mjs, snapshots in lib/bench.mjs, the settings tokendoctor-set may change in lib/tunables.mjs, and the optimize tools in hooks/register.ts.
MIT
hooks/register.ts 164 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Tools for /tokendoctor:optimize. Reading tools run without a prompt. A change runs only after the
4// person picks "Apply" in a dialog that shows it: a permission `ask` can be settled by the mode
5// (bypass, a -p run) without a person, so the dialog is the mod's own, and with no one to ask
6// ($.ui.ask rejects in -p) nothing changes. The tools wait behind ToolSearch, so they add nothing
7// to context until the optimize skill loads them.
8const PLUGIN = 'tokendoctor'
9const tool = <N extends string>(name: N) => `mcp__${PLUGIN}__${name}` as const
10const READS: string[] = [tool('metrics'), tool('settings'), tool('config_list')]
11const WRITES: string[] = [tool('config_set'), tool('set_setting')]
12
13const text = (value: unknown) => ({ result: typeof value === 'string' ? value : JSON.stringify(value, null, 1) })
14
15export const register: Register = on => {
16 on('session.start', async ($, e, next) => {
17 const result = await next(e)
18 await $.tool.register({
19 name: 'metrics',
20 description:
21 'Token metrics: the current session (context fill, rate limits, cost, context breakdown by category), ' +
22 'daily totals for the last `days` (default 14) and the current audit findings. Read-only.',
23 inputSchema: { type: 'object', properties: { days: { type: 'number' } } },
24 })
25 await $.tool.register({
26 name: 'settings',
27 description:
28 'Every setting tokendoctor may change, with its current merged value (unset ones included), ' +
29 'whether a change applies live or at the next session, and what it does. Read-only.',
30 })
31 await $.tool.register({
32 name: 'config_list',
33 description: 'The rows of the /config menu with their current values, kinds and options. Read-only.',
34 })
35 await $.tool.register({
36 name: 'config_set',
37 description: 'Change one /config row (key from config_list), live, as if the person did it in /config. Asks the person.',
38 inputSchema: { type: 'object', properties: { key: { type: 'string' }, value: {} }, required: ['key', 'value'] },
39 })
40 await $.tool.register({
41 name: 'set_setting',
42 description:
43 'Write one setting from the `settings` list to a settings file, with a backup. `value` omitted unsets it. ' +
44 'scope: user (default), project or local. Asks the person.',
45 inputSchema: {
46 type: 'object',
47 properties: { key: { type: 'string' }, value: {}, scope: { type: 'string', enum: ['user', 'project', 'local'] } },
48 required: ['key'],
49 },
50 })
51 return result
52 })
53
54 on('tool.check', async ($, e, next) => {
55 if (READS.includes(e.tool)) return { decision: 'allow' }
56 if (WRITES.includes(e.tool)) return { decision: 'allow' }
57 return next(e)
58 })
59
60 on('tool.call', { tool: tool('metrics') }, async ($, e) => {
61 const days = typeof e.days === 'number' ? e.days : 14
62 const usage = await $.session.usage({ breakdown: 'summary' })
63 const b = usage.context.breakdown
64 const session = {
65 tokens: usage.context.tokens,
66 window: usage.context.window,
67 compactAt: b?.autoCompactThreshold,
68 autoCompact: b?.isAutoCompactEnabled,
69 model: b?.model,
70 biggest: b?.categories
71 .filter(c => c.kind === 'used')
72 .sort((x, y) => y.tokens - x.tokens)
73 .slice(0, 6)
74 .map(c => ({ name: c.name, tokens: c.tokens })),
75 rateLimits: usage.rateLimits,
76 costUsd: usage.cost?.usd,
77 }
78 const trend = await bin($, 'tokendoctor-sessions', ['--by', 'day', '--days', String(days), '--json'])
79 const audit = await bin($, 'tokendoctor-audit', ['--json'])
80 let findings: unknown = audit
81 try {
82 findings = JSON.parse(audit).findings.map((f: { id: string; severity: string; title: string; fixes?: unknown }) => ({
83 id: f.id,
84 severity: f.severity,
85 title: f.title,
86 hasFix: Boolean(f.fixes),
87 }))
88 } catch {}
89 return text({ session, trend: safeJson(trend), findings })
90 })
91
92 on('tool.call', { tool: tool('settings') }, async ($) => {
93 const out = await bin($, 'tokendoctor-set', ['--list', '--json'])
94 const rows = safeJson(out)
95 if (!Array.isArray(rows)) return text(out)
96 const merged = (await $.settings.read()) as Record<string, unknown>
97 const env = (merged.env ?? {}) as Record<string, unknown>
98 const perModel = (merged.modelSettings ?? {}) as Record<string, Record<string, unknown>>
99 // A per-model row stays as the pattern for a model with nothing saved, and gets one row with the
100 // saved value for each model that has one (/effort saves the level there, not in effortLevel).
101 return text(
102 rows.flatMap((r: { key: string }) => {
103 const field = /^modelSettings\.<model id>\.(\w+)$/.exec(r.key)?.[1]
104 if (field) {
105 const saved = Object.entries(perModel).filter(([, m]) => m?.[field] !== undefined)
106 return [{ ...r, current: null }, ...saved.map(([model, m]) => ({ ...r, key: `modelSettings.${model}.${field}`, current: m[field] }))]
107 }
108 return [{ ...r, current: r.key.startsWith('env.') ? env[r.key.slice(4)] ?? null : merged[r.key] ?? null }]
109 }),
110 )
111 })
112
113 on('tool.call', { tool: tool('config_list') }, async ($) => {
114 const rows = await $.config.list()
115 return text(rows.map(r => ({ key: r.key, label: r.label, kind: r.kind, value: r.value, options: r.options })))
116 })
117
118 on('tool.call', { tool: tool('config_set') }, async ($, e) => {
119 const { key, value } = e as unknown as { key: string; value: never }
120 const row = (await $.config.list()).find(r => r.key === key)
121 if (!row) return text(`Not changed: /config has no row "${key}".`)
122 const change = `/config ${row.label}: ${JSON.stringify(row.value)} → ${JSON.stringify(value)} (applies now)`
123 if (!(await confirm($, change))) return text(`Not changed: the person did not approve (${change}).`)
124 const r = await $.config.set({ key, value })
125 return text(r.deny ? `Not changed: ${r.deny}` : `${key} is now ${JSON.stringify(r.value)} (live).`)
126 })
127
128 on('tool.call', { tool: tool('set_setting') }, async ($, e) => {
129 const { key, value, scope } = e as unknown as { key: string; value?: unknown; scope?: string }
130 const args = [key, ...(value === undefined ? ['--unset'] : [JSON.stringify(value)]), '--scope', scope ?? 'user']
131 const plan = await bin($, 'tokendoctor-set', args)
132 if (!plan.startsWith('Would change')) return text(plan)
133 if (!(await confirm($, plan.replace(/\n+Dry run.*$/s, '')))) return text(`Not changed: the person did not approve.\n${plan}`)
134 return text(await bin($, 'tokendoctor-set', [...args, '--write']))
135 })
136}
137
138// The person's yes, in the engine's own dialog. False when they decline, dismiss it, or no one can
139// be asked (a -p run).
140async function confirm($: EngineInterface, change: string) {
141 try {
142 const answer = await $.ui.ask(`tokendoctor wants to change a setting:\n${change}\nApply it?`, {
143 header: 'tokendoctor',
144 options: ['Apply', 'Cancel'],
145 })
146 return answer === 'Apply'
147 } catch {
148 return false
149 }
150}
151
152async function bin($: EngineInterface, name: string, args: string[]) {
153 const { exitCode, stdout, stderr } = await $.process.run(['node', `${$.plugin.root}/bin/${name}`, ...args], { timeoutMs: 120_000 })
154 return exitCode === 0 ? stdout : `${name} failed (${exitCode}): ${stderr || stdout}`
155}
156
157function safeJson(s: string): unknown {
158 try {
159 return JSON.parse(s)
160 } catch {
161 return s
162 }
163}
164