SLOPSHOPPER

Friendly Reminder

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.

newbandtoastprocess
A shopper browsing a rack in a slop shop
README

Friendly Reminder 💧

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
  • Water leads the message. It is an estimate built from published research, pictured as something everyday (see Comparisons), and totaled for the day and the month across all your Claude Code sessions.
  • Energy is estimated the same way.
  • Tokens are exact: the API's own counts for every request in the turn, subagents included.
  • API cost is exact at Anthropic's list prices, with cache writes and reads priced separately. On a subscription, this is the API-equivalent value of the work, not what you pay.
  • Quota: on Pro, Max and Team, your 5-hour and weekly usage. The short format shows it once either reaches 50%.

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.

Install

/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

The band appears when a response ends and hides while Claude is working. It has two buttons:

  • Options opens pickers for format, language, comparison, estimate and water scope. A pick is saved to /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.
  • Hide dismisses the band for the rest of the session. The history keeps recording.

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.

Formats

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.

Comparisons

Milliliters are hard to picture, so each amount is compared with something familiar. Two modes:

  • progressive (default) grows with your use. The short format shows it on the month, where the total keeps climbing. How far it goes is for you to find out.
  • random pictures each response as something different, and usually something fun:
  💧 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.

Configure

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.

OptionValuesDefault
formatshort, table, detailed, off (see Formats)short
languageen, pt, auto (Claude Code's language setting, then the system locale)en
comparisonprogressive (grows with your use), random (something different every response)progressive
currency + exchange_rateany currency code, and units of it per 1 USD (e.g. BRL, 5.4)USD, 1
footprint_profilecentral, low, high: which end of the published range to showcentral
water_scopefull (cooling + power generation), onsite (cooling only)full

Data and overrides

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.

How the water is estimated

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]
Factorlowcentralhigh
Output, Wh per 1k tokens0.10.52.0
Uncached input and cache writes, Wh per 1k0.0050.050.4
Cache reads, Wh per 1k0.00050.0050.04
PUE1.081.121.2
On-site cooling, L/kWh0.20.51.8
Power generation, L/kWh1.03.14.5

A few things drive these factors:

  • Output (decode) tokens dominate inference energy, and cache reads are the cheapest tokens. Claude Code re-reads its context from cache on every tool call, so a busy turn can reach millions of cache-read tokens.
  • With water_scope=onsite, the estimate counts cooling only. Most vendor per-prompt figures report that, and they come out much lower.
  • The 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.

How it works

Friendly Reminder is a mod: a plugin whose hooks module (hooks/register.tsx) draws in Claude Code's interface. When Claude finishes a response:

  1. The module runs scripts/friendly-reminder, passing it the turn's Stop payload and your settings.
  2. The script reads the session transcript and the subagent transcripts beside it.
  3. It sums the usage of every API call since the turn's opening prompt, prices it, and estimates energy and water.
  4. It records the turn in the history, keyed by session and turn, so measuring the same turn twice never counts it twice.
  5. It reads the other transcripts in ~/.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.
  6. The day, month and month-by-month totals come from the history.
  7. The module draws the result above the prompt.

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.

Troubleshooting

  • No band after a response. Check that the plugin is enabled (/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).
  • Numbers look off. Look at 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 *).
  • Wrong language. The default is English. Pick pt or auto in Options.

Limits

  • Water and energy are estimates with a wide range. Tokens and API cost are exact.
  • Prices are a snapshot (as_of in data/pricing.json). Bedrock and Vertex use partner pricing, which can differ.
  • Quota comes from an undocumented cache in ~/.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.
  • A turn interrupted with Esc doesn't fire Stop. It enters the totals after the next response, when the transcripts are read again.
  • The history only goes back as far as the transcripts Claude Code keeps (30 days by default, cleanupPeriodDays) and to the start of last month.
  • The response's own numbers are read the moment it ends, so once in a while they miss the last request. The totals fix that after the next response.
  • Mods are an early-access Claude Code feature, and their API may change between releases.
  • Where an organization manages Claude Code, it runs Stop hooks only for plugins listed in managed settings, so the band stays empty.
  • The Claude mobile app shows the band without Options, since it can't draw pickers yet.

Development

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

License

MIT

Source 2 files
hooks/register.tsx 203 lines
1// 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}
203
types/index.d.ts 14 lines
1export 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