Nags you, with fire, when much of your weekly usage limit is still unspent close to the reset

<img src="assets/banner.svg" alt="Burn Baby Burn: a Claude Code mod that nags you, with fire, when your weekly limit is about to go to waste" width="100%">
<a href="https://claude.ai/directory"><img src="https://img.shields.io/badge/Claude%20plugin%20directory-Burn%20Baby%20Burn-e8342c?style=flat-square&labelColor=161026" alt="Listed in the Claude plugin directory"></a> <img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Feel-brah%2Fburn-baby-burn%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&label=version&style=flat-square&color=ff8c1a&labelColor=161026" alt="Version"> <img src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-ffd640?style=flat-square&labelColor=161026" alt="Requires Claude Code 2.1.287 or later"> <img src="https://img.shields.io/badge/network-none-b4a9c9?style=flat-square&labelColor=161026" alt="Sends nothing over the network"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-7d7196?style=flat-square&labelColor=161026" alt="MIT license"></a>
<a href="#install">Install</a> · <a href="#the-closer-the-reset-the-hotter-it-gets">How it nags</a> · <a href="#what-it-does-on-your-machine">What it does on your machine</a> · <a href="#tweak">Tweak</a>
Your weekly usage limit resets whether you used it or not. Burn Baby Burn stays quiet for most of the week. When the reset is close and a quarter or more of your limit is still unused, it starts nagging you to use it.
<img src="assets/demo.svg" alt="Claude Code with Burn Baby Burn: a toast nags you, /burn replies with the weekly status and a quote, and the status line shows two flames, 41% of the week left, resets in 16h 4m" width="100%">
| Status line | 🔥🔥 41% of the week left · resets in 16h 4m, counting down live, even while you're idle. |
| Toasts | A different quote each time, more often as the reset nears. |
/burn | Shows the status whenever you ask. It has something to say outside the window too. |
<img src="assets/tiers.svg" alt="The nags get hotter as the reset nears: one flame every 6 hours from 3 days out, two flames every 2 hours from 1 day out, three flames every 30 minutes in the last 6 hours" width="100%">
It only nags in the last 72 hours before your weekly reset, and only while 25% or more of the week is left. Use the limit up and it goes quiet again.
Tokens don't roll over. Neither does regret.<br> We didn't start the fire, but 41% of your week says you should.<br> Ask Claude to rewrite it in Rust. You have 38% to spare.
There are 33 nag quotes, 11 per tier, plus replies for /burn before your first prompt, outside the window, and on API keys, where there's no weekly limit to burn. They're all in hooks/burn.ts.
You need Claude Code 2.1.287 or later and a plan with a weekly limit. Burn Baby Burn is listed in the Claude plugin directory:
claude plugin install burn-baby-burn@anthropic-plugin-directory --scope user
In Claude Code, run /plugin and search for "Burn Baby Burn", or install it from claude.ai/directory.
This repo is also its own marketplace:
claude plugin marketplace add eel-brah/burn-baby-burn
claude plugin install burn-baby-burn@burn-baby-burn --scope user
To try it without installing:
claude --plugin-dir /path/to/burn-baby-burn
| Reads | Writes | Network | Commands |
|---|---|---|---|
| Claude Code's usage data for the session (only the weekly limit) and the clock, re-checked once a minute | When it last nagged and at which level, in Claude Code's plugin store | None | None |
It shows a status line and toasts, adds the /burn command, and touches no other files.
Its hooks (hooks/register.ts) only observe and always pass the event on unchanged:
session.start: registers /burn, takes the first usage reading and starts the one-minute re-check.session.measure: re-checks when Claude Code reports new rate limits.command.run (only for /burn): answers with the current status; every other command is left alone.[!IMPORTANT] A mod runs inside Claude Code with the same access Claude Code has. Read the code (
hooks/) before installing.
Everything is in hooks/burn.ts: MIN_LEFT sets how much of the week must be left before it nags, and the TIERS table sets when each level starts and how often it nags.
export const MIN_LEFT = 25
const TIERS = [
{ tier: 'inferno', within: 6 * HOUR, every: 30 * 60 * 1000 },
{ tier: 'blaze', within: 24 * HOUR, every: 2 * HOUR },
{ tier: 'smoulder', within: 72 * HOUR, every: 6 * HOUR },
]
<sub>MIT licensed. Now go burn some tokens. 🔥</sub>
hooks/register.ts 65 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { API, CALM, WARMUP, WINDOW_HOURS, assess, isNagDue, pick, quote, readLastNag, statusText, weekly } from './burn'
4import type { Limit } from './burn'
5
6const LAST_NAG = 'lastNag'
7// Rate limits only change when usage moves, so a timer keeps the countdown and nags going while idle.
8const RECHECK_MS = 60_000
9
10// The latest reading, re-assessed by the timer between measures.
11let latest: readonly Limit[] = []
12
13async function check($: EngineInterface, limits: readonly Limit[]) {
14 latest = limits
15 const now = await $.clock.now()
16 const burn = assess(weekly(limits), now)
17 $.ui.status(burn ? statusText(burn) : undefined)
18 if (burn === null) return
19
20 // Kept in $.store so a nag isn't repeated by every new session.
21 const last = readLastNag(await $.store.get(LAST_NAG))
22 if (!isNagDue(burn, now, last)) return
23 $.ui.toast(quote(burn, now))
24 await $.store.set(LAST_NAG, { at: now, tier: burn.tier })
25}
26
27// A nag is never worth breaking the session for: a failed check is dropped.
28async function safeCheck($: EngineInterface, limits: readonly Limit[]) {
29 try {
30 await check($, limits)
31 } catch {
32 // Nothing to recover; the next measure or tick tries again.
33 }
34}
35
36export const register: Register = on => {
37 on('session.start', async ($, e, next) => {
38 const started = await next(e)
39 await $.command.register({ name: 'burn', description: 'How much of the weekly limit is left to burn' })
40 // Empty until the first API response of the session; session.measure follows.
41 await safeCheck($, (await $.session.usage()).rateLimits)
42 $.clock.every(RECHECK_MS, () => void safeCheck($, latest))
43 return started
44 })
45
46 on('session.measure', async ($, e, next) => {
47 if (e.changed.includes('rateLimits')) await safeCheck($, e.rateLimits)
48 return next(e)
49 })
50
51 on('command.run', { command: 'burn' }, async $ => {
52 const now = await $.clock.now()
53 const usage = await $.session.usage()
54 const limit = weekly(usage.rateLimits)
55 if (limit === undefined) {
56 // Subscriptions report the weekly window with the first answer; still none after one means an API key.
57 const hasAnswered = (usage.context.tokens ?? 0) > 0 || (usage.cost?.usd ?? 0) > 0
58 return { text: pick(hasAnswered ? API : WARMUP, now) }
59 }
60 const burn = assess(limit, now)
61 if (burn === null) return { text: `${Math.round(100 - limit.percentUsed)}% of the week left. ${pick(CALM, now).replaceAll('{window}', String(WINDOW_HOURS))}` }
62 return { text: `${statusText(burn)}\n${quote(burn, now)}` }
63 })
64}
65hooks/burn.ts 149 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Tier = 'smoulder' | 'blaze' | 'inferno'
3export type Burn = { tier: Tier; left: number; msLeft: number }
4
5const HOUR = 60 * 60 * 1000
6
7// Below this share of the week left, there is nothing worth nagging about.
8export const MIN_LEFT = 25
9
10// How long before the reset each tier starts, and how often it may nag.
11const TIERS: { tier: Tier; within: number; every: number }[] = [
12 { tier: 'inferno', within: 6 * HOUR, every: 30 * 60 * 1000 },
13 { tier: 'blaze', within: 24 * HOUR, every: 2 * HOUR },
14 { tier: 'smoulder', within: 72 * HOUR, every: 6 * HOUR },
15]
16
17const QUOTES: Record<Tier, string[]> = {
18 smoulder: [
19 'Burn, baby, burn. {left} of your week is just sitting there.',
20 "Tokens don't roll over. Neither does regret.",
21 "Your weekly quota called. It's feeling unloved.",
22 'Unused tokens are just compute crying quietly.',
23 'Light my fire: {left} left, reset in {time}.',
24 "{left} of your week is still in the fridge. It expires in {time}.",
25 "Your tokens are like gym membership: paid for and unused.",
26 "Fun fact: unused tokens don't go to token heaven.",
27 "Your context window is looking a bit empty. Feed it.",
28 "Somewhere, a backlog is waiting for you. {left} could clear it.",
29 "Claude is getting bored. Give it a refactor.",
30 ],
31 blaze: [
32 'Disco inferno: {left} left and under a day to go.',
33 "Use it or lose it. And you're about to lose it.",
34 'Somewhere a GPU is idle because of you.',
35 'We didn\'t start the fire, but {left} of your week says you should.',
36 'Ship it like the reset is tomorrow. Because it is.',
37 "{left} left and {time} to go. Even your linter is judging you.",
38 "That TODO from 2023? Now's the time. {left} says so.",
39 "Pretend it's Friday at 5 pm and the demo is Monday.",
40 "Your weekly quota is packing its bags. {time} until it leaves.",
41 "Write the tests you promised yourself. You have {left} of a week to do it.",
42 "Under a day left. Ask Claude something ambitious.",
43 ],
44 inferno: [
45 'BURN BABY BURN! {left} left, reset in {time}!',
46 'Last call at the token bar. Order big.',
47 "It's the final countdown: {time} to burn {left}.",
48 'Refactor something. Anything. Now.',
49 'Great balls of fire! {left} still unspent!',
50 "{time} left. This is not a drill. {left} still unburned!",
51 "Open every repo. Fix everything. GO.",
52 "The tokens are melting. {left} gone in {time}.",
53 "Spend it like it's the last day of vacation money.",
54 "Ask Claude to rewrite it in Rust. You have {left} to spare.",
55 "Midnight sale on tokens: everything must go in {time}.",
56 ],
57}
58
59export function weekly(limits: readonly Limit[]): Limit | undefined {
60 return limits.find(l => l.kind === 'seven_day')
61}
62
63export function assess(limit: Limit | undefined, now: number): Burn | null {
64 if (limit?.resetsAt === undefined) return null
65 const msLeft = Date.parse(limit.resetsAt) - now
66 const left = Math.round(100 - limit.percentUsed)
67 if (Number.isNaN(msLeft) || msLeft <= 0 || left < MIN_LEFT) return null
68 // Tiers are ordered tightest first, so the first match is the hottest.
69 const match = TIERS.find(t => msLeft <= t.within)
70 return match ? { tier: match.tier, left, msLeft } : null
71}
72
73export function nagEvery(tier: Tier): number {
74 return TIERS.find(t => t.tier === tier)?.every ?? 6 * HOUR
75}
76
77// The warning window: the widest tier's reach before the reset.
78export const WINDOW_HOURS = Math.max(...TIERS.map(t => t.within)) / HOUR
79
80export type LastNag = { at: number; tier: Tier }
81
82// Store values are untrusted (older versions, hand edits): anything malformed counts as no nag yet.
83export function readLastNag(value: unknown): LastNag | undefined {
84 if (typeof value !== 'object' || value === null) return undefined
85 const { at, tier } = value as Record<string, unknown>
86 if (typeof at !== 'number' || !Number.isFinite(at)) return undefined
87 if (!TIERS.some(t => t.tier === tier)) return undefined
88 return { at, tier: tier as Tier }
89}
90
91// Tiers only get hotter as the reset nears, so a tier change always nags; otherwise once per the tier's interval.
92export function isNagDue(burn: Burn, now: number, last: LastNag | undefined): boolean {
93 if (last === undefined || last.tier !== burn.tier) return true
94 return now - last.at >= nagEvery(burn.tier)
95}
96
97export function formatLeft(ms: number): string {
98 const hours = Math.floor(ms / HOUR)
99 const days = Math.floor(hours / 24)
100 if (days > 0) return `${days}d ${hours % 24}h`
101 if (hours > 0) return `${hours}h ${Math.floor((ms % HOUR) / 60000)}m`
102 return `${Math.max(1, Math.floor(ms / 60000))}m`
103}
104
105// Picks a line that changes every minute, so repeat nags don't repeat lines.
106export function pick(lines: readonly string[], now: number): string {
107 return lines[Math.floor(now / 60000) % lines.length] ?? lines[0] ?? ''
108}
109
110export function quote(burn: Burn, now: number): string {
111 return pick(QUOTES[burn.tier], now)
112 .replaceAll('{left}', `${burn.left}%`)
113 .replaceAll('{time}', formatLeft(burn.msLeft))
114}
115
116// /burn outside the warning window.
117export const CALM = [
118 "The fire is just a pilot light for now. Go build something.",
119 "Plenty of fuel in the tank. I'll start yelling {window} h before the reset.",
120 "Relax. Your tokens are safe… for now. 😈",
121 "Too early to panic. Come back in a few days.",
122 "The kindling is stacked. The match is ready. Not yet, though.",
123 "I'm on standby. Spend wisely, and I'll keep quiet.",
124]
125
126// /burn when Claude has answered but no weekly limit came back: an API key, pay per token.
127export const API = [
128 "No weekly limit here. You pay per token, so I'm the one who should be scared. 💸",
129 "API user detected. There's no quota to burn, only your credit card.",
130 "You don't have a weekly limit. You have an invoice.",
131 "Nothing resets for you. Your bill just keeps going up.",
132 "I'm a weekly-limit nagger with no weekly limit. Existential crisis. 🔥🫠",
133 "Every token counts on your bill. Burn responsibly.",
134]
135
136// /burn before the session's first answer, when there is no reading yet.
137export const WARMUP = [
138 "I can't see your fuel gauge yet. Say something to Claude and I'll take a look.",
139 "Warming up the matches… send a prompt and I'll check your tank.",
140 "No smoke without fire, and no reading without a prompt. Ask Claude anything.",
141 "I'm blind until Claude answers once. Go on, poke it.",
142 "Still lighting the stove. One prompt and I'll know how much you've got to burn.",
143]
144
145export function statusText(burn: Burn): string {
146 const flames = { smoulder: '🔥', blaze: '🔥🔥', inferno: '🔥🔥🔥' }[burn.tier]
147 return `${flames} ${burn.left}% of the week left · resets in ${formatLeft(burn.msLeft)}`
148}
149