eleven / messenger Public

web+server: carbon page — split into Built + Run, tangible anchors (#803)

Rework the public carbon page two ways.

1) Replace the circular "member-years of running the service" equivalence
   with a curated set of tangible high-carbon anchors, applied to BOTH
   sections (each scaled to its own magnitude): petrol car (kept), a
   short-haul economy flight expressed against one real leg (Dublin–London
   ~460 km ≈ 67 kg CO₂e incl. the ~1.9× radiative-forcing uplift), a petrol
   leaf blower (~2.3 kg CO₂/h), beef (~60 kg CO₂e/kg, land-use-inclusive),
   and tree-years to reabsorb (~21 kg CO₂/yr). Each factor is cited, banded,
   and snapshot-dated in docs/comparison-carbon.md. Distances/volumes convert
   with the metric/imperial toggle; masses convert via massF; counts and
   durations stay unit-agnostic.

2) Split the page into Built (one-time, the committed AI-build tally) and Run
   (ongoing, a LIVE all-services operational estimate). The published
   carbon.json is now { updated_at, methodology, built:{…}, run:{…} }:
   carbon-tally.mjs writes the nested `built` (committed, republished as-is);
   deploy/status-publish.sh injects a live `run` block at upload
   (carbon_run_object): router-box watts from the Hetzner server_type's vCPUs
   × per-vCPU watt band × 24h × the box region's grid intensity × PUE ~1.15,
   plus small explicit AWS-storage and bounded bot-inference terms — all
   BANDED low/central/high, as a daily rate, a cumulative-to-date, and
   normalised per active space (denominator = the public instance count, never
   per-member). Absent inputs → run stays absent and the page shows
   "operational estimate pending", never a fake zero. Framing is neutral:
   one-time-to-make vs ongoing-to-operate-and-rising-with-scale, never
   "building cost more than running".

Contract test allowlists the run block's keys, asserts no hostnames/IPs and
built-side unchanged, and pins the watts→kg math; a dry-run twin folds the run
block from the Hetzner + registry fixtures. Fixture test/status-fixtures/
carbon.json regenerated from --dry-run. npm test + status-contract green.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell df6b478489495c7375cd7b8ba3327592d8f783e5 parent dd1acbf
7 files changed, +769 −194
  • deploy/status-publish.sh +215 −8
  • docs/comparison-carbon.md +97 −12
  • test/status-contract.test.mjs +73 −12
  • test/status-fixtures/carbon.json +1 −0
  • tools/carbon-tally.mjs +27 −10
  • web/status/carbon.html +300 −98
  • web/status/carbon.json +56 −54
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh
index b07f272..f3ef584 100755
--- a/deploy/status-publish.sh
+++ b/deploy/status-publish.sh
@@ -47,10 +47,14 @@
# usage breakdowns. The AWS creds and the Anthropic Admin key are 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.
+# carbon.json — two halves. `built` is 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 + kg only,
+# never per-session data. `run` is a LIVE operational estimate this script
+# INJECTS at upload (build_carbon): AGGREGATES ONLY — total vCPUs, watts,
+# grid intensity, banded kg, and the public instance count as the per-space
+# denominator (NEVER per-member, never a hostname/IP). Absent inputs → run
+# stays absent and the page shows "pending", never a fake zero.
#
# contributors.json — also COMMITTED data (tools/contributors.mjs),
# republished as-is. Public GitHub identities + commit counts only;
@@ -162,6 +166,30 @@ box_region() {
STATUS_ENV=${STATUS_ENV_FILE:-/data/status.env}
PAGES_DIR=${STATUS_PAGES_DIR:-/opt/lchat/status-pages}
STRIPE_API=${STATUS_STRIPE_API:-https://api.stripe.com}
+
+# Carbon "run" block coefficients (see build_carbon + docs/comparison-carbon.md
+# § The run methodology; snapshot July 2026, all BANDED low/central/high). The
+# fleet hibernates and runs at a fraction of the box, so the attributable draw
+# is a low-single-digit-to-~15W slice of a lightly loaded server — modelled here
+# as per-vCPU average watts (marginal → whole-box-idle-inclusive):
+CARBON_W_LO=${STATUS_CARBON_W_LO:-1.5} # W/vCPU, marginal (load-proportional)
+CARBON_W_CE=${STATUS_CARBON_W_CE:-3.5} # W/vCPU, central
+CARBON_W_HI=${STATUS_CARBON_W_HI:-7.5} # W/vCPU, whole-box idle-inclusive share
+CARBON_PUE=${STATUS_CARBON_PUE:-1.15} # AWS-published fleet-PUE ballpark
+# AWS storage+requests: near-zero but NOT zero — a small explicit banded term
+# (~a gram a day: ~0.1 TB of our ciphertext × ~1 Wh/TB-h × 24h × a grid).
+CARBON_AWS_LO=${STATUS_CARBON_AWS_LO:-0.0002}
+CARBON_AWS_CE=${STATUS_CARBON_AWS_CE:-0.001}
+CARBON_AWS_HI=${STATUS_CARBON_AWS_HI:-0.003}
+# Bot inference (the sidecars' ongoing Anthropic API use): small today, bounded
+# wide — the high end allows for busy days. Precise token counts exist in the
+# provider console; we publish a bounded estimate rather than couple to it.
+CARBON_INF_LO=${STATUS_CARBON_INF_LO:-0.0005}
+CARBON_INF_CE=${STATUS_CARBON_INF_CE:-0.005}
+CARBON_INF_HI=${STATUS_CARBON_INF_HI:-0.05}
+# The fleet's start-of-life (drives the cumulative-to-date figure). The dry-run
+# pins STATUS_CARBON_DAYS instead so its cumulative is deterministic.
+CARBON_SINCE=${STATUS_CARBON_SINCE:-2026-07-05}
SELF=/opt/lchat/status-publish.sh
UNIT=/etc/systemd/system/lchat-status.service
TIMER=/etc/systemd/system/lchat-status.timer
@@ -863,6 +891,121 @@ SQL
} | sqlite3 :memory: > "$out"
}
+# ---- carbon.json: republish the committed built ledger + inject a live run block
+
+# grid_band — the box region's grid carbon intensity as "<low> <central> <high>"
+# g CO₂e/kWh. low is always ~0 (a market-based renewable contract — Hetzner's
+# EU sites run on renewable electricity); central+high are the LOCATION grid,
+# banded. Region resolves from box_region() (coarse continent+country; the prod
+# box is Helsinki → Finland's low-carbon grid). Operator overrides:
+# STATUS_CARBON_GRID_CENTRAL / _HIGH. Cited in docs/comparison-carbon.md.
+grid_band() {
+ local gce ghi region
+ region=${BOX_REGION:-$(box_region)}
+ case "$region" in
+ "EU · Finland") gce=100; ghi=130 ;; # Finnish grid ~80–130 (Fingrid/ElectricityMaps 2026)
+ "EU · Sweden") gce=40; ghi=60 ;;
+ "EU · Germany") gce=350; ghi=400 ;;
+ "EU · Ireland") gce=300; ghi=380 ;;
+ "EU · UK") gce=210; ghi=280 ;;
+ "NA · USA") gce=370; ghi=480 ;;
+ *) gce=350; ghi=480 ;; # unknown: IEA-ish global band
+ esac
+ [ -n "${STATUS_CARBON_GRID_CENTRAL:-}" ] && gce=$STATUS_CARBON_GRID_CENTRAL
+ [ -n "${STATUS_CARBON_GRID_HIGH:-}" ] && ghi=$STATUS_CARBON_GRID_HIGH
+ printf '0 %s %s' "$gce" "$ghi"
+}
+
+# hetzner_vcpus — total vCPUs across every server in the Hetzner account (the
+# hardware the whole fleet runs on; sums so it scales as boxes are added). Same
+# paginated servers-API read as fetch_hetzner_cents (dry-run twin included);
+# each server_type.cores must be an integer or we can't honestly total. Prints
+# the sum, or returns 1 (no token / bad page) so the run block stays absent.
+hetzner_vcpus() {
+ local page=1 n=0 f next load="CREATE TABLE srv(j TEXT);"
+ while :; do
+ n=$((n + 1))
+ f="$TMP/hetzner-vcpus-$n.json"
+ hetzner_get_page "$page" "$f" || return 1
+ [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.servers')='array'
+ AND json_type(readfile('$f'), '\$.meta.pagination')='object'")" = "1" ] || return 1
+ load="$load INSERT INTO srv SELECT value FROM json_each(readfile('$f'), '\$.servers');"
+ next=$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.meta.pagination.next_page'), '')")
+ [ -n "$next" ] || break
+ case "$next" in *[!0-9]*) return 1 ;; esac
+ [ "$n" -lt 10 ] || return 1
+ page=$next
+ done
+ [ "$(sqj "$load SELECT COUNT(*) FROM srv WHERE json_type(j, '\$.server_type.cores') <> 'integer'")" = "0" ] || return 1
+ sqj "$load SELECT COALESCE(SUM(json_extract(j, '\$.server_type.cores')), 0) FROM srv"
+}
+
+# carbon_run_object — the live operational estimate as a JSON object, or nothing
+# printed when the router term can't be resolved (no Hetzner token / fetch fail)
+# so the published `run` stays absent and the page shows "pending", never a fake
+# zero. Math (all BANDED): daily kg = vCPUs × W/vCPU × 24h × grid(g/kWh) × PUE
+# / 1e6, plus small explicit AWS-storage + bot-inference terms. Reported as a
+# daily rate, a cumulative-to-date (× days since CARBON_SINCE, or a pinned
+# STATUS_CARBON_DAYS), and normalised per active space (denominator = the public
+# instance count from the registry — NEVER per-member). Aggregates only: vCPU
+# total, watts, grid, kg, space count — no hostnames/IPs/per-space needles.
+carbon_run_object() {
+ local vcpus glo gce ghi spaces days since now
+ vcpus=$(hetzner_vcpus) || vcpus=""
+ case "$vcpus" in ''|*[!0-9]*|0) return 0 ;; esac # no router term → run absent
+ set -- $(grid_band); glo=$1 gce=$2 ghi=$3
+ spaces=$(sq "SELECT COUNT(*) FROM routes WHERE kind='instance'" 2>/dev/null || echo "")
+ case "$spaces" in ''|*[!0-9]*|0) spaces=1 ;; esac
+ since=$CARBON_SINCE
+ days=${STATUS_CARBON_DAYS:-}
+ [ -n "$days" ] || days=$(sqj "SELECT MAX(1, CAST(julianday('now') - julianday('$since') AS INTEGER))")
+ case "$days" in ''|*[!0-9]*) days=1 ;; esac
+ now=$(now_iso)
+ # Daily bands live in the CTE d(lo,ce,hi); cumulative/per-space derive from the
+ # UNROUNDED daily so rounding never compounds. Watts round to whole; kg to 6dp.
+ sqj "WITH d(lo, ce, hi) AS (VALUES(
+ $vcpus*$CARBON_W_LO*24*$glo*$CARBON_PUE/1e6 + $CARBON_AWS_LO + $CARBON_INF_LO,
+ $vcpus*$CARBON_W_CE*24*$gce*$CARBON_PUE/1e6 + $CARBON_AWS_CE + $CARBON_INF_CE,
+ $vcpus*$CARBON_W_HI*24*$ghi*$CARBON_PUE/1e6 + $CARBON_AWS_HI + $CARBON_INF_HI))
+ SELECT json_object(
+ 'generated_at', '$now',
+ 'since', '$since',
+ 'spaces', $spaces,
+ 'pue', $CARBON_PUE,
+ 'vcpus', $vcpus,
+ 'watts', json_object('low', CAST(ROUND($vcpus*$CARBON_W_LO) AS INTEGER),
+ 'central', CAST(ROUND($vcpus*$CARBON_W_CE) AS INTEGER),
+ 'high', CAST(ROUND($vcpus*$CARBON_W_HI) AS INTEGER)),
+ 'grid_g_per_kwh', json_object('low', $glo, 'central', $gce, 'high', $ghi),
+ 'daily_kg', json_object('low', ROUND(lo,6), 'central', ROUND(ce,6), 'high', ROUND(hi,6)),
+ 'cumulative_kg', json_object('low', ROUND(lo*$days,6), 'central', ROUND(ce*$days,6), 'high', ROUND(hi*$days,6)),
+ 'per_space_daily_kg', json_object('low', ROUND(lo/$spaces,6), 'central', ROUND(ce/$spaces,6), 'high', ROUND(hi/$spaces,6)),
+ 'services', json_array(
+ json_object('name', 'router + instances', 'daily_kg_central', ROUND($vcpus*$CARBON_W_CE*24*$gce*$CARBON_PUE/1e6, 6)),
+ json_object('name', 'object storage (AWS)', 'daily_kg_central', $CARBON_AWS_CE),
+ json_object('name', 'bot inference', 'daily_kg_central', $CARBON_INF_CE)))
+ FROM d"
+}
+
+# build_carbon <out> <src> — republish the committed carbon.json (the built
+# ledger, tools/carbon-tally.mjs) and inject a live `run` block. We nest HERE
+# rather than in the tool: the committed file stays exactly the built ledger
+# ({ built:{…} }), and the only transform is this one merge — so "carbon is
+# committed data" holds and the run block never touches git. A malformed source
+# or no live inputs → republished verbatim (run stays absent → page = pending).
+build_carbon() {
+ local out=$1 src=$2
+ if [ "$(sqj "SELECT json_valid(readfile('$src')) AND json_type(readfile('$src'), '\$.built')='object'")" != "1" ]; then
+ cp "$src" "$out"; return
+ fi
+ carbon_run_object > "$TMP/carbon-run.json" || true
+ if [ -s "$TMP/carbon-run.json" ]; then
+ sqj "SELECT json_set(readfile('$src'), '\$.run', json(readfile('$TMP/carbon-run.json')))" > "$out"
+ else
+ cp "$src" "$out"
+ fi
+}
+
# ---- upload ------------------------------------------------------------------
upload() { # file key content-type cache-control
@@ -897,9 +1040,13 @@ publish() {
[ -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 + contributors index are committed data (see header) —
- # republished, not built.
- [ -f "$PAGES_DIR/carbon.json" ] && upload "$PAGES_DIR/carbon.json" carbon.json "application/json" "max-age=300"
+ # The carbon tally is committed data (the built ledger) — republished as-is
+ # EXCEPT for a live `run` block injected here (build_carbon). Built untouched,
+ # run recomputed each publish; absent inputs → run stays absent (page pending).
+ if [ -f "$PAGES_DIR/carbon.json" ]; then
+ build_carbon "$TMP/carbon.json" "$PAGES_DIR/carbon.json"
+ upload "$TMP/carbon.json" carbon.json "application/json" "max-age=300"
+ fi
[ -f "$PAGES_DIR/contributors.html" ] && upload "$PAGES_DIR/contributors.html" contributors.html "text/html; charset=utf-8" "max-age=300"
[ -f "$PAGES_DIR/contributors.json" ] && upload "$PAGES_DIR/contributors.json" contributors.json "application/json" "max-age=300"
# The security ledger: page + committed, build-time-scanned, REDACTED JSON
@@ -1260,9 +1407,69 @@ EOF
{ echo "dry-run: metered results must be cached per provider" >&2; exit 1; }
;;
esac
+
+ # ---- carbon.json: the committed BUILT ledger (a fixed fixture here, so the
+ # run fixture stays stable across real accruals) + a live-computed RUN block.
+ # The run block folds from the Hetzner servers fixture (cpx31=4 + cx22=2 → 6
+ # vCPUs), the box region grid (Germany → 0/350/400 g/kWh), PUE 1.15, a pinned
+ # 10-day span and the fixture registry's 6 instances. Only the expense modes
+ # that stage the Hetzner servers fixture (default + hetzner-constant) give the
+ # run its vCPU input; omit/stale-cache legitimately leave run absent (pending).
+ cat > "$FIXDIR/carbon-built.json" <<'EOF'
+{
+ "updated_at": "2026-07-11T00:00:00.000Z",
+ "methodology": "https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md",
+ "built": {
+ "unit": "kg CO2e",
+ "factors_mg_co2e_per_output_token": { "low": 0.1, "central": 0.55, "high": 3 },
+ "not_counted": "cache reads, cloud-side agent runs, and contributor machines not yet accrued",
+ "entries": [
+ { "period_start": "2026-07-01T00:00:00.000Z", "period_end": "2026-07-11T00:00:00.000Z",
+ "source": "ada-sketching", "basis": "measured", "sessions": 10, "output_tokens": 1000000,
+ "fresh_input_tokens": 5000000, "cache_read_tokens": 90000000,
+ "kg_low": 0.1, "kg_central": 0.6, "kg_high": 3, "note": "dry-run fixture" }
+ ],
+ "totals": { "output_tokens": 1000000, "kg_low": 0.1, "kg_central": 0.6, "kg_high": 3 }
+ }
+}
+EOF
+ local cvc cwt cgr cda ccu cps
+ CARBON_SINCE=2026-07-02
+ STATUS_CARBON_DAYS=10
+ build_carbon "$outdir/carbon.json" "$FIXDIR/carbon-built.json"
+ [ "$(sqj "SELECT json_valid(readfile('$outdir/carbon.json'))")" = "1" ] ||
+ { echo "dry-run: carbon.json is not valid JSON" >&2; exit 1; }
+ [ "$(sqj "SELECT json_type(readfile('$outdir/carbon.json'), '\$.built')='object'")" = "1" ] ||
+ { echo "dry-run: carbon built ledger missing (built must be republished verbatim)" >&2; exit 1; }
+ case "${STATUS_DRY_EXPENSES:-}" in
+ omit|stale-cache)
+ [ "$(sqj "SELECT json_type(readfile('$outdir/carbon.json'), '\$.run') IS NULL")" = "1" ] ||
+ { echo "dry-run: carbon run must be absent when no server inputs resolve (pending, never a fake zero)" >&2; exit 1; }
+ ;;
+ *)
+ cvc=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.vcpus')")
+ cwt=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.watts.central')")
+ cgr=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.grid_g_per_kwh.central')")
+ cda=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.daily_kg.central')")
+ ccu=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.cumulative_kg.central')")
+ cps=$(sqj "SELECT json_extract(readfile('$outdir/carbon.json'), '\$.run.per_space_daily_kg.central')")
+ # daily central = 6 × 3.5 W × 24h × 350 g/kWh × 1.15 / 1e6 + 0.001 + 0.005
+ # = 0.20886 kg; cumulative = ×10 days = 2.0886; per space = ÷6.
+ [ "$cvc" = "6" ] && [ "$cwt" = "21" ] && [ "$cgr" = "350" ] &&
+ [ "$cda" = "0.20886" ] && [ "$ccu" = "2.0886" ] && [ "$cps" = "0.03481" ] ||
+ { echo "dry-run: carbon run math (got vcpus=$cvc watts=$cwt grid=$cgr daily=$cda cumulative=$ccu per_space=$cps)" >&2; exit 1; }
+ ;;
+ esac
+ # Privacy: no fixture marker, hostname, socket path, or IP may reach carbon.json.
+ if grep -qE 'hzfix|ada-sketching@|\.example|\.sock|[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "$outdir/carbon.json"; then
+ echo "dry-run: PRIVACY LEAK — a hostname/IP reached carbon.json" >&2
+ exit 1
+ fi
+
echo "--- fleet.json ---"; cat "$outdir/fleet.json"
echo "--- books.json ---"; cat "$outdir/books.json"
- echo "dry-run ok: valid JSON, no hostnames, no per-payer fields, capacity abstract ($lvl), expenses ${STATUS_DRY_EXPENSES:-itemized}"
+ echo "--- carbon.json ---"; cat "$outdir/carbon.json"
+ echo "dry-run ok: valid JSON, no hostnames, no per-payer fields, capacity abstract ($lvl), expenses ${STATUS_DRY_EXPENSES:-itemized}, carbon run ${cda:-absent}"
}
case "${1:-}" in
diff --git a/docs/comparison-carbon.md b/docs/comparison-carbon.md
index 8b85601..d0f899c 100644
--- a/docs/comparison-carbon.md
+++ b/docs/comparison-carbon.md
@@ -136,12 +136,14 @@ 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.
-- **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.
+- **Runtime AI inference isn't counted in the ratio.** Heavy LLM use by the
+ bot sidecars would dwarf either infrastructure's footprint — and applies to
+ both models equally, so it cancels out of *this comparison's* 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
+ live status-page **run** estimate does carry a small, bounded inference term
+ (see *The status page*), and the AI cost of *building* the app is estimated
+ in full — 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
@@ -182,12 +184,14 @@ serving energy, and nobody publishes a price for cached prefill, which is
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.
+**Result so far: ~6–180 kg CO₂e, central ~34 kg.** To make that legible,
+the status page renders the central estimate as a set of everyday anchors
+(each factor documented under *What the anchors mean* below): about **half a
+Dublin–London economy flight**, **~15 hours** of a petrol leaf blower,
+**~0.6 kg of beef**, and **~1.6 tree-years** to soak it back up — or **~200
+km** in a petrol car (~15 litres). 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
@@ -203,6 +207,77 @@ 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 status page: two figures, built and run
+
+The public page carries **two** ledgers, deliberately separate and neither
+editorialised against the other:
+
+- **Built** — the one-time AI cost of *making* the app (the tally above). It
+ grows only when more AI development happens.
+- **Run** — the ongoing cost of *operating* the whole fleet, recomputed live
+ at every publish. It rises with the fleet — one box today, fifty later.
+
+They are two different things — *one-time to make* versus *ongoing to
+operate, and rising with scale* — so the page does **not** claim one is
+"bigger" than the other; the ratio between them is a moving target that says
+more about how young and how small the fleet is than about either number.
+The figure that stays meaningful at any size is the **per-space** run rate.
+
+**The run estimate, banded low/central/high.** Computed at publish time in
+`deploy/status-publish.sh` (`carbon_run_object`), summing every service the
+stack needs:
+
+1. **The router box + its instances.** The Hetzner Cloud API gives each
+ server's `server_type` (its vCPU count); the whole fleet is a lightly
+ loaded, hibernating workload (load ~0.2 on the box), so its attributable
+ draw is a **low-single-digit-to-~15 W slice** of the box, modelled as
+ **per-vCPU average watts — 1.5 / 3.5 / 7.5 W** (marginal load-proportional
+ → whole-box idle-inclusive). Times **24 h**, times the **box region's grid
+ intensity**, times **PUE ≈ 1.15**. The prod box is in **Helsinki, so the
+ Finnish grid ≈ 80–130 g CO₂e/kWh** (a low-carbon, hydro/nuclear/wind mix);
+ the low band takes the grid to **≈ 0** (Hetzner's EU sites run on a
+ renewable-electricity contract), the high band to the location grid. vCPUs
+ sum across every box in the account, so the term scales as boxes are added.
+2. **Object storage (AWS).** Our S3 use is a slice of ciphertext plus
+ requests — near-zero, but stated as a **small explicit banded term**
+ (~a gram a day), never folded to zero.
+3. **Bot inference.** The sidecars' ongoing Anthropic API use. Small today
+ and **bounded wide** (the high band allows for busy days); precise token
+ counts exist in the provider console, but the page publishes a bounded
+ estimate rather than couple to a live spend feed.
+
+The result publishes as a **daily rate**, a **cumulative-to-date** (the daily
+rate × days since the fleet's start — a first-order figure that assumes
+today's rate held), and **per active space** (denominator = the public
+instance count from the registry — **never** per-member, no member counts).
+At today's single box that lands at **tens of grams of CO₂e per day** for the
+whole fleet — less than a nightlight — and a per-space rate of a **gram or so
+per space per day**. All coefficients are honest bands, and the region grid
+is the one that can dominate everything (see below).
+
+## What the anchors mean
+
+The everyday equivalents shown beside **both** figures (each scaled to its
+own magnitude — the build total, and the run's cumulative-to-date) convert
+the central kg through published, banded factors. Snapshot **July 2026**;
+these move, so re-check before quoting.
+
+| anchor | factor (central) | band | source class |
+|---|---|---|---|
+| Petrol car | 170 g CO₂e/km; 1 L petrol ≈ 2.3 kg | ±~15% | DEFRA/EPA average passenger car |
+| **Short-haul flight** | **0.145 kg CO₂e per economy passenger-km, incl. the non-CO₂ altitude uplift (~1.9× radiative forcing)** | **0.13–0.16** | DEFRA short-haul-with-RF economy class |
+| **Leaf blower** | 2-stroke ~1 L/h → **~2.3 kg CO₂/h** | ±~25% | EPA small-engine fuel burn × petrol EF |
+| **Beef** | **~60 kg CO₂e/kg** (land-use-inclusive) | 40–100 | Poore & Nemecek (Science 2018) / Our World in Data |
+| **Tree-years** | a mature tree absorbs **~21 kg CO₂/yr** | 10–40 | EPA / arboricultural estimates |
+
+The flight is expressed against **one real short leg — Dublin–London
+(DUB–LHR) ≈ 460 km great-circle → ≈ 67 kg CO₂e per economy leg** — so "half a
+Dublin–London flight" is ~34 kg. The uplift matters: without the
+radiative-forcing multiplier the same leg is ~40% lower, so the page names it
+explicitly. Distances and volumes convert with the metric/imperial toggle;
+the mass anchors (beef) convert too; counts and durations (flights,
+leaf-blower time, tree-years) are unit-agnostic.
+
## The one-sentence version
**~2 g versus ~75 g CO₂e per member per year — the difference between an
@@ -217,6 +292,16 @@ 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)
+- [Electricity Maps — Finland grid carbon intensity](https://app.electricitymaps.com/zone/FI) (the prod box's Helsinki region; ~80–130 g CO₂e/kWh)
+- Everyday anchors (all snapshot July 2026, banded):
+ [DEFRA/DESNZ greenhouse-gas conversion factors](https://www.gov.uk/government/collections/government-conversion-factors-for-company-reporting)
+ (average car ~0.17 kg CO₂e/km, petrol ~2.31 kg/L, short-haul economy air
+ ~0.15 kg CO₂e/pax-km with radiative forcing);
+ [Poore & Nemecek, *Reducing food's environmental impacts* (Science 2018)](https://www.science.org/doi/10.1126/science.aaq0216)
+ via [Our World in Data](https://ourworldindata.org/environmental-impacts-of-food)
+ (beef ~60 kg CO₂e/kg, land-use-inclusive);
+ [US EPA — greenhouse gas equivalencies](https://www.epa.gov/energy/greenhouse-gases-equivalencies-calculator-calculations-and-references)
+ (a mature tree ~21 kg CO₂/yr; small-engine fuel burn)
- [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)
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs
index 4f57140..64fdb78 100644
--- a/test/status-contract.test.mjs
+++ b/test/status-contract.test.mjs
@@ -78,9 +78,15 @@ 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.
+// The carbon page reads TWO shapes. `built` is COMMITTED data (accrued by
+// tools/carbon-tally.mjs) — the real web/status/carbon.json is its own fixture,
+// and carries only { updated_at, methodology, built:{…} } (run absent → page
+// shows "pending"). The live `run` block is injected by deploy/status-publish.sh
+// at upload; its shape is pinned by the dry-run fixture test/status-fixtures/
+// carbon.json (built + run — regenerate with `./deploy/status-publish.sh
+// --dry-run <dir>` then copy <dir>/carbon.json here).
const carbon = JSON.parse(read("web/status/carbon.json"));
+const carbonRun = JSON.parse(read("test/status-fixtures/carbon.json"));
const carbonHtml = read("web/status/carbon.html");
// The contributors index is COMMITTED data too (tools/contributors.mjs).
const contributors = JSON.parse(read("web/status/contributors.json"));
@@ -109,9 +115,14 @@ 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", () => {
+await test("carbon page fields resolve — built in committed carbon.json, run in the live fixture", () => {
for (const f of fields(carbonHtml)) {
- assert.notEqual(resolve(carbon, f), undefined, "carbon.json is missing " + f);
+ if (f.startsWith("run.")) {
+ // run.* is absent from the committed file by design — pinned by the fixture.
+ assert.notEqual(resolve(carbonRun, f), undefined, "run fixture is missing " + f);
+ } else {
+ assert.notEqual(resolve(carbon, f), undefined, "committed carbon.json is missing " + f);
+ }
}
});
@@ -174,7 +185,7 @@ await test("degraded books shape carries available:false + reason", () => {
// customer, transaction, or provider-account/workspace 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, booksNoExp, booksHzConst, carbon, contributors, ...securityShapes.map(([, s]) => s)]) {
+ for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, carbonRun, contributors, ...securityShapes.map(([, s]) => s)]) {
const s = JSON.stringify(j);
assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|hzfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80));
}
@@ -294,11 +305,12 @@ await test("hetzner: API-computed cents with rounding; constant overrides", () =
// 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;
+ const b = carbon.built;
+ const f = b.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) {
+ for (const e of b.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");
@@ -313,13 +325,62 @@ await test("carbon tally arithmetic + per-source chronology hold", () => {
}
out += e.output_tokens;
}
- assert.equal(carbon.totals.output_tokens, out, "totals.output_tokens is the ledger sum");
+ assert.equal(b.totals.output_tokens, out, "built.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.ok(Math.abs(b.totals["kg_" + band] - kg[band]) <= 0.05,
+ "built.totals.kg_" + band + " is the ledger sum");
+ }
+ assert.equal(Date.parse(carbon.updated_at), Math.max(...b.entries.map((e) => Date.parse(e.period_end))),
+ "updated_at is the newest built period_end");
+});
+
+// The injected run block: an ALLOWLIST schema (any extra key fails, so a needle
+// can't ship by accident), banded low≤central≤high, aggregates only — no
+// hostname/IP/socket, and the per-space denominator is the public instance
+// count, NEVER per-member.
+await test("run block carries only the allowed operational schema, banded, no needles", () => {
+ const r = carbonRun.run;
+ assert.deepEqual(Object.keys(r).sort(),
+ ["cumulative_kg", "daily_kg", "generated_at", "grid_g_per_kwh", "per_space_daily_kg",
+ "pue", "services", "since", "spaces", "vcpus", "watts"], "run keys");
+ assert.match(r.generated_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "generated_at is ISO UTC");
+ assert.match(r.since, /^\d{4}-\d{2}-\d{2}$/, "since is a date");
+ assert.ok(Number.isInteger(r.spaces) && r.spaces > 0, "spaces is a positive instance count");
+ assert.ok(Number.isInteger(r.vcpus) && r.vcpus > 0, "vcpus is a positive count");
+ assert.ok(typeof r.pue === "number" && r.pue >= 1, "pue is a real ≥ 1");
+ for (const band of ["watts", "grid_g_per_kwh", "daily_kg", "cumulative_kg", "per_space_daily_kg"]) {
+ assert.deepEqual(Object.keys(r[band]).sort(), ["central", "high", "low"], band + " is banded");
+ assert.ok(r[band].low <= r[band].central && r[band].central <= r[band].high, band + " ordered low≤central≤high");
}
- 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");
+ for (const s of r.services) {
+ assert.deepEqual(Object.keys(s).sort(), ["daily_kg_central", "name"], "service " + s.name + " keys");
+ assert.ok(typeof s.name === "string" && s.name.length > 0, "service name");
+ assert.ok(typeof s.daily_kg_central === "number" && s.daily_kg_central >= 0, s.name + " daily kg ≥ 0");
+ }
+ const t = JSON.stringify(r);
+ assert.ok(!/\d+\.\d+\.\d+\.\d+|[a-z0-9-]+\.(sock|example|com|net|chat|at)\b/i.test(t),
+ "run block reads like a hostname/IP: " + t.slice(0, 80));
+});
+
+// The run math folds server_type + region grid → watts → kg exactly, so the
+// generator's arithmetic can't drift (mirrors deploy/status-publish.sh's
+// carbon_run_object + its dry-run assertions). 6 vCPUs (cpx31=4 + cx22=2), the
+// Germany fixture grid 350 g/kWh, W/vCPU central 3.5, PUE 1.15, 10-day span, 6
+// spaces.
+await test("run fixture folds server_type + grid to the asserted watts→kg math", () => {
+ const r = carbonRun.run;
+ assert.equal(r.vcpus, 6, "vCPUs summed across the servers fixture");
+ assert.equal(r.watts.central, 21, "6 vCPU × 3.5 W");
+ assert.equal(r.grid_g_per_kwh.central, 350, "Germany fixture grid");
+ assert.equal(r.pue, 1.15);
+ // 6 × 3.5 × 24 × 350 × 1.15 / 1e6 + 0.001 (AWS) + 0.005 (inference) = 0.20886.
+ assert.equal(r.daily_kg.central, 0.20886, "daily central kg");
+ assert.equal(r.cumulative_kg.central, 2.0886, "cumulative = daily × 10 days");
+ assert.equal(r.per_space_daily_kg.central, 0.03481, "per space = daily ÷ 6 spaces");
+ // The service breakdown's central terms sum to the daily central (router term
+ // is the watts→kg piece; AWS + inference are the small explicit terms).
+ const sum = r.services.reduce((a, s) => a + s.daily_kg_central, 0);
+ assert.ok(Math.abs(sum - r.daily_kg.central) <= 1e-6, "services sum to the daily central");
});
// Capacity + hosts publish ABSTRACT levels only (Paul's call: bucketed words,
diff --git a/test/status-fixtures/carbon.json b/test/status-fixtures/carbon.json
new file mode 100644
index 0000000..50a5748
--- /dev/null
+++ b/test/status-fixtures/carbon.json
@@ -0,0 +1 @@
+{"updated_at":"2026-07-11T00:00:00.000Z","methodology":"https://github.com/elevenmessenger/messenger/blob/main/docs/comparison-carbon.md","built":{"unit":"kg CO2e","factors_mg_co2e_per_output_token":{"low":0.1,"central":0.55,"high":3},"not_counted":"cache reads, cloud-side agent runs, and contributor machines not yet accrued","entries":[{"period_start":"2026-07-01T00:00:00.000Z","period_end":"2026-07-11T00:00:00.000Z","source":"ada-sketching","basis":"measured","sessions":10,"output_tokens":1000000,"fresh_input_tokens":5000000,"cache_read_tokens":90000000,"kg_low":0.1,"kg_central":0.6,"kg_high":3,"note":"dry-run fixture"}],"totals":{"output_tokens":1000000,"kg_low":0.1,"kg_central":0.6,"kg_high":3}},"run":{"generated_at":"2026-07-12T16:37:32Z","since":"2026-07-02","spaces":6,"pue":1.15,"vcpus":6,"watts":{"low":9,"central":21,"high":45},"grid_g_per_kwh":{"low":0,"central":350,"high":400},"daily_kg":{"low":0.0007,"central":0.20886,"high":0.5498},"cumulative_kg":{"low":0.007,"central":2.0886,"high":5.498},"per_space_daily_kg":{"low":0.000117,"central":0.03481,"high":0.091633},"services":[{"name":"router + instances","daily_kg_central":0.20286},{"name":"object storage (AWS)","daily_kg_central":0.001},{"name":"bot inference","daily_kg_central":0.005}]}}
diff --git a/tools/carbon-tally.mjs b/tools/carbon-tally.mjs
index caff488..8079807 100644
--- a/tools/carbon-tally.mjs
+++ b/tools/carbon-tally.mjs
@@ -66,9 +66,26 @@ if (!existsSync(tallyPath)) {
process.exit(mode === "check" ? 0 : 1);
}
const tally = JSON.parse(readFileSync(tallyPath, "utf8"));
+// The published carbon.json is { updated_at, methodology, built:{…}, run:{…} }:
+// `built` is this ledger (accrued here), `run` is a live operational block the
+// status publisher injects at upload (never written by this tool). We only ever
+// touch `built`. A pre-nesting flat tally is migrated in place so old checkouts
+// still accrue cleanly.
+if (!tally.built) {
+ tally.built = {
+ unit: tally.unit,
+ factors_mg_co2e_per_output_token: tally.factors_mg_co2e_per_output_token,
+ not_counted: tally.not_counted,
+ entries: tally.entries || [],
+ totals: tally.totals,
+ };
+ for (const k of ["unit", "factors_mg_co2e_per_output_token", "not_counted", "entries", "totals"]) delete tally[k];
+}
+const built = tally.built;
let mainEntries = [];
-try { mainEntries = JSON.parse(git("show origin/main:web/status/carbon.json")).entries; } catch {}
-const known = [...tally.entries, ...mainEntries];
+// origin/main may still be flat (pre-nesting) or already nested — handle both.
+try { const m = JSON.parse(git("show origin/main:web/status/carbon.json")); mainEntries = (m.built ? m.built.entries : m.entries) || []; } catch {}
+const known = [...built.entries, ...mainEntries];
const cutoff = Math.max(0, ...known.filter((e) => e.source === source).map((e) => Date.parse(e.period_end)));
if (mode === "check") {
@@ -132,7 +149,7 @@ if (out === 0) {
}
if (noTs) console.error("note: skipped " + noTs + " usage lines without timestamps");
-const factors = tally.factors_mg_co2e_per_output_token;
+const factors = built.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(),
@@ -156,18 +173,18 @@ for (const [model, n] of [...byModel].sort((a, b) => b[1] - a[1])) {
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),
+ built.entries.push(entry);
+ const sum = (pick) => Math.round(built.entries.reduce((a, e) => a + pick(e), 0) * 10) / 10;
+ built.totals = {
+ output_tokens: built.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();
+ tally.updated_at = new Date(Math.max(...built.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 + ")");
+ console.error("appended to " + tallyPath + " — built totals now " + built.totals.kg_central + " kg central (" +
+ built.totals.kg_low + "–" + built.totals.kg_high + ")");
} else {
console.error("dry run — pass --write to append");
}
diff --git a/web/status/carbon.html b/web/status/carbon.html
index ef89060..5968612 100644
--- a/web/status/carbon.html
+++ b/web/status/carbon.html
@@ -1,8 +1,15 @@
-<!-- 🤖 Public AI-build carbon tally (the honesty leg of docs/comparison-carbon.md).
+<!-- 🤖 Public carbon page: two honest ledgers side by side (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.
+ ./carbon.json (relative). The JSON is { updated_at, methodology,
+ built:{…}, run:{…} }:
+ built — COMMITTED data (web/status/carbon.json, accrued by
+ tools/carbon-tally.mjs): the one-time AI cost of BUILDING the app,
+ republished as-is by deploy/status-publish.sh (token counts + kg only).
+ run — a LIVE operational estimate the publisher INJECTS at upload
+ (deploy/status-publish.sh § build_carbon): the ongoing cost of running
+ every service in the fleet, banded, as a daily rate + cumulative +
+ per-space. Absent (no live inputs) → the Run section shows a pending
+ note, never a fake zero.
The JSON stays metric (canonical); the metric/imperial toggle is purely
presentational (?units=imperial|metric, remembered per browser). -->
<!doctype html>
@@ -15,18 +22,15 @@
<link rel="icon" type="image/png" href="./icon-192.png" />
<link rel="stylesheet" href="./status.css" />
<style>
- /* Carbon-only: the units toggle, the equivalents lines, the accrual chart,
- and the ledger bars' wider columns + period/basis/kg cells. Everything
- shared lives in status.css. */
- .tiles { margin-bottom: 12px; }
- .equiv { color: var(--ink-2); margin: 0 0 28px; }
- /* The undercounts note follows the ledger list flush — give it air. */
- #notcounted { margin-top: 18px; }
-
- .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 0 0 10px; }
+ /* Carbon-page-only styles — the Built/Run sections, units toggle, everyday
+ anchors, accrual chart, and the ledger bars' wider columns. Everything
+ shared (tokens, header, nav, .bars base, #error, footer) is in status.css. */
+
+ .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 12px 0 24px; }
.row h2 { margin: 0; }
/* Metric/imperial: a labelled two-button group; the selected unit reads by
- weight + an underline mark, never color alone. */
+ weight + an underline mark, never color alone. One toggle drives both
+ sections. */
.units { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.units button {
font: inherit; font-size: 13px; padding: 4px 12px; border: 0; cursor: pointer;
@@ -39,9 +43,33 @@
}
.units button:focus-visible { outline: 2px solid var(--series); outline-offset: -2px; }
- /* Cumulative chart: single series (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. */
+ /* Two peer sections — Built (one-time) and Run (ongoing). The big heading
+ names each; sub-heads inside are h3. */
+ .part { border-top: 1px solid var(--border); padding-top: 22px; margin-top: 30px; }
+ .part:first-of-type { border-top: 0; padding-top: 0; margin-top: 8px; }
+ .part-title { font-size: 19px; font-weight: 650; margin: 0 0 4px; }
+ .part-lede { color: var(--ink-2); margin: 0 0 16px; }
+ .part h3 { font-size: 15px; font-weight: 650; margin: 22px 0 10px; }
+
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 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: 28px; font-weight: 600; margin-top: 2px; }
+ .equiv { color: var(--ink-2); margin: 0 0 14px; }
+ #built-notcounted { margin-top: 18px; }
+
+ /* Everyday anchors: a short list, each an "≈ …" line of real text; the mark
+ is decorative (aria-hidden) so screen readers just read the phrase. */
+ .anchors { list-style: none; padding: 0; margin: 2px 0 8px; }
+ .anchors li { color: var(--ink-2); padding: 3px 0 3px 22px; position: relative; }
+ .anchors li::before {
+ content: "\2248"; position: absolute; left: 0; color: var(--series);
+ font-weight: 650;
+ }
+
+ /* Cumulative chart (Built): 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; }
@@ -54,12 +82,14 @@
#tip .sub { color: var(--ink-2); }
.pt:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 50%; }
- /* Measurement ledger: wider first column + a kg cell; shared .bars supplies
- the container, rows, and bar mark. */
+ /* Built ledger + Run breakdown both use .bars (base in status.css); they
+ just want a wider first column + a kg cell. */
.bars li { grid-template-columns: minmax(120px, 200px) 1fr 5.5em; }
.bars .period { font-size: 14px; font-weight: 600; }
.bars .basis { display: block; font-size: 12px; font-weight: 400; color: var(--ink-2); }
.bars .kg { font-size: 14px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
+
+ #run-pending { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; color: var(--ink-2); }
</style>
</head>
<body>
@@ -79,73 +109,115 @@
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
</nav>
- <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:
+ <p class="lede">Eleven is AI-written, and the honest carbon ledger has two
+ halves: what it cost to <em>build</em> the app (the AI tokens spent writing
+ it) and what it costs to <em>run</em> it (every service in the fleet, every
+ day). Building is one-time and grows only as more AI development happens;
+ running is ongoing and rises with the fleet. 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>
+
+ <div class="row">
+ <p class="asof">Last measured <time id="asof" datetime="">—</time></p>
+ <div class="units" role="group" aria-label="Measurement units">
+ <button type="button" id="u-metric" aria-pressed="false">Metric</button>
+ <button type="button" id="u-imperial" aria-pressed="false">Imperial</button>
+ </div>
+ </div>
<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">
- <div class="row">
- <h2 id="h-total">Estimated so far</h2>
- <div class="units" role="group" aria-label="Measurement units">
- <button type="button" id="u-metric" aria-pressed="false">Metric</button>
- <button type="button" id="u-imperial" aria-pressed="false">Imperial</button>
- </div>
- </div>
+ <!-- BUILT — the one-time AI cost of making the app -->
+ <section class="part" id="built" aria-labelledby="h-built">
+ <h2 class="part-title" id="h-built">Built — one-time, to make it</h2>
+ <p class="part-lede">A running estimate of the CO₂e cost of the LLM tokens
+ spent building Eleven. 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>. This grows only when more AI
+ development happens.</p>
+ <div class="tiles">
+ <div class="tile"><div class="label">Central estimate</div><div class="value" id="bt-central">—</div></div>
+ <div class="tile"><div class="label">Honest range</div><div class="value" id="bt-range">—</div></div>
+ <div class="tile"><div class="label">Output tokens</div><div class="value" id="bt-tokens">—</div></div>
+ </div>
+ <p class="equiv" style="margin-bottom:4px">That’s about the same CO₂e as, centrally:</p>
+ <ul class="anchors" id="built-anchors"></ul>
+ <p class="equiv" id="built-band"></p>
+
+ <h3 id="h-built-chart">Accrual over time</h3>
+ <figure>
+ <svg id="chart" viewBox="0 0 720 240" role="img" aria-label=""></svg>
+ <div id="tip"></div>
+ <figcaption id="chart-cap">Cumulative CO₂e — the line is the central
+ estimate, the wash is the low–high band. Exact figures are in the ledger
+ below.</figcaption>
+ </figure>
+
+ <h3 id="h-built-entries">The ledger (central estimate per period)</h3>
+ <ul class="bars" id="built-entries"><li><span class="period">Loading…</span></li></ul>
+ <p class="equiv" id="built-notcounted"></p>
+ </section>
+
+ <!-- RUN — the ongoing cost of operating the whole fleet -->
+ <section class="part" id="run" aria-labelledby="h-run">
+ <h2 class="part-title" id="h-run">Run — ongoing, to operate it</h2>
+ <p class="part-lede">A live estimate of the CO₂e cost of running every
+ service in the fleet — the router box and its instances, object storage, and
+ bot inference — banded low to high. It rises as boxes are added; the
+ per-space figure is what stays meaningful at any size.</p>
+
+ <div id="run-body" hidden>
<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 class="tile"><div class="label">Per day, whole fleet</div><div class="value" id="rt-daily">—</div><div class="label" id="rt-daily-range">—</div></div>
+ <div class="tile"><div class="label" id="rt-cum-label">Cumulative to date</div><div class="value" id="rt-cum">—</div><div class="label" id="rt-cum-range">—</div></div>
+ <div class="tile"><div class="label">Per space, per day</div><div class="value" id="rt-space">—</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 id="chart-cap">Cumulative 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 estimate per period)</h2>
- <ul class="bars" id="entries"><li><span class="period">Loading…</span></li></ul>
- <p class="equiv" id="notcounted"></p>
- </section>
- </div>
+ <p class="equiv" style="margin-bottom:4px" id="run-anchors-lede">Cumulatively so far, that’s about the same CO₂e as, centrally:</p>
+ <ul class="anchors" id="run-anchors"></ul>
+ <p class="equiv" id="run-band"></p>
+
+ <h3 id="h-run-breakdown">Where the watts go (central, per day)</h3>
+ <ul class="bars" id="run-bars"></ul>
+ <p class="equiv" id="run-assumptions"></p>
+ </div>
+ <p id="run-pending" hidden>Operational estimate pending — the live fleet
+ inputs (server type and grid) weren’t available at the last publish. It
+ fills in on the next successful publish; nothing here is ever shown as a
+ fake zero.</p>
+ </section>
<footer>
- <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>
+ <p>The build tally accrues when we re-measure (at least weekly — it’s
+ committed to the repo and republished with these dashboards). The run
+ estimate is recomputed live at each publish. How the two compare in full is
+ in 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.
+// web/status/carbon.json (committed `built`) + the run block the publisher
+// injects, and checked by test/status-contract.test.mjs. Built fields resolve
+// against the committed file; run fields resolve against the live/dry-run
+// fixture (run is absent from the committed file → the Run section is pending).
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",
+ "built.factors_mg_co2e_per_output_token.low",
+ "built.factors_mg_co2e_per_output_token.central",
+ "built.factors_mg_co2e_per_output_token.high",
+ "built.not_counted",
+ "built.entries[].period_start", "built.entries[].period_end", "built.entries[].source",
+ "built.entries[].basis", "built.entries[].output_tokens", "built.entries[].kg_low",
+ "built.entries[].kg_central", "built.entries[].kg_high", "built.entries[].note",
+ "built.totals.output_tokens", "built.totals.kg_low", "built.totals.kg_central", "built.totals.kg_high",
+ "run.generated_at", "run.since", "run.spaces", "run.pue", "run.vcpus",
+ "run.watts.low", "run.watts.central", "run.watts.high",
+ "run.grid_g_per_kwh.low", "run.grid_g_per_kwh.central", "run.grid_g_per_kwh.high",
+ "run.daily_kg.low", "run.daily_kg.central", "run.daily_kg.high",
+ "run.cumulative_kg.low", "run.cumulative_kg.central", "run.cumulative_kg.high",
+ "run.per_space_daily_kg.low", "run.per_space_daily_kg.central", "run.per_space_daily_kg.high",
+ "run.services[].name", "run.services[].daily_kg_central",
];
const $ = (id) => document.getElementById(id);
@@ -153,12 +225,28 @@ const day = (iso) => new Date(iso).toLocaleDateString(undefined, { month: "short
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;
+// Everyday-equivalent factors (all documented + banded + dated in
+// docs/comparison-carbon.md § What the anchors mean; snapshot July 2026):
+// petrol car ~170 g CO₂e/km, burning 1 L petrol ≈ 2.3 kg (DEFRA class).
+// short-haul air ~0.145 kg CO₂e per economy passenger-km INCLUDING the
+// non-CO₂ altitude uplift (~1.9× radiative forcing; DEFRA
+// short-haul-with-RF class, band 0.13–0.16). Expressed against
+// one real short leg — Dublin–London (DUB–LHR) ≈ 460 km great
+// circle → ≈ 67 kg CO₂e per economy leg.
+// leaf blower 2-stroke ~1 L/h → ~2.3 kg CO₂/h.
+// beef ~60 kg CO₂e/kg, land-use-inclusive (Poore & Nemecek 2018).
+// tree a mature tree absorbs ~21 kg CO₂/yr.
+// These apply to BOTH sections, each scaled to its own magnitude.
+const CAR_G_PER_KM = 170, PETROL_KG_PER_L = 2.3;
+const FLIGHT_KG_PER_PAX_KM = 0.145, FLIGHT_LEG_KM = 460;
+const FLIGHT_LEG_KG = FLIGHT_KG_PER_PAX_KM * FLIGHT_LEG_KM; // ≈ 67 kg / leg
+const LEAFBLOWER_KG_PER_H = 2.3;
+const BEEF_KG_CO2E_PER_KG = 60;
+const TREE_KG_PER_YEAR = 21;
-// The JSON is canonical metric; imperial is a display conversion only.
+// The JSON is canonical metric; imperial is a display conversion only. Distances
+// and volumes convert (car km/litres); the new anchors are mass/time/count —
+// mass converts via massF (beef), counts/durations stay unit-agnostic.
const UNITS = {
metric: { mass: "kg", massF: 1, dist: "km", distF: 1, vol: "litres", volF: 1 },
imperial: { mass: "lb", massF: 2.20462, dist: "miles", distF: 0.621371, vol: "US gallons", volF: 0.264172 },
@@ -177,12 +265,74 @@ function initialUnits() {
}
let units = initialUnits();
const U = () => UNITS[units];
-// Whole numbers once they're big enough to not need the decimal.
+// Whole numbers once they're big enough to not need the decimal (used by the
+// chart's kg-scale axis).
const nice = (v) => (Math.abs(v) >= 20 ? Math.round(v) : Math.round(v * 10) / 10);
const mass = (kg) => nice(kg * U().massF);
+// Adaptive precision for a plain number: fewer decimals the bigger it is.
+function fmtNum(v) {
+ const a = Math.abs(v);
+ const r = a >= 100 ? Math.round(v) : a >= 1 ? Math.round(v * 10) / 10
+ : a >= 0.1 ? Math.round(v * 100) / 100 : Math.round(v * 1000) / 1000;
+ return r.toLocaleString();
+}
+// Mass, adaptive across scales so tiny run figures don't collapse to "0 kg":
+// grams under a kilo (metric), ounces under a pound (imperial).
+function fmtMass(kg) {
+ if (units === "metric") return Math.abs(kg) < 1 ? fmtNum(kg * 1000) + " g" : fmtNum(kg) + " kg";
+ const lb = kg * UNITS.imperial.massF;
+ return Math.abs(lb) < 1 ? fmtNum(lb * 16) + " oz" : fmtNum(lb) + " lb";
+}
+// A low–high pair: collapse to one unit when both ends share it, else keep both.
+function rangeStr(lo, hi) {
+ const a = fmtMass(lo), b = fmtMass(hi);
+ const unit = (s) => s.replace(/^[\d.,]+\s*/, "");
+ return unit(a) === unit(b) ? a.replace(/\s*\S+$/, "") + "–" + b : a + " – " + b;
+}
+const plur = (n, s) => n + " " + (n === 1 ? s : s + "s");
let lastData = null;
+// ---- everyday anchors (shared by both sections) ------------------------------
+function flightPhrase(legs) {
+ if (legs >= 0.95) return "about " + plur(nice(legs), "Dublin–London economy flight");
+ const pct = legs * 100;
+ return "about " + (pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10) + "% of one Dublin–London economy flight";
+}
+function durationPhrase(hours) {
+ if (hours >= 1) return "for about " + plur(nice(hours), "hour");
+ const mins = hours * 60;
+ if (mins >= 1) return "for about " + plur(Math.round(mins), "minute");
+ return "for about " + plur(Math.max(1, Math.round(mins * 60)), "second");
+}
+function treePhrase(years) {
+ if (years >= 1) return "about " + plur(nice(years), "tree-year") + " to soak it back up";
+ const days = years * 365;
+ if (days >= 1) return "about " + plur(Math.round(days), "tree-day") + " to soak it back up";
+ return "under a tree-day to soak it back up";
+}
+// Build the anchor list for a given central-estimate kg. Central values only;
+// the band is noted once beneath (in the *-band element).
+function anchorList(ul, kg) {
+ ul.replaceChildren();
+ const U0 = U();
+ const items = [
+ "driving a petrol car about " + fmtNum(kg * 1000 / CAR_G_PER_KM * U0.distF) + " " + U0.dist +
+ " (burning ~" + fmtNum(kg / PETROL_KG_PER_L * U0.volF) + " " + U0.vol + " of petrol)",
+ flightPhrase(kg / FLIGHT_LEG_KG),
+ "running a petrol leaf blower " + durationPhrase(kg / LEAFBLOWER_KG_PER_H),
+ "about " + fmtMass(kg / BEEF_KG_CO2E_PER_KG) + " of beef",
+ treePhrase(kg / TREE_KG_PER_YEAR),
+ ];
+ for (const t of items) {
+ const li = document.createElement("li");
+ li.textContent = t;
+ ul.append(li);
+ }
+}
+const bandNote = (lo, hi) => "Central estimates; the honest range is " + rangeStr(lo, hi) +
+ " CO₂e. Flights are economy short-haul, including the altitude (radiative-forcing) uplift.";
+
// All rendering is textContent / createElement — never innerHTML of fetched
// data, even though the feed is our own.
function render(d) {
@@ -193,29 +343,30 @@ 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;
+ renderBuilt(d.built);
+ renderRun(d.run);
+}
+
+function renderBuilt(b) {
+ const f = b.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 = "~" + mass(d.totals.kg_central) + " " + U().mass + " CO₂e";
- $("t-range").textContent = mass(d.totals.kg_low) + "–" + mass(d.totals.kg_high) + " " + U().mass;
- $("t-tokens").textContent = compact.format(d.totals.output_tokens);
+ $("bt-central").textContent = "~" + fmtMass(b.totals.kg_central) + " CO₂e";
+ $("bt-range").textContent = rangeStr(b.totals.kg_low, b.totals.kg_high);
+ $("bt-tokens").textContent = compact.format(b.totals.output_tokens);
- const dist = nice(d.totals.kg_central * 1000 / CAR_G_PER_KM * U().distF);
- const vol = nice(d.totals.kg_central / PETROL_KG_PER_L * U().volF);
- 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 " + dist + " " + U().dist +
- " (burning ~" + vol + " " + U().vol + " of petrol) — or roughly " +
- memberYears.toLocaleString() + " member-years of actually running the service.";
+ anchorList($("built-anchors"), b.totals.kg_central);
+ $("built-band").textContent = bandNote(b.totals.kg_low, b.totals.kg_high);
$("chart-cap").textContent = "Cumulative " + U().mass + " CO₂e — the line is the central estimate, " +
"the wash is the low–high band. Exact figures are in the ledger below.";
- chart(d);
+ builtChart(b);
- const list = $("entries");
+ const list = $("built-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 entries = (b.entries || []).slice().sort((a, c) => Date.parse(a.period_end) - Date.parse(c.period_end));
const max = Math.max(1, ...entries.map((e) => e.kg_central));
for (const e of entries) {
const li = document.createElement("li");
@@ -237,27 +388,78 @@ function render(d) {
track.append(bar);
const kg = document.createElement("span");
kg.className = "kg";
- kg.textContent = mass(e.kg_central) + " " + U().mass;
+ kg.textContent = fmtMass(e.kg_central);
+ li.append(period, track, kg);
+ list.append(li);
+ }
+
+ $("built-notcounted").textContent = "Known undercounts, on purpose: " + b.not_counted + ".";
+}
+
+function renderRun(r) {
+ const body = $("run-body"), pending = $("run-pending");
+ if (!r) { body.hidden = true; pending.hidden = false; return; }
+ body.hidden = false; pending.hidden = true;
+
+ $("rt-daily").textContent = "~" + fmtMass(r.daily_kg.central);
+ $("rt-daily-range").textContent = "range " + rangeStr(r.daily_kg.low, r.daily_kg.high);
+ $("rt-cum-label").textContent = "Cumulative since " + day(r.since);
+ $("rt-cum").textContent = "~" + fmtMass(r.cumulative_kg.central);
+ $("rt-cum-range").textContent = "range " + rangeStr(r.cumulative_kg.low, r.cumulative_kg.high);
+ $("rt-space").textContent = "~" + fmtMass(r.per_space_daily_kg.central);
+
+ // Anchors scale to the cumulative-to-date central — the run section's most
+ // legible magnitude as the fleet grows (a single day is grams).
+ $("run-anchors-lede").textContent = "Cumulatively since " + day(r.since) +
+ ", that’s about the same CO₂e as, centrally:";
+ anchorList($("run-anchors"), r.cumulative_kg.central);
+ $("run-band").textContent = bandNote(r.cumulative_kg.low, r.cumulative_kg.high);
+
+ // Service breakdown: central daily kg per service (rows text, bars aria-hidden).
+ const list = $("run-bars");
+ list.replaceChildren();
+ const max = Math.max(1e-9, ...r.services.map((s) => s.daily_kg_central));
+ for (const s of r.services) {
+ const li = document.createElement("li");
+ const period = document.createElement("span");
+ period.className = "period";
+ period.textContent = s.name;
+ 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 * s.daily_kg_central / max) + "%";
+ bar.style.display = "block";
+ track.append(bar);
+ const kg = document.createElement("span");
+ kg.className = "kg";
+ kg.textContent = fmtMass(s.daily_kg_central);
li.append(period, track, kg);
list.append(li);
}
- $("notcounted").textContent = "Known undercounts, on purpose: " + d.not_counted + ".";
+ const asof = new Date(r.generated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
+ $("run-assumptions").textContent = "Estimated from ~" + r.vcpus.toLocaleString() + " vCPUs drawing ~" +
+ r.watts.central.toLocaleString() + " W (central), a ~" + r.grid_g_per_kwh.central.toLocaleString() +
+ " g CO₂e/kWh grid and PUE ~" + r.pue + ", plus small explicit terms for object storage and bot " +
+ "inference; normalised over " + plur(r.spaces, "space") + ". Recomputed live at each publish (as of " +
+ asof + ").";
}
-// Cumulative line over time: central line + low–high wash. Crosshair snaps to
-// the nearest measurement; points are keyboard-focusable with the same tooltip.
-// Values are cumulated in canonical kg, converted to the display unit at the
-// last moment so labels, ticks, and tooltip always agree.
-function chart(d) {
+// Cumulative line over time (Built): central line + low–high wash. Crosshair
+// snaps to the nearest measurement; points are keyboard-focusable with the same
+// tooltip. Values are cumulated in canonical kg, converted to the display unit
+// at the last moment so labels, ticks, and tooltip always agree.
+function builtChart(b) {
const svg = $("chart");
svg.replaceChildren();
const round1 = (v) => Math.round(v * 10) / 10;
const disp = (kg) => nice(kg * U().massF);
// 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 ordered = b.entries.slice().sort((a, c) => Date.parse(a.period_end) - Date.parse(c.period_end));
+ const t0 = Math.min(...b.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) {
diff --git a/web/status/carbon.json b/web/status/carbon.json
index 1083b59..a06d0de 100644
--- a/web/status/carbon.json
+++ b/web/status/carbon.json
@@ -1,61 +1,63 @@
{
"updated_at": "2026-07-12T11:08:41.593Z",
- "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"
+ "built": {
+ "unit": "kg CO2e",
+ "factors_mg_co2e_per_output_token": {
+ "low": 0.1,
+ "central": 0.55,
+ "high": 3
},
- {
- "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"
- },
- {
- "period_start": "2026-07-03T08:19:42.191Z",
- "period_end": "2026-07-12T11:08:41.593Z",
- "source": "barbara-painting",
- "basis": "measured",
- "sessions": 171,
- "output_tokens": 27347111,
- "fresh_input_tokens": 171281918,
- "cache_read_tokens": 9095881269,
- "kg_low": 2.7,
- "kg_central": 15,
- "kg_high": 82,
- "note": "local Claude Code transcripts, one dev machine"
+ "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"
+ },
+ {
+ "period_start": "2026-07-03T08:19:42.191Z",
+ "period_end": "2026-07-12T11:08:41.593Z",
+ "source": "barbara-painting",
+ "basis": "measured",
+ "sessions": 171,
+ "output_tokens": 27347111,
+ "fresh_input_tokens": 171281918,
+ "cache_read_tokens": 9095881269,
+ "kg_low": 2.7,
+ "kg_central": 15,
+ "kg_high": 82,
+ "note": "local Claude Code transcripts, one dev machine"
+ }
+ ],
+ "totals": {
+ "output_tokens": 61334823,
+ "kg_low": 6.1,
+ "kg_central": 33.7,
+ "kg_high": 184
}
- ],
- "totals": {
- "output_tokens": 61334823,
- "kg_low": 6.1,
- "kg_central": 33.7,
- "kg_high": 184
}
}