# Meter every AI plan, and spend what's left before it resets: implementation spec for an AI agent

Source: https://jacobkunselman.com/tips/usage-meter-and-burn-off/ (AI tips, No. 3, 2026-09-25)

You are an AI coding agent. Build a usage meter for the AI plans your user has (any of Claude
Code, Codex, Grok), plus burn-off rules in their `CLAUDE.md`. The reference implementation is
`reference/meter.py` (Python 3, standard library only). Read it and adapt it; the file
formats below are what it parses. Anything marked *undocumented* is vendor internals that
were observed, not promised. Check each against the user's own files before relying on it.

## The design rule

**Monitoring token spend must not cost tokens.** No model calls, no network. Collection is
passive: a timer parses what the vendors already write to disk into one small state file,
and sessions read a short summary instead of scanning transcripts themselves.

## Sources (undocumented)

**Codex**: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`
- Lines containing `"used_percent"`: JSON with `timestamp` (ISO) and `payload.rate_limits`
  (older builds put `rate_limits` at the top level) holding `primary` and `secondary` windows,
  each `{used_percent, window_minutes, resets_at (epoch s)}`. **Exact.** The newest snapshot
  per `window_minutes` is the current state (300 = 5 hours, 10080 = the week).
- Lines containing `"last_token_usage"`: `payload.info.last_token_usage` with `input_tokens`,
  `cached_input_tokens` (a subset of input), `output_tokens`. Per turn.

**Claude Code**: `~/.claude/projects/**/*.jsonl`. **Recurse**: subagents write to
`<project>/<session>/subagents/agent-*.jsonl`, and a plain glob misses them.
- Lines with `"usage"`: `timestamp` (ISO, Z) and `message.usage` with `input_tokens`,
  `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, plus
  `message.model`. **Tokens exact, no percentage.**
- A rate-limited request writes a line with `quotaLimits` where `status == "rejected"`,
  `resetsAt` (epoch s) and `rateLimitType` (`five_hour` or `seven_day`). **Exact** while that
  reset is in the future, so let it override your model. Check this before the usage branch;
  the record also carries a zeroed `usage`.

**Grok**: nothing local except what you log yourself. If the user has a Grok bridge that logs
exchanges (see AI tips No. 2), parse its cost lines (`- **cost:** $0.0123`). **Dollars exact,
no percentage.**

## Calibration (Claude and Grok)

- `meter.py --read <bucket> <pct> [note]` appends `when, vendor, pct, consumed, note` to
  `readings.tsv`, where `consumed` is the meter's own consumption figure for that bucket at that
  moment. Buckets: `claude_session`, `claude_week`, `claude_fable` (a per-model weekly bucket,
  where the plan has one), `grok`, `codex`.
- Estimate = consumption now × (pct ÷ consumed), from the newest reading (or a fit over the
  last few). Each reading is ground truth, so ask the user to record one whenever they look
  at `/usage` or an account page.
- **Weekly reset times are not on disk** for Claude or Grok. Read them once from the account
  page into `windows.conf` (`CLAUDE_WEEK_RESET=`, `GROK_RESET=`, ISO local time) and roll them
  forward a week at a time. Until one is set, the week figure is a rolling 7 days.

## Claude's five-hour block

It is a **block, not a rolling window**: the first message after the previous block expires
opens a new one, which resets five hours after its start. The start is **floored to ten
minutes** (every observed reset lands on a :x0). Consumption is counted from the block's
start. ⚠ A headless `claude -p` job that writes no transcript still opens a block: if the
user runs one on a schedule, have it log its start time and feed that in as a zero-token
event. The reference got 0 of 5 real resets right without that, and 5 of 5 with it.

## Output

- `meter.py`: a summary of about 14 lines, one or two per bucket: percentage used or left,
  consumption, reset time and time remaining. It refreshes the state if it is older than about
  20 minutes.
- `meter.py --collect`: the timer's job. It rewrites `state.json` and appends a line to
  `ledger.tsv`.
- `meter.py --json`: machine-readable state, for a dashboard or a web page.
- Optional value ratio: price each week's tokens at API list prices (`prices.tsv`) against the
  plan's price for the week (`plans.tsv`). This is a monitoring figure, not a bill.
- A systemd user timer: `OnBootSec=3min`, `OnUnitActiveSec=20min`, `Persistent=true`, running
  `meter.py --collect`.

## Burn-off rules (append to the user's CLAUDE.md; see `reference/claude-md-burn-off.md`)

1. Before any big delegated job, run `meter.py`. Never scan transcripts to work out usage.
2. Unused capacity expires. When a window is near its reset with room left, spend it on
   purpose rather than letting it lapse.
3. Spend it on the hardest queue first: problems that the cheaper agents already failed.
4. Don't hoard an expiring bucket; send it the work it does best (for Codex: writing files to
   a spec) and move other work elsewhere.
5. Size burn-off jobs for the budget: two problems per agent, and write the first file early.
6. Respect the user's stop lines. A self-imposed stop is a decision, not an error.
7. A failed worker call is billed but often not logged. After one, assume less headroom than
   the meter shows.

## Acceptance tests

1. `meter.py --collect` runs with no network (for example under `unshare -n`, or with
   networking off) and writes `state.json`.
2. `meter.py` prints the summary in well under a second once the state is fresh, and makes no
   model call. Check that the user's usage didn't move because of it.
3. With Codex present, the Codex percentage matches the account page within one point.
4. Record `--read claude_week <pct>` and run `meter.py`: the week line shows a percentage
   instead of `?`.
5. Delete `state.json`: the next `meter.py` rebuilds it.
6. The timer is active: `systemctl --user list-timers ai-meter.timer`.
7. The burn-off rules are in `CLAUDE.md`, and a fresh session asked "should you check usage
   before a big delegated job?" says to run `meter.py`.
