A friendly reminder after every response of the water it took, plus energy, tokens, API cost and subscription quota, with a daily and monthly history.

Every answer from an AI model costs water: the water that cools the data center, plus the water used to generate its electricity. Friendly Reminder is a Claude Code plugin that makes that cost visible. After each response, it shows two lines in a band above the prompt:
💧 56 mL of water in this response (29% of a small glass) · today 1.4 L · month 19 L (≈ 1.9 buckets)
⚡ 15 Wh · 1.3M tokens · $0.82
It speaks English by default, with Portuguese one click away. It detects your billing mode and plan by itself. It needs only python3 3.8+ and has no dependencies.
/plugin marketplace add leon-lourenco/green-ai
/plugin install friendly-reminder@green-ai
The reminder appears after your next response. On Windows, make sure python3 is on PATH.
To update later, run /plugin marketplace update green-ai, then /reload-plugins (or start a new session).
The band appears when a response ends and hides while Claude is working. It has two buttons:
/config and the reminder of the last response redraws at once, so you see the change without waiting for the next one. Done closes the pickers.Click a button, or press ctrl+x tab to focus the band from the keyboard: Enter acts, the arrow keys move through a picker's choices, and Esc returns to the prompt.
short (default) is the two lines above.
table adds a title, a plain sentence, the day and month comparisons, a turn/month table and, once there is more than one month of history, a month-by-month table:
💧 Friendly Reminder · turn 7 · opus-5-5
This response used ≈ 55.6 mL of water, 29% of a small glass.
Today: ≈ 1.40 L (≈ 7.4 small glasses) · Month: ≈ 19.0 L (≈ 1.9 buckets)
┌──────────┬───────────┬────────────┐
│ │ Turn │ Month │
├──────────┼───────────┼────────────┤
│ Water │ ≈ 55.6 mL │ ≈ 19.0 L │
│ Energy │ ≈ 15.4 Wh │ ≈ 5.20 kWh │
│ Tokens │ 1,291,395 │ 40.9M │
│ API cost │ $0.818 │ $52.40 │
└──────────┴───────────┴────────────┘
┌──────────┬──────────┬────────────┬────────┬──────────┐
│ By month │ Water │ Energy │ Tokens │ API cost │
├──────────┼──────────┼────────────┼────────┼──────────┤
│ Oct 2026 │ ≈ 19.0 L │ ≈ 5.20 kWh │ 40.9M │ $52.40 │
│ Sep 2026 │ ≈ 31.2 L │ ≈ 8.60 kWh │ 66.0M │ $87.10 │
└──────────┴──────────┴────────────┴────────┴──────────┘
Max quota: 5h 34% · week 58%
detailed is the table plus the token breakdown (input, cache writes, cache reads, output) and the low–high range of the water and energy estimates:
Water: 3.40 mL – 410 mL
Energy: 2.10 Wh – 64.0 Wh
≈ = estimate (central profile; water counts data-center cooling + power generation).
off shows nothing but keeps recording the history, so the totals are right when you turn it back on.
Milliliters are hard to picture, so each amount is compared with something familiar. Two modes:
💧 56 mL of water in this response (22% of a cactus watering) · today 1.4 L · month 19 L
The pick is fixed for each response, so redrawing the band from Options doesn't change it.
The comparisons live in comparisons.py. Ideas are welcome.
Use Options on the band, or open /config and edit the Friendly Reminder rows. You can also run claude plugin configure friendly-reminder@green-ai from your shell. Currency and exchange rate are only in /config.
| Option | Values | Default |
|---|---|---|
format | short, table, detailed, off (see Formats) | short |
language | en, pt, auto (Claude Code's language setting, then the system locale) | en |
comparison | progressive (grows with your use), random (something different every response) | progressive |
currency + exchange_rate | any currency code, and units of it per 1 USD (e.g. BRL, 5.4) | USD, 1 |
footprint_profile | central, low, high: which end of the published range to show | central |
water_scope | full (cooling + power generation), onsite (cooling only) | full |
Everything the plugin keeps is in its data directory, ~/.claude/plugins/data/friendly-reminder-green-ai/:
history/YYYY-MM.jsonl holds one line per response: tokens, cost, energy, water and models. The day, month and month-by-month totals come from here. Delete a file to reset that month (sessions still on disk are counted again after the next response).sync-state.json remembers which transcripts were already read. Delete it to read them all again.error.log collects any error, so a failure never interrupts your session.pricing.json (optional) adds or replaces model prices, using the same shape as data/pricing.json.footprint.json (optional) replaces water and energy factors, using the same shape as data/footprint.json.Anthropic doesn't publish per-token energy or water figures, so Friendly Reminder estimates them:
energy (Wh) = PUE × Σ tokens_by_class × Wh_per_token(class)
water (mL) = energy × (on-site cooling WUE + off-site water intensity of electricity) [L/kWh]
| Factor | low | central | high |
|---|---|---|---|
| Output, Wh per 1k tokens | 0.1 | 0.5 | 2.0 |
| Uncached input and cache writes, Wh per 1k | 0.005 | 0.05 | 0.4 |
| Cache reads, Wh per 1k | 0.0005 | 0.005 | 0.04 |
| PUE | 1.08 | 1.12 | 1.2 |
| On-site cooling, L/kWh | 0.2 | 0.5 | 1.8 |
| Power generation, L/kWh | 1.0 | 3.1 | 4.5 |
A few things drive these factors:
water_scope=onsite, the estimate counts cooling only. Most vendor per-prompt figures report that, and they come out much lower.detailed format shows the low–high range for every turn.Sources and assumptions are listed in data/footprint.json. Corrections backed by better sources are very welcome.
Friendly Reminder is a mod: a plugin whose hooks module (hooks/register.tsx) draws in Claude Code's interface. When Claude finishes a response:
scripts/friendly-reminder, passing it the turn's Stop payload and your settings.usage of every API call since the turn's opening prompt, prices it, and estimates energy and water.~/.claude/projects that changed since its last run and records any turn the history lacks: other sessions and windows, sessions started before the plugin was installed, turns interrupted with Esc. Only transcripts touched since the start of last month are read, and a run stops after 6 seconds and continues after the next response, so the first one after installing can take a few responses to catch up.A pick in Options writes the /config row, then runs step 1 again for the last response with the new setting.
The plugin never reads credentials. Billing detection looks only at provider switches, at whether an API key is present, and at the plan type in ~/.claude.json.
/plugin) and that python3 runs from your shell. If you pressed Hide, the band stays hidden until the next session. If you updated the plugin with /reload-plugins and the band stopped, start a new session (claude --continue keeps the conversation).error.log in the data directory. Prices are a snapshot, so a new model may be missing from data/pricing.json (the cost then shows a *).pt or auto in Options.as_of in data/pricing.json). Bedrock and Vertex use partner pricing, which can differ.~/.claude.json, so it is only as fresh as Claude Code's last fetch. Subscriptions have 5-hour and weekly limits, not a monthly allowance.Stop. It enters the totals after the next response, when the transcripts are read again.cleanupPeriodDays) and to the start of last month.Stop hooks only for plugins listed in managed settings, so the band stays empty.python3 -m unittest discover -s tests # Python: no dependencies
claude plugin test . # the mod: tests/*.test.tsx
claude plugin validate .
claude --plugin-dir . # try it without installing
MIT
hooks/register.tsx 203 lines1// Friendly Reminder as a band above the prompt.
2//
3// After each response the Python package (scripts/friendly-reminder) reads the
4// transcript, records the turn and returns the message; this module keeps it
5// and draws it above the prompt. A Stop hook's own systemMessage would be
6// shown with a "Stop says:" prefix on every line, which a band avoids.
7//
8// Options opens pickers for the plugin's choice settings. A pick is written to
9// the /config row (which reloads this module with the new options) and the
10// last response is measured again, so the band shows the change at once.
11// Recording the same turn twice is harmless: the history keys rows by turn.
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { Reminder } from '../types'
17
18const last = atom({ plugin: 'friendly-reminder', key: 'last' } as const, null)
19const lastStop = atom({ plugin: 'friendly-reminder', key: 'lastStop' } as const, null)
20const isHidden = atom({ plugin: 'friendly-reminder', key: 'isHidden' } as const, false)
21const isEditing = atom({ plugin: 'friendly-reminder', key: 'isEditing' } as const, false)
22
23type Words = Record<string, string>
24
25const HIDE: Words = { en: 'Hide', pt: 'Ocultar' }
26const OPTIONS: Words = { en: 'Options', pt: 'Opções' }
27const DONE: Words = { en: 'Done', pt: 'Concluir' }
28const MORE: Words = {
29 en: 'Currency and exchange rate: /config',
30 pt: 'Moeda e câmbio: /config',
31}
32
33// The userConfig fields with fixed choices, in the order they are drawn.
34const CHOICES: { field: string; label: Words; options: { value: string; label: Words }[] }[] = [
35 {
36 field: 'format',
37 label: { en: 'Format', pt: 'Formato' },
38 options: [
39 { value: 'short', label: { en: 'short', pt: 'curto' } },
40 { value: 'table', label: { en: 'table', pt: 'tabela' } },
41 { value: 'detailed', label: { en: 'detailed', pt: 'detalhado' } },
42 { value: 'off', label: { en: 'off (history only)', pt: 'desligado (só histórico)' } },
43 ],
44 },
45 {
46 field: 'language',
47 label: { en: 'Language', pt: 'Idioma' },
48 options: [
49 { value: 'auto', label: { en: 'automatic', pt: 'automático' } },
50 { value: 'en', label: { en: 'English', pt: 'English' } },
51 { value: 'pt', label: { en: 'Português', pt: 'Português' } },
52 ],
53 },
54 {
55 field: 'comparison',
56 label: { en: 'Compare with', pt: 'Comparar com' },
57 options: [
58 { value: 'progressive', label: { en: 'progressive', pt: 'progressivo' } },
59 { value: 'random', label: { en: 'random', pt: 'aleatório' } },
60 ],
61 },
62 {
63 field: 'footprint_profile',
64 label: { en: 'Estimate', pt: 'Estimativa' },
65 options: [
66 { value: 'central', label: { en: 'central', pt: 'central' } },
67 { value: 'low', label: { en: 'low end', pt: 'mínima' } },
68 { value: 'high', label: { en: 'high end', pt: 'máxima' } },
69 ],
70 },
71 {
72 field: 'water_scope',
73 label: { en: 'Water', pt: 'Água' },
74 options: [
75 { value: 'full', label: { en: 'cooling + power generation', pt: 'resfriamento + geração de energia' } },
76 { value: 'onsite', label: { en: 'cooling only', pt: 'só resfriamento' } },
77 ],
78 },
79]
80
81const say = (words: Words, language: string) => words[language] ?? words.en!
82
83// The Python side reads its settings the way a command hook would.
84const toEnv = (options: Record<string, unknown>) => {
85 const env: Record<string, string> = {}
86 for (const [key, value] of Object.entries(options)) {
87 if (value !== undefined && value !== null) {
88 env[`CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}`] = String(value)
89 }
90 }
91 return env
92}
93
94async function measure($: EngineInterface, stop: unknown, env: Record<string, string>): Promise<Reminder | null> {
95 const { stdout } = await $.process.run(['python3', `${$.plugin.root}/scripts/friendly-reminder`], {
96 stdin: JSON.stringify(stop),
97 env,
98 timeoutMs: 15000,
99 })
100 const out = stdout.trim() ? (JSON.parse(stdout) as { systemMessage?: string; language?: string }) : {}
101 return out.systemMessage ? { lines: out.systemMessage.split('\n'), language: out.language ?? 'en' } : null
102}
103
104async function choose($: EngineInterface, current: Record<string, unknown>, field: string, value: string) {
105 const rows = await $.config.list()
106 const row =
107 rows.find(r => r.key === `friendly-reminder.${field}`) ??
108 rows.find(r => r.key.startsWith('friendly-reminder') && r.key.endsWith(`.${field}`))
109 if (!row) return
110 const { deny } = await $.config.set({ key: row.key, value })
111 if (deny) {
112 $.ui.toast(deny)
113 return
114 }
115 const stop = await read($, lastStop)
116 if (stop === null) return
117 try {
118 const reminder = await measure($, stop, toEnv({ ...current, [field]: value }))
119 await update($, last, () => reminder)
120 } catch {
121 // keep the band as it was
122 }
123}
124
125export const register: Register = (on, options) => {
126 const current = options as Record<string, unknown>
127
128 on('classic.Stop', async ($, e, next) => {
129 const result = await next(e)
130 try {
131 await update($, lastStop, () => e as unknown as Record<string, unknown>)
132 const reminder = await measure($, e, toEnv(current))
133 await update($, last, () => reminder)
134 } catch {
135 // a reminder must never get in the way of the session
136 }
137 return result
138 }).catch(($, e, next) => next(e))
139
140 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
141 if (e.props.hasSurvey || e.props.isWorking || (await read($, isHidden))) {
142 return next(e)
143 }
144 const reminder = await read($, last)
145 const editing = await read($, isEditing)
146 // With format "off" there is no reminder, but an open panel stays so the pick can be undone.
147 if (reminder === null && !editing) {
148 return next(e)
149 }
150
151 const elements = $.ui.resolve(e)
152 const { Box, Button, Text } = elements
153 // The mobile app draws no Select yet, so it gets no Options button.
154 const Select = 'Select' in elements ? elements.Select : undefined
155 const language = reminder?.language ?? (current.language === 'pt' ? 'pt' : 'en')
156 const [water, ...rest] = reminder?.lines ?? []
157
158 return (
159 <Box flexDirection="column">
160 {water !== undefined && <Text key="water">{water}</Text>}
161 {rest.map((line, i) => (
162 <Text key={`line-${i}`} dimColor>
163 {line}
164 </Text>
165 ))}
166 {editing && Select && (
167 <Box key="options" flexDirection="column" marginTop={1}>
168 {CHOICES.map(choice => (
169 <Select
170 key={`option-${choice.field}`}
171 label={`${say(choice.label, language)}: `}
172 value={String(current[choice.field] ?? choice.options[0]!.value)}
173 options={choice.options.map(o => ({ value: o.value, label: say(o.label, language) }))}
174 onSelect={(value: string) => choose($, current, choice.field, value)}
175 />
176 ))}
177 <Text key="more" dimColor>
178 {say(MORE, language)}
179 </Text>
180 </Box>
181 )}
182 <Box key="actions" flexDirection="row" gap={1}>
183 {Select && (
184 <Button
185 key="options"
186 label={say(editing ? DONE : OPTIONS, language)}
187 onPress={() => update($, isEditing, open => !open)}
188 />
189 )}
190 <Button
191 key="hide"
192 label={say(HIDE, language)}
193 onPress={async () => {
194 await update($, isEditing, () => false)
195 await update($, isHidden, () => true)
196 }}
197 />
198 </Box>
199 </Box>
200 )
201 })
202}
203types/index.d.ts 14 lines1export type Reminder = { lines: string[]; language: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'friendly-reminder': {
6 last: Reminder | null
7 /** The last Stop payload, to measure that response again after a setting changes. */
8 lastStop: Record<string, unknown> | null
9 isHidden: boolean
10 isEditing: boolean
11 }
12 }
13}
14