eleven / messenger Public

AI-build carbon tally: running ledger + public status page with accrual chart (#734)

* docs+status: AI-build carbon tally — running ledger, public dashboard page, weekly accrual

The carbon comparison doc's honesty section said "AI inference isn't
counted"; now the AI cost of BUILDING the app is. New section in
docs/comparison-carbon.md: exact token counts from local Claude Code
transcripts (~34M output tokens so far, first week extrapolated
commit-weighted after pruning), converted at 0.1–3 mg CO2e per output
token (Google/Mistral public calibration points, geometric-mean central
0.55) → ~3–100 kg, central ~19 kg — like driving a petrol car ~110 km,
or ~9,000 member-years of actually running the service.

The number accrues, so it ships as a running tally:

- web/status/carbon.json — the committed ledger (per-period entries +
  factors + totals), seeded with the first measurement.
- web/status/carbon.html — third public dashboard at
  status.elevenmessenger.com: cumulative chart over time (central line +
  low–high wash, crosshair + keyboard-focusable points), stat tiles,
  everyday equivalents, per-period ledger.
- tools/carbon-tally.mjs — sweeps transcripts (worktree sessions
  included), counts only usage newer than the last entry (re-runs never
  double-count), --write appends + recomputes totals.
- deploy/status-publish.sh republishes the committed tally with the
  pages; contract test pins the page fields AND the ledger arithmetic
  (totals = sum, kg = tokens × factors, periods non-overlapping).
- Keep-it-accruing notes: CLAUDE.md convention (weekly — transcripts
  prune!) + docs/status.md recipe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* status: carbon tally goes multi-contributor + self-nudging

Every contributor machine now accrues its own entries under a stable
anonymous source label (name-pair hashed from user@host — the tally is
public, so no hostnames, same rule as the fleet's box labels; override
with LCHAT_CARBON_SOURCE). Cutoffs are per-source and consult both the
working tree and origin/main, so re-runs, other machines, and parallel
branches never double-count; the one blind spot (two open branches
accruing the same window) is caught by the contract test's per-source
overlap check — keep tally commits in tiny promptly-merged PRs.

Automation: a SessionStart hook in .claude/settings.json runs the new
fast `carbon-tally.mjs check` (no transcript sweep, ~50ms) and tells the
session to accrue when this machine's entries are >5 days old — the
transcripts it measures from prune after about a week, so the nudge is
what keeps the ledger gapless.

Page: ledger rows and the chart tooltip carry the source; entries render
in time order even when machines interleave. Docs + CLAUDE.md convention
updated for the automated flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 03447e00d40e5875847dbe279ea0a357bee8c06e parent 2a7ae74
11 files changed, +791 −12
  • .claude/settings.json +14 −0
  • CLAUDE.md +15 −0
  • deploy/status-publish.sh +11 −2
  • docs/comparison-carbon.md +64 −3
  • docs/status.md +54 −3
  • test/status-contract.test.mjs +49 −2
  • tools/carbon-tally.mjs +173 −0
  • web/status/books.html +1 −1
  • web/status/carbon.html +362 −0
  • web/status/carbon.json +47 −0
  • web/status/index.html +1 −1
diff --git a/.claude/settings.json b/.claude/settings.json
index d0c4ced..d3c6c30 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,5 +1,19 @@
{
"enabledPlugins": {
"stripe@claude-plugins-official": true
+ },
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node tools/carbon-tally.mjs check 2>/dev/null || true",
+ "timeout": 15,
+ "statusMessage": "Checking carbon tally freshness"
+ }
+ ]
+ }
+ ]
}
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 11c4875..ca8c231 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -313,6 +313,21 @@ open with the note itself) carry the 🤖 on their first line.
hand-measured and drift every release, so re-measure and update them (and the
"as of" date) every so often — a quick `git ls-files '*.go' …| wc -l` for
lines, and `du -h` on the built binaries / `.app` bundles for sizes.
+- 🤖 **Keep the carbon tally accruing — act on the session nudge.**
+ `web/status/carbon.json` is the public running estimate of the AI tokens
+ spent building this app, per contributor machine (served at
+ status.elevenmessenger.com/carbon.html; methodology
+ `docs/comparison-carbon.md`). A `SessionStart` hook runs
+ `node tools/carbon-tally.mjs check` and tells the session when this
+ machine's entries are stale (>5 days) — its raw material, local Claude Code
+ transcripts, prunes after about a week, so a missed week is lost to the
+ ledger. When nudged (or before starting long work): run
+ `node tools/carbon-tally.mjs --write` (per-source cutoffs — re-runs and
+ other machines never double-count), commit the tally as a **tiny standalone
+ PR merged promptly** (two open branches accruing the same window is the one
+ race the cutoffs can't see; the contract test catches it on main), and copy
+ it to the router box's status-pages dir (`docs/status.md § Keeping the
+ carbon tally accruing`).
## 🤖 Cross-platform event contract (web ↔ native)
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh
index b538957..e50556a 100755
--- a/deploy/status-publish.sh
+++ b/deploy/status-publish.sh
@@ -39,6 +39,11 @@
# lines, or timestamps of individual payments. The Stripe key is read for
# the API calls and must never be echoed, logged, or uploaded.
#
+# carbon.json — NOT generated here: it's COMMITTED data (the AI-build carbon
+# tally, web/status/carbon.json, accrued by tools/carbon-tally.mjs),
+# staged with the pages and republished as-is. Aggregate token counts and
+# kg estimates only — never per-session data.
+#
# Adding a field? It must be an aggregate over the whole fleet / all payers,
# or it doesn't ship.
# ==============================================================================
@@ -64,7 +69,8 @@
# (infra/prod/status.tf; Put/Get/List on the status
# bucket only, no Delete). chown lchat, chmod 0600.
# /opt/lchat/status-pages/ — the viewer pages, copied from web/status/
-# (index.html, books.html, icon-192.png).
+# (index.html, books.html, carbon.html, carbon.json,
+# icon-192.png).
# aws CLI + sqlite3 (apt install awscli sqlite3).
set -euo pipefail
@@ -553,11 +559,14 @@ publish() {
if [ -d "$PAGES_DIR" ]; then
[ -f "$PAGES_DIR/index.html" ] && upload "$PAGES_DIR/index.html" index.html "text/html; charset=utf-8" "max-age=300"
[ -f "$PAGES_DIR/books.html" ] && upload "$PAGES_DIR/books.html" books.html "text/html; charset=utf-8" "max-age=300"
+ [ -f "$PAGES_DIR/carbon.html" ] && upload "$PAGES_DIR/carbon.html" carbon.html "text/html; charset=utf-8" "max-age=300"
+ # The carbon tally is committed data (see header) — republished, not built.
+ [ -f "$PAGES_DIR/carbon.json" ] && upload "$PAGES_DIR/carbon.json" carbon.json "application/json" "max-age=300"
[ -f "$PAGES_DIR/icon-192.png" ] && upload "$PAGES_DIR/icon-192.png" icon-192.png "image/png" "max-age=86400"
else
echo "warning: $PAGES_DIR missing — JSONs published, pages skipped (copy web/status/* there)" >&2
fi
- echo "published to s3://$BUCKET (fleet + books, $(now_iso))"
+ echo "published to s3://$BUCKET (fleet + books + pages, $(now_iso))"
}
# ---- install (root) ----------------------------------------------------------
diff --git a/docs/comparison-carbon.md b/docs/comparison-carbon.md
index 6c42214..8b85601 100644
--- a/docs/comparison-carbon.md
+++ b/docs/comparison-carbon.md
@@ -136,9 +136,12 @@ Honesty section, in the spirit of the siblings:
market-based-zero renewable contract versus this stack on a coal grid
inverts the operational numbers. Embodied carbon — ~10 servers versus
~500 — favors the small fleet regardless of who signs which PPA.
-- **AI inference isn't counted.** Heavy LLM use by the bot sidecars would
- dwarf either infrastructure's footprint — and applies to both models
- equally, so it cancels out of the ratio and dominates the absolutes.
+- **Runtime AI inference isn't counted.** Heavy LLM use by the bot sidecars
+ would dwarf either infrastructure's footprint — and applies to both models
+ equally, so it cancels out of the ratio and dominates the absolutes. (It is
+ at least *measurable* exactly: the sidecars run on an API key, so the
+ provider console has precise token counts.) The AI cost of *building* the
+ app, though, is now estimated — see the next section.
- **Client-side work moves joules, it doesn't create many.** Crypto and
image downscaling run on members' devices; the marginal energy per
message on a phone is micro-joules against a battery already being
@@ -148,6 +151,58 @@ Honesty section, in the spirit of the siblings:
the gap further; this comparison deliberately gives Rails the benefit of
doing only what Eleven does.
+## What the AI that built it cost
+
+The estimates above stop at the servers. But this codebase is AI-written,
+and the honest ledger has to include the tokens it took to write it — which
+turn out to dwarf, by orders of magnitude, the infrastructure they produced
+(at today's scale; at the 1M-space scale above, the fleet takes over).
+
+**The tokens are measurable.** Claude Code keeps per-session transcripts
+with exact token counts. Summed over every session against this repo on the
+primary dev machine (the first measurement, 2026-07-03 → 2026-07-11, 162
+sessions — transcripts from the repo's first week, 2026-06-27 → 07-02, had
+already been auto-pruned, so that week is extrapolated from the measured
+window's per-commit rate):
+
+| | tokens |
+|---|---|
+| Output (generated text + code) | **~25M measured + ~9M extrapolated ≈ 34M** |
+| Fresh input (prompt prefill) | ~145M measured |
+| Cache reads | ~7.9B measured |
+
+**The conversion is the soft part.** No frontier lab publishes per-token
+energy or carbon. The best public calibration points disagree by ~30×:
+Google's production study of Gemini serving works out to roughly 0.1 mg
+CO₂e per output token, while Mistral's lifecycle study for Large 2 implies
+~2.9 mg. We band with **0.1–3 mg CO₂e per output token, central 0.55 mg**
+(the geometric mean), priced on output tokens only — decode dominates
+serving energy, and nobody publishes a price for cached prefill, which is
+~97% of tokens processed here. That un-priced cache-read pile, plus
+cloud-side agent runs and any contributor machine that hasn't accrued its
+entries yet, make this a floor.
+
+**Result so far: ~3–100 kg CO₂e, central ~19 kg** — like driving a petrol
+car ~110 km, or ~8 litres of petrol. Set against the fleet's ~2 g per member
+per year, the central estimate equals roughly **9,000 member-years of
+actually running the service**. E2EE keeps the *servers* from burning watts
+on members' content; the watts moved upstream, into the model that wrote
+the code.
+
+**This number accrues, so it's kept as a running tally**: the ledger lives
+in [`web/status/carbon.json`](../web/status/carbon.json) and publishes with
+the public dashboards at
+[status.elevenmessenger.com/carbon.html](https://status.elevenmessenger.com/carbon.html)
+(cumulative chart + per-period ledger; `docs/status.md`). It covers **every
+contributor machine**, each accruing entries under an anonymous stable
+`source` label, and **keeping it up to date is automated**: a `SessionStart`
+hook nudges any Claude session in this repo when its machine's entries go
+stale (the transcripts it measures from are pruned after about a week), and
+`node tools/carbon-tally.mjs --write` appends only what's newer than that
+machine's last entry — recipe in `docs/status.md § Keeping the carbon tally
+accruing`. A gap that slips past the pruning window gets an
+explicitly-flagged `extrapolated` entry, like the first week did.
+
## The one-sentence version
**~2 g versus ~75 g CO₂e per member per year — the difference between an
@@ -162,9 +217,15 @@ provisioning forever.**
- [Datadog Container Report — utilization findings](https://www.datadoghq.com/container-report/)
- [AWS sustainability — fleet PUE](https://sustainability.aboutamazon.com/products-services/aws-cloud)
- [IEA — global electricity carbon intensity](https://www.iea.org/energy-system/electricity)
+- [Google Cloud — measuring the environmental impact of AI inference](https://cloud.google.com/blog/products/infrastructure/measuring-the-environmental-impact-of-ai-inference)
+- [Mistral AI — lifecycle analysis of Large 2](https://mistral.ai/news/our-contribution-to-a-global-environmental-standard-for-ai)
+- [Epoch AI — how much energy does ChatGPT use?](https://epoch.ai/gradient-updates/how-much-energy-does-chatgpt-use)
+- [EcoLogits — provider-side LLM impact methodology](https://ecologits.ai/)
- Live fleet measurements: production router box, 2026-07-11 (per-unit
RSS via systemd; 15 warm instances + 4 services + 5 sidecars ≈ 550 MB,
load 0.21 on 2 ARM vCPUs)
+- Build-token measurements: local Claude Code transcripts, swept by
+ `tools/carbon-tally.mjs` into `web/status/carbon.json` (the running tally)
- The plan: [#550 — planet-scale over S3](https://github.com/elevenmessenger/messenger/issues/550)
*Snapshot as of July 2026 — coefficients and grids move; re-measure before
diff --git a/docs/status.md b/docs/status.md
index 4727c90..dbca0a4 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -1,6 +1,6 @@
-# 🤖 Public status dashboards — fleet state & the books
+# 🤖 Public status dashboards — fleet state, the books & the carbon tally
-Two world-readable pages, published every 5 minutes from the router box: the
+Three world-readable pages, published every 5 minutes from the router box: the
running half of #550 Part 11/12's transparency direction (the append-only
public *ledger* repo is still roadmap — see `docs/planet-scale.md`).
@@ -13,6 +13,13 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`).
- **The books** (`books.html` + `books.json`) — income, in aggregate: active
paid subscriptions (total and by tier — tier names are the public price
book), MRR in EUR, and gross collected this month.
+- **The carbon tally** (`carbon.html` + `carbon.json`) — the running estimate
+ of the CO₂e cost of the LLM tokens spent *building* Eleven, as a cumulative
+ chart + ledger with everyday equivalents. Unlike the other two feeds, the
+ JSON is **committed data** (`web/status/carbon.json`, accrued by
+ `tools/carbon-tally.mjs` — see *Keeping the carbon tally accruing* below);
+ the box timer just republishes it with the pages. Methodology + error bars:
+ `docs/comparison-carbon.md`.
Pieces: generator `deploy/status-publish.sh` (box timer, mirrors
`deploy/fleet-backup.sh`), viewer pages `web/status/`, bucket + IAM
@@ -41,6 +48,9 @@ aggregation boundary lives in the generator and holds by construction:
customer ids, emails, subscription ids, per-payer lines, or timestamps of
individual payments. Expenses/disbursements publish *itemized* — but those
belong to the future ledger repo, not these dashboards.
+- **Carbon: aggregate token counts and kg estimates only.** Never per-session
+ data, never prompt or transcript content — the tally reads token *usage
+ numbers* from local transcripts and publishes only their sums.
- Adding a field? It must be an aggregate over the whole fleet / all payers,
or it doesn't ship. The `--dry-run` self-check and the contract test both
fail if a fixture hostname or per-payer id reaches the output.
@@ -105,6 +115,47 @@ to a month (yearly ÷ 12, etc.), EUR items only. Collected-this-month is the
gross of positive `charge`/`payment` balance transactions since the month
started (refunds and payouts excluded — it's gross income, not net cash).
+`carbon.json` (committed at `web/status/carbon.json`, republished as-is): an
+`entries[]` ledger of measurement periods — `period_start`/`period_end`,
+`source` (which contributor machine, as an anonymous name-pair label — same
+no-hostnames rule as the fleet's box labels), `basis`
+(`measured` | `extrapolated`), `sessions`, `output_tokens`,
+`fresh_input_tokens`, `cache_read_tokens`, `kg_low`/`kg_central`/`kg_high` —
+plus the conversion `factors_mg_co2e_per_output_token`, a `totals` block, and
+a `not_counted` honesty string. The contract test pins the arithmetic: totals
+must equal the ledger sum, each entry's kg must derive from its tokens × the
+factors, and each source's periods must be chronological and non-overlapping.
+
+## Keeping the carbon tally accruing
+
+The tally's raw material is the local Claude Code transcripts on **each
+contributor's machine**, and those are **pruned after roughly a week** — a
+missed week is lost to the ledger and has to be extrapolated by hand (like
+the tally's first entry). Every contributor machine accrues its own entries
+under a stable anonymous `source` label (hashed from user@host; the tool
+prints yours; override with `LCHAT_CARBON_SOURCE`).
+
+**The nudge is automatic.** A `SessionStart` hook in `.claude/settings.json`
+runs `node tools/carbon-tally.mjs check` (fast — no transcript sweep): when
+this machine's newest entry is older than 5 days (`LCHAT_CARBON_STALE_DAYS`),
+the session is told to accrue. The accrual itself:
+
+1. `node tools/carbon-tally.mjs --write` (from the repo root) — sweeps this
+ machine's transcripts and appends a `measured` entry. Cutoffs are
+ **per-source** and consult both the working tree and `origin/main`, so
+ repeated runs and parallel machines never double-count.
+2. Commit the updated `web/status/carbon.json` — prefer a tiny standalone PR,
+ merged promptly: two long-lived branches accruing the same window is the
+ one race the cutoffs can't see, and the contract test fails main on the
+ overlap (fix: drop the younger duplicate entry, re-run `--write`).
+3. Copy `web/status/carbon.json` to the box's `/opt/lchat/status-pages/` (with
+ `carbon.html` if the page changed); the next 5-minute publish uploads it.
+
+The conversion factors live in the JSON so a recalibration (better public
+data) is one committed change; if the factors change, recompute the whole
+ledger's kg columns rather than mixing eras (`docs/comparison-carbon.md`
+carries the sources and the "as of" date).
+
## Install (router box)
1. **Apply the infra** (deliberate, named — not yet applied):
@@ -114,7 +165,7 @@ started (refunds and payouts excluded — it's gross income, not net cash).
2. **Creds on the box**: write `/data/status.env` with
`AWS_ACCESS_KEY_ID=…` / `AWS_SECRET_ACCESS_KEY=…`; `chown lchat:lchat`,
`chmod 0600`.
-3. **Pages on the box**: `scp web/status/{index.html,books.html,icon-192.png}
+3. **Pages on the box**: `scp web/status/{index.html,books.html,carbon.html,carbon.json,icon-192.png}
root@<box>:/opt/lchat/status-pages/`.
4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/`
then `ssh root@<box> /opt/lchat/status-publish.sh install`. The unit runs
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs
index e6b3470..3137ac3 100644
--- a/test/status-contract.test.mjs
+++ b/test/status-contract.test.mjs
@@ -57,6 +57,10 @@ function resolve(obj, path) {
const indexHtml = read("web/status/index.html");
const booksHtml = read("web/status/books.html");
+// The carbon tally is COMMITTED data (accrued by tools/carbon-tally.mjs), not
+// dry-run output — the real file is its own fixture.
+const carbon = JSON.parse(read("web/status/carbon.json"));
+const carbonHtml = read("web/status/carbon.html");
await test("fleet page fields all exist in the generator's fleet.json", () => {
for (const f of fields(indexHtml)) {
@@ -71,10 +75,16 @@ await test("books page fields all exist in one of the two books shapes", () => {
}
});
+await test("carbon page fields all exist in the committed carbon.json", () => {
+ for (const f of fields(carbonHtml)) {
+ assert.notEqual(resolve(carbon, f), undefined, "carbon.json is missing " + f);
+ }
+});
+
// A manifest entry nobody reads is drift too: each leaf property must appear
// in the page's render code, not just the manifest.
await test("every declared field is actually read by its page", () => {
- for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)]]) {
+ for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)], [carbonHtml, fields(carbonHtml)]]) {
const body = html.slice(html.indexOf("];") + 2);
for (const f of fs) {
const leaf = f.split(".").pop().replace("[]", "");
@@ -94,12 +104,49 @@ await test("degraded books shape carries available:false + reason", () => {
// belt and suspenders on what's in git): no fixture hostname, subscription,
// customer, or transaction id may appear in published output.
await test("fixtures contain no per-payer ids or hostnames", () => {
- for (const j of [fleet, fleetGF, fleetAC, books, booksDown]) {
+ for (const j of [fleet, fleetGF, fleetAC, books, booksDown, carbon]) {
const s = JSON.stringify(j);
assert.ok(!/sub_|cus_|txn_|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80));
}
});
+// The carbon tally must stay internally consistent as entries accrue — the
+// totals are the sum of the ledger, each entry's kg derives from its output
+// tokens × the published factors, and each SOURCE's periods are chronological
+// and non-overlapping (tools/carbon-tally.mjs appends strictly after that
+// source's last period_end; an overlap means two branches double-counted the
+// same window — drop the younger entry and re-run --write). Sources are
+// anonymous name-pair labels, same rule as the fleet's box labels: the tally
+// is public, so never a hostname or username.
+await test("carbon tally arithmetic + per-source chronology hold", () => {
+ const f = carbon.factors_mg_co2e_per_output_token;
+ assert.ok(f.low < f.central && f.central < f.high, "factors ordered");
+ let out = 0, kg = { low: 0, central: 0, high: 0 };
+ const prevEnd = new Map();
+ for (const e of carbon.entries) {
+ assert.match(e.source, /^[a-z]+-[a-z]+$/, "source must be a name-pair label, got " + e.source);
+ assert.ok(["measured", "extrapolated"].includes(e.basis), "basis word: " + e.basis);
+ assert.ok(Date.parse(e.period_start) < Date.parse(e.period_end), "period runs forward");
+ assert.ok(Date.parse(e.period_start) >= (prevEnd.get(e.source) ?? -Infinity),
+ e.source + " periods overlap at " + e.period_start);
+ prevEnd.set(e.source, Date.parse(e.period_end));
+ for (const band of ["low", "central", "high"]) {
+ const want = (e.output_tokens * f[band]) / 1e6; // mg → kg
+ assert.ok(Math.abs(e["kg_" + band] - want) <= 0.06,
+ "entry kg_" + band + " ≠ tokens × factor (" + e["kg_" + band] + " vs " + want.toFixed(2) + ")");
+ kg[band] += e["kg_" + band];
+ }
+ out += e.output_tokens;
+ }
+ assert.equal(carbon.totals.output_tokens, out, "totals.output_tokens is the ledger sum");
+ for (const band of ["low", "central", "high"]) {
+ assert.ok(Math.abs(carbon.totals["kg_" + band] - kg[band]) <= 0.05,
+ "totals.kg_" + band + " is the ledger sum");
+ }
+ assert.equal(Date.parse(carbon.updated_at), Math.max(...carbon.entries.map((e) => Date.parse(e.period_end))),
+ "updated_at is the newest period_end");
+});
+
// Capacity + hosts publish ABSTRACT levels only (Paul's call: bucketed words,
// never needles). The schema is an ALLOWLIST — any extra key under hosts /
// capacity fails, so a precise number (remaining slots, GB, percentages, cap
diff --git a/tools/carbon-tally.mjs b/tools/carbon-tally.mjs
new file mode 100644
index 0000000..caff488
--- /dev/null
+++ b/tools/carbon-tally.mjs
@@ -0,0 +1,173 @@
+#!/usr/bin/env node
+// 🤖 Accrue the public AI-build carbon tally (web/status/carbon.json) from
+// local Claude Code transcripts — multi-contributor.
+//
+// The tally is the running estimate of the CO₂e cost of the LLM tokens spent
+// building this app (methodology + error bars: docs/comparison-carbon.md).
+// Claude Code writes per-session JSONL transcripts with exact token usage
+// under ~/.claude/projects/<sanitized-checkout-path>/; those transcripts are
+// PRUNED after roughly a week, so this must run at least weekly on EVERY
+// contributor machine or its tokens are lost to the ledger (an unmeasured gap
+// has to be extrapolated by hand). A SessionStart hook in .claude/settings.json
+// runs `check` so agent sessions get nudged automatically when stale.
+//
+// node tools/carbon-tally.mjs # print the would-be entry (dry run)
+// node tools/carbon-tally.mjs --write # append it + recompute totals
+// node tools/carbon-tally.mjs check # fast staleness nudge (the hook);
+// # prints only when stale, always exit 0
+//
+// Each machine contributes entries under a stable anonymous `source` label (a
+// name-pair hashed from user@host, same style as the fleet's box labels — the
+// tally is PUBLIC, and the dashboards' rule is no hostnames, ever; override
+// with LCHAT_CARBON_SOURCE). Cutoffs are per-source: a run counts only usage
+// newer than this source's last entry, consulting BOTH the working tree and
+// origin/main's copy, so re-runs and parallel branches never double-count.
+// Residual race: two unmerged branches accruing the same window — the contract
+// test's per-source overlap check catches it at merge; keep tally commits in
+// tiny, promptly-merged PRs.
+import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
+import { execSync } from "node:child_process";
+import { join } from "node:path";
+import os from "node:os";
+
+const mode = process.argv.includes("check") ? "check" : process.argv.includes("--write") ? "write" : "dry";
+const STALE_DAYS = Number(process.env.LCHAT_CARBON_STALE_DAYS || 5);
+
+const git = (args) => execSync("git " + args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
+
+// Stable, public-safe source label for this machine: never a hostname (the
+// dashboards' privacy rule) — a name-pair hashed from user@host, recognisable
+// to the contributor (run the tool: it prints yours) and meaningless outside.
+const NAMES = ["ada", "grace", "alan", "edsger", "radia", "hedy", "katherine", "margaret",
+ "linus", "barbara", "donald", "hypatia", "annie", "vint", "niklaus", "frances"];
+const DOINGS = ["stargazing", "whistling", "humming", "sketching", "wandering", "gardening",
+ "juggling", "sailing", "painting", "daydreaming", "tinkering", "strolling", "fiddling",
+ "doodling", "pottering", "moseying"];
+function sourceLabel() {
+ if (process.env.LCHAT_CARBON_SOURCE) return process.env.LCHAT_CARBON_SOURCE;
+ const seed = os.userInfo().username + "@" + os.hostname().split(".")[0];
+ let h = 2166136261; // FNV-1a
+ for (const c of seed) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619) >>> 0; }
+ return NAMES[h % NAMES.length] + "-" + DOINGS[(h >>> 4) % DOINGS.length];
+}
+const source = sourceLabel();
+
+// The main checkout's path names the transcript dir — resolve it even when
+// run from a worktree, and prefix-match so worktree sessions count too.
+const repoRoot = join(git("rev-parse --path-format=absolute --git-common-dir").trim(), "..");
+const sanitized = repoRoot.replace(/[^a-zA-Z0-9]/g, "-");
+
+// The tally in the checkout this script runs from is the one appended to; the
+// per-source cutoff also consults origin/main's copy, so a fresh branch can't
+// re-count a window another branch already merged.
+const tallyPath = join(process.cwd(), "web/status/carbon.json");
+if (!existsSync(tallyPath)) {
+ console.error("missing " + tallyPath + " — run from the repo root");
+ process.exit(mode === "check" ? 0 : 1);
+}
+const tally = JSON.parse(readFileSync(tallyPath, "utf8"));
+let mainEntries = [];
+try { mainEntries = JSON.parse(git("show origin/main:web/status/carbon.json")).entries; } catch {}
+const known = [...tally.entries, ...mainEntries];
+const cutoff = Math.max(0, ...known.filter((e) => e.source === source).map((e) => Date.parse(e.period_end)));
+
+if (mode === "check") {
+ // Fast path for the SessionStart hook: no transcript sweep, print only when
+ // stale (stdout lands in the agent's context), never fail the session.
+ const days = cutoff ? (Date.now() - cutoff) / 86400e3 : Infinity;
+ if (days > STALE_DAYS) {
+ const age = cutoff ? "last entry from this machine (" + source + ") is " + Math.floor(days) + " days old"
+ : "no entries yet from this machine (" + source + ")";
+ console.log("Carbon tally is stale: " + age + ". Local transcripts prune after ~a week, so, " +
+ "when convenient this session, accrue it: run `node tools/carbon-tally.mjs --write`, commit " +
+ "web/status/carbon.json (a tiny standalone PR, merged promptly), and copy it to the router box's " +
+ "status-pages dir (docs/status.md § Keeping the carbon tally accruing).");
+ }
+ process.exit(0);
+}
+
+const projectsDir = join(os.homedir(), ".claude", "projects");
+const dirs = readdirSync(projectsDir).filter((d) => d.startsWith(sanitized)).map((d) => join(projectsDir, d));
+if (!dirs.length) {
+ console.error("no transcript dirs under " + projectsDir + " match " + sanitized);
+ process.exit(1);
+}
+
+const seen = new Set();
+const byModel = new Map();
+let out = 0, fresh = 0, cacheRead = 0, noTs = 0;
+let tsMin = Infinity, tsMax = -Infinity;
+const contributing = new Set();
+for (const dir of dirs) {
+ for (const f of readdirSync(dir).filter((n) => n.endsWith(".jsonl"))) {
+ const path = join(dir, f);
+ for (const line of readFileSync(path, "utf8").split("\n")) {
+ if (!line) continue;
+ let j;
+ try { j = JSON.parse(line); } catch { continue; }
+ const m = j.message;
+ const u = m && m.usage;
+ if (!u) continue;
+ const ts = Date.parse(j.timestamp || "");
+ if (Number.isNaN(ts)) { noTs++; continue; } // dateless usage can't be windowed — skipped, reported below
+ if (ts <= cutoff) continue;
+ const key = (m.id || "") + "/" + (j.requestId || "");
+ if (key !== "/" && seen.has(key)) continue;
+ seen.add(key);
+ contributing.add(path);
+ tsMin = Math.min(tsMin, ts);
+ tsMax = Math.max(tsMax, ts);
+ out += u.output_tokens || 0;
+ fresh += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
+ cacheRead += u.cache_read_input_tokens || 0;
+ const model = m.model || "unknown";
+ byModel.set(model, (byModel.get(model) || 0) + (u.output_tokens || 0));
+ }
+ }
+}
+
+if (out === 0) {
+ console.error("nothing newer than " + new Date(cutoff).toISOString() + " for " + source + " — tally already up to date");
+ process.exit(0);
+}
+if (noTs) console.error("note: skipped " + noTs + " usage lines without timestamps");
+
+const factors = tally.factors_mg_co2e_per_output_token;
+const kg = (f) => Math.round((out * f) / 1e6 * 10) / 10; // mg → kg, 1 decimal
+const entry = {
+ period_start: new Date(tsMin).toISOString(),
+ period_end: new Date(tsMax).toISOString(),
+ source,
+ basis: "measured",
+ sessions: contributing.size,
+ output_tokens: out,
+ fresh_input_tokens: fresh,
+ cache_read_tokens: cacheRead,
+ kg_low: kg(factors.low),
+ kg_central: kg(factors.central),
+ kg_high: kg(factors.high),
+ note: "local Claude Code transcripts, one dev machine",
+};
+
+console.error("source: " + source + " · output tokens by model:");
+for (const [model, n] of [...byModel].sort((a, b) => b[1] - a[1])) {
+ console.error(" " + model + ": " + n.toLocaleString());
+}
+console.log(JSON.stringify(entry, null, 2));
+
+if (mode === "write") {
+ tally.entries.push(entry);
+ const sum = (pick) => Math.round(tally.entries.reduce((a, e) => a + pick(e), 0) * 10) / 10;
+ tally.totals = {
+ output_tokens: tally.entries.reduce((a, e) => a + e.output_tokens, 0),
+ kg_low: sum((e) => e.kg_low),
+ kg_central: sum((e) => e.kg_central),
+ kg_high: sum((e) => e.kg_high),
+ };
+ tally.updated_at = new Date(Math.max(...tally.entries.map((e) => Date.parse(e.period_end)))).toISOString();
+ writeFileSync(tallyPath, JSON.stringify(tally, null, 2) + "\n");
+ console.error("appended to " + tallyPath + " — totals now " + tally.totals.kg_central + " kg central (" +
+ tally.totals.kg_low + "–" + tally.totals.kg_high + ")");
+} else {
+ console.error("dry run — pass --write to append");
+}
diff --git a/web/status/books.html b/web/status/books.html
index be710bc..819e5af 100644
--- a/web/status/books.html
+++ b/web/status/books.html
@@ -112,7 +112,7 @@
</div>
<footer>
- <p><a href="./index.html">Fleet status</a></p>
+ <p><a href="./index.html">Fleet status</a> · <a href="./carbon.html">The carbon tally</a></p>
<p>Published every 5 minutes from Stripe aggregates. Tier names are the
public price book; amounts are EUR. Expenses and disbursements will join
these pages as the public ledger lands (see the roadmap in
diff --git a/web/status/carbon.html b/web/status/carbon.html
new file mode 100644
index 0000000..6f78b35
--- /dev/null
+++ b/web/status/carbon.html
@@ -0,0 +1,362 @@
+<!-- 🤖 Public AI-build carbon tally (the honesty leg of docs/comparison-carbon.md).
+ Fully self-contained: inline CSS/JS, no external requests except
+ ./carbon.json (relative). Unlike fleet/books, the JSON is COMMITTED data
+ (web/status/carbon.json, accrued by tools/carbon-tally.mjs), republished
+ as-is by deploy/status-publish.sh — aggregate token counts and kg only. -->
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<meta name="color-scheme" content="light dark" />
+<title>Eleven — the carbon tally</title>
+<link rel="icon" type="image/png" href="./icon-192.png" />
+<style>
+ /* Same validated palette roles as index.html (dataviz six-checks, light +
+ dark); violet series hue, secondary ink for all small text (>=4.5:1). */
+ :root {
+ --plane: #f9f9f7; --surface: #fcfcfb;
+ --ink: #0b0b0b; --ink-2: #52514e;
+ --grid: #e1e0d9; --border: rgba(11,11,11,0.10);
+ --series: #4a3aa7;
+ --bad: #b23030; --bad-mark: #d03b3b;
+ --link: #4a3aa7;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --plane: #0d0d0d; --surface: #1a1a19;
+ --ink: #ffffff; --ink-2: #c3c2b7;
+ --grid: #2c2c2a; --border: rgba(255,255,255,0.10);
+ --series: #9085e9;
+ --bad: #e66767; --bad-mark: #e66767;
+ --link: #9085e9;
+ }
+ }
+ * { box-sizing: border-box; margin: 0; }
+ body {
+ background: var(--plane); color: var(--ink);
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
+ padding: 24px 16px 48px;
+ }
+ .wrap { max-width: 760px; margin: 0 auto; }
+ a { color: var(--link); }
+ a:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; }
+
+ header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
+ header img { width: 44px; height: 44px; border-radius: 10px; }
+ header h1 { font-size: 22px; font-weight: 650; }
+ header .brand { color: var(--ink-2); font-size: 14px; }
+ .lede { color: var(--ink-2); margin: 6px 0 2px; }
+ .asof { font-weight: 600; margin: 10px 0 24px; }
+
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 12px; }
+ .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
+ .tile .label { font-size: 13px; color: var(--ink-2); }
+ .tile .value { font-size: 30px; font-weight: 600; margin-top: 2px; }
+ .equiv { color: var(--ink-2); margin: 0 0 28px; }
+
+ section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; }
+ section { margin-bottom: 28px; }
+
+ /* Cumulative chart: single series (no legend box — the title names it),
+ 2px line, 10%-wash uncertainty band, hairline gridlines, crosshair +
+ one tooltip. The ledger list below is the chart's table view. */
+ figure { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 8px 6px; position: relative; }
+ figure svg { display: block; width: 100%; height: auto; }
+ figcaption { color: var(--ink-2); font-size: 13px; padding: 4px 8px 8px; }
+ #tip {
+ display: none; position: absolute; z-index: 1; pointer-events: none;
+ background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
+ padding: 8px 10px; font-size: 13px; max-width: 240px; box-shadow: 0 2px 8px rgba(0,0,0,0.12);
+ }
+ #tip .big { font-size: 15px; font-weight: 650; }
+ #tip .sub { color: var(--ink-2); }
+ .pt:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 50%; }
+
+ /* Measurement ledger: single-series horizontal bars (kg, central estimate);
+ rows are real text, bars aria-hidden — same mark spec as books.html. */
+ .bars { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
+ .bars li { display: grid; grid-template-columns: minmax(120px, 200px) 1fr 5.5em; align-items: center; gap: 12px; padding: 10px 16px; }
+ .bars li + li { border-top: 1px solid var(--grid); }
+ .bars .period { font-size: 14px; font-weight: 600; }
+ .bars .basis { display: block; font-size: 12px; font-weight: 400; color: var(--ink-2); }
+ .bars .track { height: 12px; }
+ .bars .bar { height: 12px; min-width: 2px; background: var(--series); border-radius: 0 4px 4px 0; }
+ .bars .kg { font-size: 14px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
+
+ #error { display: none; background: var(--surface); border: 1px solid var(--bad-mark); border-radius: 12px; padding: 14px 16px; margin-bottom: 24px; }
+ #error.show { display: block; }
+
+ footer { margin-top: 36px; color: var(--ink-2); font-size: 14px; }
+ footer p { margin-top: 6px; }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <img src="./icon-192.png" alt="" />
+ <div>
+ <p class="brand">Eleven Messenger</p>
+ <h1>The carbon tally</h1>
+ </div>
+ </header>
+ <p class="lede">Eleven is AI-written, and the honest carbon ledger includes
+ the AI: this is a running estimate of the CO₂e cost of the LLM tokens spent
+ <em>building</em> the app. Token counts are exact; the carbon conversion is
+ an estimate with wide error bars, because no model provider publishes
+ per-token figures — <span id="factors">—</span>. Full methodology and
+ honesty notes:
+ <a href="https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md">the
+ carbon comparison</a>.</p>
+ <p class="asof">Last measured <time id="asof" datetime="">—</time></p>
+
+ <p id="error" role="alert">Couldn’t load the carbon tally. It may be mid-publish — this page retries every minute.</p>
+
+ <div id="tally">
+ <section aria-labelledby="h-total">
+ <h2 id="h-total">Estimated so far</h2>
+ <div class="tiles">
+ <div class="tile"><div class="label">Central estimate</div><div class="value" id="t-central">—</div></div>
+ <div class="tile"><div class="label">Honest range</div><div class="value" id="t-range">—</div></div>
+ <div class="tile"><div class="label">Output tokens</div><div class="value" id="t-tokens">—</div></div>
+ </div>
+ <p class="equiv" id="equiv"></p>
+ </section>
+
+ <section aria-labelledby="h-chart">
+ <h2 id="h-chart">Accrual over time</h2>
+ <figure>
+ <svg id="chart" viewBox="0 0 720 240" role="img" aria-label=""></svg>
+ <div id="tip"></div>
+ <figcaption>Cumulative kg CO₂e — the line is the central estimate, the
+ wash is the low–high band. Exact figures are in the ledger below.</figcaption>
+ </figure>
+ </section>
+
+ <section aria-labelledby="h-entries">
+ <h2 id="h-entries">The ledger (central kg per period)</h2>
+ <ul class="bars" id="entries"><li><span class="period">Loading…</span></li></ul>
+ <p class="equiv" id="notcounted"></p>
+ </section>
+ </div>
+
+ <footer>
+ <p><a href="./index.html">Fleet status</a> · <a href="./books.html">The books</a></p>
+ <p>Accrues when we re-measure (at least weekly — the tally is committed to
+ the repo and republished with these dashboards). The running fleet’s own
+ footprint is tiny by comparison: see the carbon comparison linked above.</p>
+ </footer>
+</div>
+
+<script>
+// contract: the fields this page reads from ./carbon.json — kept in sync with
+// web/status/carbon.json (committed) and checked by test/status-contract.test.mjs.
+const FIELDS = [
+ "updated_at",
+ "factors_mg_co2e_per_output_token.low",
+ "factors_mg_co2e_per_output_token.central",
+ "factors_mg_co2e_per_output_token.high",
+ "not_counted",
+ "entries[].period_start", "entries[].period_end", "entries[].source",
+ "entries[].basis", "entries[].output_tokens", "entries[].kg_low",
+ "entries[].kg_central", "entries[].kg_high", "entries[].note",
+ "totals.output_tokens", "totals.kg_low", "totals.kg_central", "totals.kg_high",
+];
+
+const $ = (id) => document.getElementById(id);
+const day = (iso) => new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
+const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
+const NS = "http://www.w3.org/2000/svg";
+
+// Everyday equivalents (docs/comparison-carbon.md): a typical petrol car emits
+// ~170 g CO₂e/km, burning 1 L of petrol ≈ 2.3 kg, and the fleet estimate is
+// ~2 g CO₂e per member per year.
+const CAR_G_PER_KM = 170, PETROL_KG_PER_L = 2.3, FLEET_G_PER_MEMBER_YEAR = 2;
+
+// All rendering is textContent / createElement — never innerHTML of fetched
+// data, even though the feed is our own.
+function render(d) {
+ $("asof").dateTime = d.updated_at;
+ $("asof").textContent = new Date(d.updated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
+
+ const f = d.factors_mg_co2e_per_output_token;
+ $("factors").textContent = "we band public calibration points at " + f.low + "–" + f.high +
+ " mg CO₂e per output token, central " + f.central + " mg";
+
+ $("t-central").textContent = "~" + d.totals.kg_central + " kg CO₂e";
+ $("t-range").textContent = d.totals.kg_low + "–" + d.totals.kg_high + " kg";
+ $("t-tokens").textContent = compact.format(d.totals.output_tokens);
+
+ const km = Math.round(d.totals.kg_central * 1000 / CAR_G_PER_KM);
+ const litres = Math.round(d.totals.kg_central / PETROL_KG_PER_L);
+ const memberYears = Math.round(d.totals.kg_central * 1000 / FLEET_G_PER_MEMBER_YEAR);
+ $("equiv").textContent = "Centrally, that’s like driving a petrol car about " + km +
+ " km (burning ~" + litres + " litres of petrol) — or roughly " +
+ memberYears.toLocaleString() + " member-years of actually running the service.";
+
+ chart(d);
+
+ const list = $("entries");
+ list.replaceChildren();
+ // Ledger order = time order, even when contributor machines interleave.
+ const entries = (d.entries || []).slice().sort((a, b) => Date.parse(a.period_end) - Date.parse(b.period_end));
+ const max = Math.max(1, ...entries.map((e) => e.kg_central));
+ for (const e of entries) {
+ const li = document.createElement("li");
+ const period = document.createElement("span");
+ period.className = "period";
+ period.textContent = day(e.period_start) + " – " + day(e.period_end);
+ const basis = document.createElement("span");
+ basis.className = "basis";
+ basis.textContent = e.source + " · " + e.basis + " · " + compact.format(e.output_tokens) + " output tokens";
+ basis.title = e.note || "";
+ period.append(basis);
+ const track = document.createElement("span");
+ track.className = "track";
+ track.setAttribute("aria-hidden", "true");
+ const bar = document.createElement("span");
+ bar.className = "bar";
+ bar.style.width = (100 * e.kg_central / max) + "%";
+ bar.style.display = "block";
+ track.append(bar);
+ const kg = document.createElement("span");
+ kg.className = "kg";
+ kg.textContent = e.kg_central + " kg";
+ li.append(period, track, kg);
+ list.append(li);
+ }
+
+ $("notcounted").textContent = "Known undercounts, on purpose: " + d.not_counted + ".";
+}
+
+// Cumulative line over time: central line + low–high wash. Crosshair snaps to
+// the nearest measurement; points are keyboard-focusable with the same tooltip.
+function chart(d) {
+ const svg = $("chart");
+ svg.replaceChildren();
+ const round1 = (v) => Math.round(v * 10) / 10;
+ // Cumulate in period_end order across ALL sources; the curve starts at the
+ // earliest period_start any source measured from.
+ const ordered = d.entries.slice().sort((a, b) => Date.parse(a.period_end) - Date.parse(b.period_end));
+ const t0 = Math.min(...d.entries.map((e) => Date.parse(e.period_start)));
+ const pts = [{ t: t0, c: 0, lo: 0, hi: 0 }];
+ let c = 0, lo = 0, hi = 0;
+ for (const e of ordered) {
+ c = round1(c + e.kg_central); lo = round1(lo + e.kg_low); hi = round1(hi + e.kg_high);
+ pts.push({ t: Date.parse(e.period_end), c, lo, hi, e });
+ }
+ const W = 720, H = 240, m = { l: 44, r: 86, t: 14, b: 26 };
+ const span = Math.max(1, pts[pts.length - 1].t - pts[0].t);
+ const x = (t) => m.l + (W - m.l - m.r) * (t - pts[0].t) / span;
+ // Clean y ticks: pick the smallest 1/2/5×10ⁿ step that needs ≤ 4 intervals.
+ let step = 1;
+ for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]) { if (hi / s <= 4) { step = s; break; } }
+ const yMax = Math.max(step, Math.ceil(hi / step) * step);
+ const y = (v) => H - m.b - (H - m.b - m.t) * v / yMax;
+ const el = (name, attrs, text) => {
+ const n = document.createElementNS(NS, name);
+ for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
+ if (text !== undefined) n.textContent = text;
+ svg.append(n);
+ return n;
+ };
+
+ svg.setAttribute("aria-label", "Cumulative carbon estimate from " + day(pts[0].t) +
+ " to " + day(pts[pts.length - 1].t) + ": central " + c + " kg CO₂e, range " + lo + " to " + hi + " kg.");
+
+ for (let v = 0; v <= yMax; v += step) {
+ el("line", { x1: m.l, x2: W - m.r, y1: y(v), y2: y(v), stroke: "var(--grid)", "stroke-width": 1 });
+ el("text", { x: m.l - 8, y: y(v) + 4, "text-anchor": "end", "font-size": 12,
+ fill: "var(--ink-2)", style: "font-variant-numeric: tabular-nums" }, String(v));
+ }
+ for (const p of pts) {
+ el("text", { x: x(p.t), y: H - 8, "text-anchor": "middle", "font-size": 12, fill: "var(--ink-2)" }, day(p.t));
+ }
+
+ const path = (pick) => pts.map((p, i) => (i ? "L" : "M") + x(p.t) + " " + y(pick(p))).join(" ");
+ el("path", { d: path((p) => p.hi) + " " + pts.slice().reverse().map((p) => "L" + x(p.t) + " " + y(p.lo)).join(" ") + " Z",
+ fill: "var(--series)", opacity: 0.1, stroke: "none" });
+ el("path", { d: path((p) => p.c), fill: "none", stroke: "var(--series)",
+ "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" });
+
+ // Selective direct labels at the right edge; ticks + tooltip carry the rest.
+ const end = pts[pts.length - 1];
+ el("text", { x: x(end.t) + 10, y: y(end.hi) + 4, "font-size": 12, fill: "var(--ink-2)" }, "high " + end.hi);
+ el("text", { x: x(end.t) + 10, y: y(end.c) + 4, "font-size": 12, "font-weight": 650, fill: "var(--ink)" }, "~" + end.c + " kg");
+ el("text", { x: x(end.t) + 10, y: y(end.lo) + 12, "font-size": 12, fill: "var(--ink-2)" }, "low " + end.lo);
+
+ const hair = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--grid)", "stroke-width": 1, visibility: "hidden" });
+ const dots = pts.map((p) => el("circle", { cx: x(p.t), cy: y(p.c), r: 4.5, fill: "var(--series)",
+ stroke: "var(--surface)", "stroke-width": 2 }));
+
+ const tip = $("tip");
+ const show = (p, dot) => {
+ hair.setAttribute("x1", x(p.t)); hair.setAttribute("x2", x(p.t));
+ hair.setAttribute("visibility", "visible");
+ dots.forEach((d2, i) => d2.setAttribute("r", pts[i] === p ? 6 : 4.5));
+ tip.replaceChildren();
+ const big = document.createElement("div");
+ big.className = "big";
+ big.textContent = "~" + p.c + " kg CO₂e";
+ const when = document.createElement("div");
+ when.className = "sub";
+ when.textContent = "by " + day(p.t) + " · range " + p.lo + "–" + p.hi + " kg";
+ tip.append(big, when);
+ if (p.e) {
+ const delta = document.createElement("div");
+ delta.className = "sub";
+ delta.textContent = "+" + p.e.kg_central + " kg (" + p.e.source + ", " + p.e.basis + ", " +
+ compact.format(p.e.output_tokens) + " output tokens)";
+ tip.append(delta);
+ }
+ tip.style.display = "block";
+ const box = svg.getBoundingClientRect();
+ const px = (x(p.t) / W) * box.width;
+ tip.style.left = Math.min(Math.max(0, px - 60), box.width - tip.offsetWidth - 4) + "px";
+ tip.style.top = Math.max(0, (y(p.c) / H) * box.height - tip.offsetHeight - 14) + "px";
+ void dot;
+ };
+ const hide = () => {
+ hair.setAttribute("visibility", "hidden");
+ dots.forEach((d2) => d2.setAttribute("r", 4.5));
+ tip.style.display = "none";
+ };
+
+ // The crosshair finds the X: the whole plot is the hit area, snapping to the
+ // nearest measurement — nobody has to land on a 2px line.
+ svg.addEventListener("pointermove", (ev) => {
+ const box = svg.getBoundingClientRect();
+ const mx = ((ev.clientX - box.left) / box.width) * W;
+ let best = pts[0];
+ for (const p of pts) if (Math.abs(x(p.t) - mx) < Math.abs(x(best.t) - mx)) best = p;
+ show(best);
+ });
+ svg.addEventListener("pointerleave", hide);
+ pts.forEach((p, i) => {
+ const dot = dots[i];
+ dot.classList.add("pt");
+ dot.setAttribute("tabindex", "0");
+ dot.setAttribute("role", "img");
+ dot.setAttribute("aria-label", "By " + day(p.t) + ": about " + p.c + " kg CO₂e, range " + p.lo + " to " + p.hi);
+ dot.addEventListener("focus", () => show(p, dot));
+ dot.addEventListener("blur", hide);
+ });
+}
+
+async function refresh() {
+ try {
+ const res = await fetch("./carbon.json", { cache: "no-cache" });
+ if (!res.ok) throw new Error("HTTP " + res.status);
+ render(await res.json());
+ $("error").classList.remove("show");
+ } catch (e) {
+ // Keep the previous render (no skeleton flash); surface the failure inline.
+ $("error").classList.add("show");
+ }
+}
+refresh();
+setInterval(refresh, 60 * 1000);
+void FIELDS;
+</script>
+</body>
+</html>
diff --git a/web/status/carbon.json b/web/status/carbon.json
new file mode 100644
index 0000000..a0c05c1
--- /dev/null
+++ b/web/status/carbon.json
@@ -0,0 +1,47 @@
+{
+ "updated_at": "2026-07-11T19:26:21.699Z",
+ "unit": "kg CO2e",
+ "methodology": "https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md",
+ "factors_mg_co2e_per_output_token": {
+ "low": 0.1,
+ "central": 0.55,
+ "high": 3
+ },
+ "not_counted": "cache reads (no public per-token figure exists), cloud-side agent runs, and contributor machines that haven't accrued their entries yet",
+ "entries": [
+ {
+ "period_start": "2026-06-27T00:00:00.000Z",
+ "period_end": "2026-07-03T00:00:00.000Z",
+ "source": "radia-doodling",
+ "basis": "extrapolated",
+ "sessions": null,
+ "output_tokens": 9098065,
+ "fresh_input_tokens": null,
+ "cache_read_tokens": null,
+ "kg_low": 0.9,
+ "kg_central": 5,
+ "kg_high": 27.3,
+ "note": "transcripts pruned before the first measurement; commit-weighted share (165 of the 616 commits to 2026-07-11) of the first measured window's per-commit rate"
+ },
+ {
+ "period_start": "2026-07-03T08:19:42.191Z",
+ "period_end": "2026-07-11T19:26:21.699Z",
+ "source": "radia-doodling",
+ "basis": "measured",
+ "sessions": 162,
+ "output_tokens": 24889647,
+ "fresh_input_tokens": 144657973,
+ "cache_read_tokens": 7949132294,
+ "kg_low": 2.5,
+ "kg_central": 13.7,
+ "kg_high": 74.7,
+ "note": "local Claude Code transcripts, one dev machine"
+ }
+ ],
+ "totals": {
+ "output_tokens": 33987712,
+ "kg_low": 3.4,
+ "kg_central": 18.7,
+ "kg_high": 102
+ }
+}
diff --git a/web/status/index.html b/web/status/index.html
index e9405e5..acc1fb0 100644
--- a/web/status/index.html
+++ b/web/status/index.html
@@ -177,7 +177,7 @@
</section>
<footer>
- <p><a href="./books.html">The books — income, in aggregate</a></p>
+ <p><a href="./books.html">The books — income, in aggregate</a> · <a href="./carbon.html">The carbon tally — what the AI build cost</a></p>
<p>Published every 5 minutes from aggregate counters on the fleet.
By design it contains no hostnames and nothing about any individual space.</p>
</footer>