Estimated electricity (β‘) and water (π§) behind each Claude Code turn and session, shown on the spinner, the turn's closing line and the hint line; /footprintβ¦

A Claude Code mod that estimates the electricity β‘ and water π§ behind your session, and shows them on the lines Claude Code already draws.
Anthropic publishes no energy figures, so these are estimates built from published measurements of comparable systems. Every factor is cited below and in the code.
While Claude works, beside the time and tokens in the spinner:
β» Ponderingβ¦ β‘β‘ 4.6 Wh π§π§ 17 mL
On the line that closes each turn:
β» β‘β‘β‘ 230 Wh π§π§π§ 840 mL Β· Baked for 5m 48s
At the end of the hint line under the prompt, the session so far:
β‘β‘β‘ 820 Wh π§π§π§ 3 L this session
Type /footprint for a breakdown: lowβhigh ranges, one row per model and effort level, tool calls and sources. Esc closes it.
The glyphs count decades, so they compare across turns and sessions:
| 1 glyph | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|
| β‘ electricity | under 1 Wh | 1β10 Wh | 10β100 Wh | 100 Whβ1 kWh | over 1 kWh |
| π§ water | under 5 mL | 5β50 mL | 50β500 mL | 0.5β5 L | over 5 L |
You need a Claude Code version that runs function-hook mods (tested on 2.1.296), on macOS or Linux.
Clone the repo, then link it into your skills folder. Claude Code loads any plugin it finds there.
git clone https://github.com/christian-engels/claude-footprint.git ~/claude-footprint
mkdir -p ~/.claude/skills
ln -s ~/claude-footprint ~/.claude/skills/footprint
Start a new session. To check that it loaded:
claude plugin list
You should see footprint@skills-dir with β loaded.
If you set CLAUDE_CONFIG_DIR, link into $CLAUDE_CONFIG_DIR/skills instead.
Update: git -C ~/claude-footprint pull. A running session reloads the mod by itself.
Remove: rm ~/.claude/skills/footprint. This deletes only the link, not your clone.
Without cloning: type /plugin install footprint --marketplace christian-engels/claude-footprint at the Claude Code prompt. This installs a copy, which you update with claude plugin update. Use one method or the other, not both, or the mod loads twice.
The mod records what each model call processes: new input tokens, tokens written to and read from the prompt cache, and output tokens. It also records how long each tool runs. Energy and water are worked out from those counts each time the display updates, so changing a factor recalculates everything already recorded.
When it loads, the mod rebuilds the session so far from its transcript files, including subagents. Loading it partway through a session, or resuming one, therefore still counts everything.
For a frontier model (Opus, Sonnet, Fable):
Wh = PUE Γ [ (input + cache writes + 10% Γ cache reads) Γ 0.32 + output Γ 0.96 ] / 1,000
| Factor | Value | Source |
|---|---|---|
| Output tokens | 0.96 Wh per 1,000 | Watershed (2026), derived from Google's measured 0.24 Wh median Gemini prompt |
| Input tokens and cache writes | 0.32 Wh per 1,000 | Same |
| Cache reads | 10% of an input token (range 1β25%) | Hausfather (2026), following the API price ratio. Not measured by anyone |
| Data-centre overhead | PUE 1.10 | Watershed (2026), hyperscaler default |
| Haiku models | one sixth of the above | Hausfather (2026) |
| Uncertainty in the per-token factors | Γ0.5 to Γ2 | Oviedo et al. (2026), interquartile range |
| Water | on-site cooling Γ· PUE + water used to generate the electricity | Li et al. (2025) |
| On-site cooling water | 0.12 / 0.55 / 1.15 L per kWh | AWS / US average / Google |
| Power-station water | 1.29 / 3.14 / 9.50 L per kWh | Li et al. (2025), Texas / US average / Washington |
| Tools | run time Γ 2 / 6 / 20 W | Judgement; small next to the model |
Reasoning effort needs no factor of its own. Thinking is billed as output tokens, so more effort shows up as more output.
The displayed figure is the central estimate. /footprint shows the lowβhigh range, which combines the low ends and the high ends of all factors.
Checks against published work
/footprint says so.All factors sit at the top of hooks/register.tsx, each with its source. If better measurements appear, change them there.
claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.tsx 378 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { Activity, Tokens, Turn } from '../types'
5
6// Low, central, high.
7type Band = [number, number, number]
8
9// --- Calibration -------------------------------------------------------------
10// Anthropic publishes no energy, hardware or region data, so every factor is
11// taken from production measurements or models of comparable frontier systems.
12// The activity is stored raw: change a factor and everything recalculates.
13
14// Facility electricity per token of a frontier-scale model (>200B parameters).
15// Watershed Activity Tier, hyperscaler defaults (Bistline et al. 2026,
16// "Estimating GHG Emissions from AI Use", Table 2): EI_in 0.32 and EI_out
17// 0.96 Wh per 1,000 tokens, times PUE 1.10, derived from Google's measured
18// 0.24 Wh median Gemini prompt (Elsworth et al. 2025, arXiv 2508.15734, which
19// counts accelerators, host CPU/DRAM, idle capacity and overhead). Oviedo et al.
20// (2026, arXiv 2509.20241) reach the same ~1 Wh per 1,000 output tokens bottom-up
21// for >200B models on H100 (0.31 Wh for a 300-token answer); their IQR,
22// 0.16-0.60 Wh, sets the x0.5-x2 band, which is also Hausfather's (2026) "factor
23// of ~2 in either direction". Thinking is billed as output, so reasoning effort
24// acts through output tokens. Cache writes are a prefill, so they count as input.
25const WH_PER_TOKEN = { input: 0.32e-3, output: 0.96e-3 }
26const SPREAD: Band = [0.5, 1, 2]
27const PUE = 1.1
28
29// A cache read skips the prefill; what remains (KV transfer and attention over
30// the cached context) has not been measured anywhere. Convention: 10% of fresh
31// input, the API price ratio, bounded at 1% and 25% (Hausfather 2026, "The real
32// energy use of agentic AI"; Couch 2026). It drives most of an agentic session.
33const CACHE_READ: Band = [0.01, 0.1, 0.25]
34
35// Haiku-class models use roughly 5-7x less energy per token than frontier ones
36// (Hausfather 2026, footnote 5); every other Claude model counts as frontier.
37const TIERS: [RegExp, number][] = [[/haiku/, 1 / 6]]
38
39// Operational water (consumption, not withdrawal) per facility Wh: on-site
40// cooling WUE / PUE + the grid's water intensity (EWIF), as in Li et al. (2025,
41// CACM, arXiv 2304.03271, Table 1). On-site: AWS 0.12 L/kWh (2025), US average
42// 0.55, Google 1.15 (Elsworth et al. 2025). Grid: Texas 1.29, US average 3.14,
43// Washington 9.50 L/kWh (Li et al. Table 1).
44const WUE_SITE: Band = [0.12, 0.55, 1.15]
45const EWIF: Band = [1.29, 3.14, 9.5]
46
47// Incremental power while a tool runs, local CPU or remote server: judgement,
48// counted with the same PUE and water. Negligible next to inference.
49// ponytail: wall time x power overcounts tools that mostly wait on the network.
50const TOOL_WATTS: Band = [2, 6, 20]
51// Tools whose wall time is waiting, not work (a subagent's own steps are counted).
52const WAITS = new Set(['Agent', 'Task', 'AskUserQuestion', 'ScheduleWakeup', 'Monitor', 'SendMessage', 'EnterPlanMode', 'ExitPlanMode'])
53
54// Fixed decade bands on the central estimate, so the glyphs compare across
55// turns and sessions. One glyph per band reached.
56const WH_BANDS = [1, 10, 100, 1000]
57const ML_BANDS = [5, 50, 500, 5000]
58// -----------------------------------------------------------------------------
59
60const PANE = 'footprint'
61const EMPTY: Activity = { models: {}, tools: {} }
62const session = atom({ plugin: 'footprint', key: 'session' } as const, EMPTY)
63const turn = atom({ plugin: 'footprint', key: 'turn' } as const, EMPTY)
64const turns = atom({ plugin: 'footprint', key: 'turns' } as const, [] as Turn[])
65const isBackfilled = atom({ plugin: 'footprint', key: 'isBackfilled' } as const, false)
66const isPartial = atom({ plugin: 'footprint', key: 'isPartial' } as const, false)
67
68const band = (f: (i: 0 | 1 | 2) => number): Band => [f(0), f(1), f(2)]
69const tier = (model: string) => TIERS.find(([re]) => re.test(model))?.[1] ?? 1
70
71export const impact = (models: Activity['models'], toolMs: number) => {
72 const wh = band(i => {
73 const inference = Object.entries(models).reduce(
74 (sum, [key, t]) =>
75 sum +
76 tier(key) *
77 SPREAD[i] *
78 ((t.input + t.cacheWrite + t.cacheRead * CACHE_READ[i]) * WH_PER_TOKEN.input + t.output * WH_PER_TOKEN.output),
79 0,
80 )
81
82 return (inference + (toolMs / 3.6e6) * TOOL_WATTS[i]) * PUE
83 })
84
85 return { wh, ml: band(i => wh[i] * (WUE_SITE[i] / PUE + EWIF[i])) }
86}
87
88const toolMs = (a: Activity) => Object.values(a.tools).reduce((sum, t) => sum + t.ms, 0)
89const isEmpty = (a: Activity) => Object.keys(a.models).length === 0 && Object.keys(a.tools).length === 0
90
91export const level = (central: number, bands: number[]) => 1 + bands.filter(b => central >= b).length
92const sig = (x: number) => String(Number(x.toPrecision(2)))
93const count = (n: number) => (n >= 1e6 ? `${sig(n / 1e6)}M` : n >= 1e3 ? `${sig(n / 1e3)}k` : String(n))
94const unit = (x: number, small: string, big: string) => (x >= 1000 ? `${sig(x / 1000)} ${big}` : `${sig(x)} ${small}`)
95const span = ([lo, , hi]: Band, small: string, big: string) =>
96 hi >= 1000 ? `${sig(lo / 1000)}β${sig(hi / 1000)} ${big}` : `${sig(lo)}β${sig(hi)} ${small}`
97const energy = (b: Band) => `${'β‘'.repeat(level(b[1], WH_BANDS))} ${unit(b[1], 'Wh', 'kWh')}`
98const water = (b: Band) => `${'π§'.repeat(level(b[1], ML_BANDS))} ${unit(b[1], 'mL', 'L')}`
99
100// The one-glance form every line uses: central estimates, a glyph per decade.
101export const glance = (a: Activity) => {
102 const { wh, ml } = impact(a.models, toolMs(a))
103
104 return `${energy(wh)} ${water(ml)}`
105}
106
107const addTokens = (key: string, u: ModelUsage) => (a: Activity): Activity => {
108 const t = a.models[key] ?? { steps: 0, input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }
109 const next: Tokens = {
110 steps: t.steps + 1,
111 input: t.input + u.input_tokens,
112 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
113 cacheRead: t.cacheRead + u.cache_read_input_tokens,
114 output: t.output + u.output_tokens,
115 }
116
117 return { ...a, models: { ...a.models, [key]: next } }
118}
119
120const addTool = (tool: string, ms: number) => (a: Activity): Activity => {
121 const t = a.tools[tool] ?? { calls: 0, ms: 0 }
122
123 return { ...a, tools: { ...a.tools, [tool]: { calls: t.calls + 1, ms: t.ms + ms } } }
124}
125
126const record = async ($: EngineInterface, fn: (a: Activity) => Activity) => {
127 await update($, session, fn)
128 await update($, turn, fn)
129}
130
131type Block = { type?: string; id?: string; name?: string; tool_use_id?: string }
132type Row = {
133 type?: string
134 subtype?: string
135 durationMs?: number
136 timestamp?: string
137 message?: { id?: string; model?: string; usage?: ModelUsage; content?: unknown }
138}
139
140// Rebuilds the session from its transcript files (main and subagents), split
141// into turns at each turn_duration row, so the meter covers what happened
142// before the mod loaded and after a --resume. Effort is not in the transcript.
143export const fromTranscript = (texts: string[]) => {
144 const rows = texts
145 .flatMap(text => text.split('\n'))
146 .filter(line => line.includes('"usage"') || line.includes('"tool_result"') || line.includes('"turn_duration"'))
147 .flatMap(line => {
148 try {
149 return [JSON.parse(line) as Row]
150 } catch {
151 return []
152 }
153 })
154 .sort((a, b) => (a.timestamp ?? '').localeCompare(b.timestamp ?? ''))
155 let all = EMPTY
156 let current = EMPTY
157 const done: Turn[] = []
158 const seen = new Set<string>()
159 const started = new Map<string, { tool: string; at: number }>()
160 const apply = (fn: (a: Activity) => Activity) => {
161 all = fn(all)
162 current = fn(current)
163 }
164
165 for (const row of rows) {
166 const at = Date.parse(row.timestamp ?? '')
167 const blocks = Array.isArray(row.message?.content) ? (row.message.content as Block[]) : []
168 if (row.subtype === 'turn_duration' && row.durationMs !== undefined) {
169 done.push({ durationMs: row.durationMs, activity: current })
170 current = EMPTY
171 }
172 const m = row.message
173 if (row.type === 'assistant' && m?.usage && m.id && m.model !== '<synthetic>' && !seen.has(m.id)) {
174 seen.add(m.id)
175 apply(addTokens(`${m.model}|earlier`, m.usage))
176 }
177 for (const b of blocks) {
178 if (b.type === 'tool_use' && b.id && b.name) started.set(b.id, { tool: b.name, at })
179 const call = b.type === 'tool_result' && b.tool_use_id ? started.get(b.tool_use_id) : undefined
180 if (call) apply(addTool(call.tool, WAITS.has(call.tool) ? 0 : Math.max(0, at - call.at)))
181 }
182 }
183
184 return { session: all, turn: current, turns: done }
185}
186
187const backfill = async ($: EngineInterface) => {
188 try {
189 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
190 const project = `${home}/projects/${(await $.session.cwd()).replace(/[^a-zA-Z0-9]/g, '-')}`
191 const id = await $.session.id()
192 const subagents = await $.fs.list(`${project}/${id}/subagents`).catch(() => [])
193 // ponytail: $.fs.read stops at 4 MiB per file; a longer session counts from load.
194 const texts = await Promise.all([
195 $.fs.read(`${project}/${id}.jsonl`),
196 ...subagents.filter(f => f.name.endsWith('.jsonl')).map(f => $.fs.read(`${project}/${id}/subagents/${f.name}`)),
197 ])
198 const found = fromTranscript(texts)
199 await update($, session, () => found.session)
200 await update($, turn, () => found.turn)
201 await update($, turns, () => found.turns)
202 } catch {
203 await update($, isPartial, () => true)
204 }
205 await update($, isBackfilled, () => true)
206}
207
208const openPane = async ($: EngineInterface) => {
209 const all = await read($, session)
210 await $.ui.open({ id: PANE, title: 'Footprint', rows: 10 + Object.keys(all.models).length, focus: true, closeOnEscape: true })
211}
212
213export const register: Register = on => {
214 on('session.start', async ($, e, next) => {
215 await $.command.register({
216 name: 'footprint',
217 description: 'Estimated electricity and water of this session, by model and tool',
218 })
219 if (!(await read($, isBackfilled))) await backfill($)
220
221 return next(e)
222 })
223
224 on('session.end', async ($, e, next) => {
225 if (e.reason === 'clear') {
226 await update($, session, () => EMPTY)
227 await update($, turn, () => EMPTY)
228 await update($, turns, () => [])
229 await update($, isPartial, () => false)
230 }
231
232 return next(e)
233 })
234
235 on('command.run', { command: 'footprint' }, async $ => {
236 await openPane($)
237
238 return { text: 'Footprint opened.' }
239 })
240
241 on('prompt.submit', async ($, e, next) => {
242 await update($, turn, () => EMPTY)
243
244 return next(e)
245 })
246
247 // Every model request of the session, main loop and subagents alike.
248 on('turn.step', async function* ($, e, next) {
249 const step = yield* next(e)
250 if (step.usage) await record($, addTokens(`${step.usage.model}|${e.effort ?? 'default'}`, step.usage))
251
252 return step
253 })
254
255 // Side calls other mods make (a Haiku naming call, say).
256 on('model.complete', async ($, e, next) => {
257 const done = await next(e)
258 if (done.value) await record($, addTokens(`${e.model}|${e.effort ?? 'default'}`, done.value.usage))
259
260 return done
261 })
262
263 on('tool.call', async ($, e, next) => {
264 const startedAt = await $.clock.now()
265 const ran = await next(e)
266 const ms = WAITS.has(e.tool) ? 0 : (await $.clock.now()) - startedAt
267 await record($, addTool(e.tool, ms))
268
269 return ran
270 })
271
272 on('turn.complete', async ($, e, next) => {
273 if (e.agentId === undefined) {
274 const activity = await read($, turn)
275 await update($, turns, list => [...list, { durationMs: e.durationMs, activity }].slice(-200))
276 }
277
278 return next(e)
279 })
280
281 // Live, beside the engine's own time and tokens: "Ponderingβ¦ β‘ 0.4 Wh π§ 1.7 mL (12s Β· β 300 tokens)".
282 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
283 const now = await read($, turn)
284
285 return isEmpty(now) ? next(e) : next({ ...e, props: { ...e.props, suffix: `${e.props.suffix} ${glance(now)}` } })
286 })
287
288 // Each turn's closing line: "β» β‘β‘ 3.1 Wh π§π§ 13 mL Β· Baked for 5m 48s".
289 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
290 const list = await read($, turns)
291 const ms = e.props.durationMs
292 const hit = list.find(t => t.durationMs === ms) ?? list.find(t => Math.abs(t.durationMs - ms) < 1000)
293
294 return hit && !isEmpty(hit.activity)
295 ? next({ ...e, props: { ...e.props, word: `${glance(hit.activity)} Β· ${e.props.word}` } })
296 : next(e)
297 })
298
299 // The session so far, dim at the end of the hint line under the prompt.
300 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
301 const all = await read($, session)
302
303 return isEmpty(all) ? next(e) : next({ ...e, props: { ...e.props, tail: `${glance(all)} this session` } })
304 })
305
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const { Box, Text } = $.ui.resolve(e)
308 const all = await read($, session)
309 const now = await read($, turn)
310 const central = impact(all.models, toolMs(all)).wh[1]
311 const share = (b: Band) => (central > 0 ? `${Math.round((100 * b[1]) / central)}%` : '')
312 const cells = (widths: number[], parts: string[], dim: boolean[] = []) => (
313 <Box flexDirection="row">
314 {parts.map((part, i) => (
315 <Box width={widths[i] ?? 10}>
316 <Text dimColor={dim[i] ?? false} wrap="truncate">
317 {part}
318 </Text>
319 </Box>
320 ))}
321 </Box>
322 )
323 const total = (label: string, a: Activity) => {
324 const { wh, ml } = impact(a.models, toolMs(a))
325
326 return cells([9, 15, 15, 15, 16], [label, energy(wh), span(wh, 'Wh', 'kWh'), water(ml), span(ml, 'mL', 'L')], [false, false, true, false, true])
327 }
328 const ROW = [26, 46, 9, 6]
329 const models = Object.entries(all.models).sort(([a, x], [b, y]) => impact({ [b]: y }, 0).wh[1] - impact({ [a]: x }, 0).wh[1])
330 const tools = Object.entries(all.tools).sort(([, a], [, b]) => b.calls - a.calls)
331 const toolWh = impact({}, toolMs(all)).wh
332
333 return (
334 <Box flexDirection="column">
335 {total('Session', all)}
336 {total('Turn', now)}
337 <Text> </Text>
338 {models.map(([key, t]) => {
339 const [model = key, effort = ''] = key.split('|')
340 const wh = impact({ [key]: t }, 0).wh
341
342 return cells(
343 ROW,
344 [
345 `${model.replace(/^claude-/, '')} Β· ${effort}`,
346 `${t.steps} steps Β· out ${count(t.output)} Β· cache ${count(t.cacheRead + t.cacheWrite)} Β· in ${count(t.input)}`,
347 unit(wh[1], 'Wh', 'kWh'),
348 share(wh),
349 ],
350 [false, true, false, true],
351 )
352 })}
353 {tools.length > 0 &&
354 cells(
355 ROW,
356 [
357 'tools',
358 tools
359 .slice(0, 5)
360 .map(([tool, t]) => `${tool.replace(/^mcp__/, '')} ${t.calls}`)
361 .join(' Β· '),
362 unit(toolWh[1], 'Wh', 'kWh'),
363 share(toolWh),
364 ],
365 [false, true, false, true],
366 )}
367 <Text> </Text>
368 <Text dimColor wrap="wrap">
369 Per token: Watershed (2026) factors from Google's measured 0.24 Wh prompt, matching Oviedo et al. (2026) for
370 {'>'}200B models; ranges Γ0.5βΓ2. Cache reads at 10% (1β25%) of fresh input, an unmeasured convention (Hausfather
371 2026). Water: on-site cooling plus electricity generation (Li et al. 2025). Haiku at 1/6.
372 {(await read($, isPartial)) ? ' Counting since the mod loaded: the transcript was too long to read.' : ''}
373 </Text>
374 </Box>
375 )
376 })
377}
378types/index.d.ts 18 lines1// Raw activity only: the impact is computed from it at display time, so new
2// coefficients recalculate everything already recorded.
3export type Tokens = { steps: number; input: number; cacheWrite: number; cacheRead: number; output: number }
4export type ToolUse = { calls: number; ms: number }
5export type Activity = {
6 // keyed `${model}|${effort}`
7 models: Record<string, Tokens>
8 tools: Record<string, ToolUse>
9}
10// A finished main-loop turn, keyed by the duration its "Baked for" line shows.
11export type Turn = { durationMs: number; activity: Activity }
12
13declare module 'claude-code' {
14 interface PluginState {
15 footprint: { session: Activity; turn: Activity; turns: Turn[]; isBackfilled: boolean; isPartial: boolean }
16 }
17}
18