diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 34c8297..2f40043 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -47,6 +47,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Generate loaf registry index
+ run: python3 scripts/generate_loaf_index.py
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
- name: Generate grain registry index
run: python3 scripts/generate_grain_index.py
env:
diff --git a/_data/loaf-index.json b/_data/loaf-index.json
new file mode 100644
index 0000000..7c405b3
--- /dev/null
+++ b/_data/loaf-index.json
@@ -0,0 +1,52 @@
+{
+ "generated": "2026-07-13T04:39:09Z",
+ "total": 2,
+ "summary": {
+ "prototype": 2
+ },
+ "loaves": [
+ {
+ "repo": "Loaf_x004",
+ "id": "loaf-x004",
+ "name": "x004 Loaf",
+ "status": "prototype",
+ "role": "backplane",
+ "summary": "Four-Slice backplane distributing 12V and I2C over the Slice bus.",
+ "slice_slots": 4,
+ "bus_type": "i2c",
+ "hw_version": "0.3.0",
+ "hw_gen_current": 2,
+ "pcb_layers": 2,
+ "schema_version": "1.0",
+ "tags": [
+ "backplane",
+ "four-slice",
+ "i2c"
+ ],
+ "updated": "2026-07-13",
+ "url": "https://feastorg.github.io/Loaf_x004/"
+ },
+ {
+ "repo": "Loaf_ESPT",
+ "id": "loaf-espt",
+ "name": "ESP32 Thing Plus Controller Loaf",
+ "status": "prototype",
+ "role": "controller",
+ "summary": "Controller Loaf carrying a SparkFun ESP32 Thing Plus, chaining onto a backplane.",
+ "slice_slots": 0,
+ "bus_type": "i2c",
+ "hw_version": "0.2.0",
+ "hw_gen_current": 2,
+ "pcb_layers": 2,
+ "schema_version": "1.0",
+ "tags": [
+ "controller",
+ "esp32",
+ "thing-plus",
+ "i2c"
+ ],
+ "updated": "2026-07-13",
+ "url": "https://feastorg.github.io/Loaf_ESPT/"
+ }
+ ]
+}
diff --git a/_pages/grains.md b/_pages/grains.md
index e41e22b..2b38358 100644
--- a/_pages/grains.md
+++ b/_pages/grains.md
@@ -2,7 +2,7 @@
layout: default
title: Grain Registry
parent: Projects
-nav_order: 2
+nav_order: 3
has_children: true
permalink: /grains/
---
diff --git a/_pages/loaves.md b/_pages/loaves.md
new file mode 100644
index 0000000..3c3b865
--- /dev/null
+++ b/_pages/loaves.md
@@ -0,0 +1,60 @@
+---
+layout: default
+title: Loaf Registry
+parent: Projects
+nav_order: 2
+has_children: true
+permalink: /loaves/
+---
+
+# Loaf Registry
+
+All BREADS-compatible Loaves, auto-generated from each repo's `loaf.yaml` manifest.
+
+A **Loaf** is the attachment and interconnect layer that lets Slices operate together as
+one BREAD. A `backplane` provides Slice attachment; a `controller` provides the controller
+interface and chains onto a backplane, with no Slice slots of its own.
+
+{% assign loaves = site.data['loaf-index'].loaves %}
+{% assign summary = site.data['loaf-index'].summary %}
+{% assign generated = site.data['loaf-index'].generated %}
+{% assign total = site.data['loaf-index'].total %}
+
+{% if loaves %}
+
+**{{ total }} loaves** · Generated {{ generated | slice: 0, 10 }}
+{%- if summary.released and summary.released > 0 %} · released {{ summary.released }}{% endif %}
+{%- if summary.validated and summary.validated > 0 %} · validated {{ summary.validated }}{% endif %}
+{%- if summary.prototype and summary.prototype > 0 %} · prototype {{ summary.prototype }}{% endif %}
+{%- if summary.concept and summary.concept > 0 %} · concept {{ summary.concept }}{% endif %}
+{%- if summary.deprecated and summary.deprecated > 0 %} · deprecated {{ summary.deprecated }}{% endif %}
+
+---
+
+{% assign roles = "backplane,hybrid,controller" | split: "," %}
+
+{% for role in roles %}
+{% assign role_loaves = loaves | where: "role", role %}
+{% if role_loaves.size > 0 %}
+
+## {{ role | capitalize }}
+
+
+
+| Loaf | Summary | Slots | Bus | Status | HW | Tags |
+|---|---|---|---|---|---|---|
+{% for l in role_loaves -%}
+| [{{ l.name | default: l.repo }}]({{ l.url }}) | {{ l.summary | default: "—" }} | {{ l.slice_slots }} | {{ l.bus_type | default: "—" }} | {{ l.status }} | {% if l.hw_version %}v{{ l.hw_version }} (gen {{ l.hw_gen_current }}){% else %}—{% endif %} | {% if l.tags and l.tags.size > 0 %}{{ l.tags | join: ", " }}{% else %}—{% endif %} |
+{% endfor %}
+
+
+
+{% endif %}
+{% endfor %}
+
+{% else %}
+
+_No Loaf manifests found. The index is regenerated daily from `loaf.yaml` in each
+`Loaf_*` repo._
+
+{% endif %}
diff --git a/scripts/generate_loaf_index.py b/scripts/generate_loaf_index.py
new file mode 100644
index 0000000..3ae2f7a
--- /dev/null
+++ b/scripts/generate_loaf_index.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""
+Generate _data/loaf-index.json from loaf.yaml manifests across all Loaf_* repos
+in the feastorg GitHub organization.
+
+Usage:
+ python3 scripts/generate_loaf_index.py
+
+Environment:
+ GITHUB_TOKEN — required; a token with read access to the feastorg org.
+ GITHUB_TOKEN is available automatically in Actions.
+
+Output:
+ _data/loaf-index.json
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+try:
+ import requests
+except ImportError:
+ sys.exit("Error: 'requests' not installed. Run: pip install requests")
+
+try:
+ import yaml
+except ImportError:
+ sys.exit("Error: 'pyyaml' not installed. Run: pip install pyyaml")
+
+ORG = "feastorg"
+OUTPUT = Path("_data/loaf-index.json")
+API = "https://api.github.com"
+
+# Fields to extract from each loaf.yaml (all nullable)
+EXTRACT = [
+ ("id", lambda m: m.get("id")),
+ ("name", lambda m: m.get("name")),
+ ("status", lambda m: m.get("status")),
+ ("role", lambda m: m.get("role")),
+ ("summary", lambda m: m.get("summary")),
+ ("slice_slots", lambda m: (m.get("interconnect") or {}).get("slice_slots")),
+ ("bus_type", lambda m: ((m.get("interconnect") or {}).get("bus") or {}).get("type")),
+ ("hw_version", lambda m: (m.get("version") or {}).get("hardware")),
+ ("hw_gen_current", lambda m: (m.get("hardware") or {}).get("hw_gen_current")),
+ ("pcb_layers", lambda m: ((m.get("hardware") or {}).get("pcb") or {}).get("layers")),
+ ("schema_version", lambda m: m.get("schema_version")),
+ ("tags", lambda m: (m.get("metadata") or {}).get("tags", [])),
+ ("updated", lambda m: (m.get("metadata") or {}).get("updated")),
+]
+
+STATUS_ORDER = ["released", "validated", "prototype", "concept", "deprecated"]
+ROLE_ORDER = ["backplane", "hybrid", "controller"]
+
+
+def gh_session() -> requests.Session:
+ token = os.environ.get("GITHUB_TOKEN", "")
+ if not token:
+ sys.exit("Error: GITHUB_TOKEN environment variable not set.")
+ s = requests.Session()
+ s.headers.update(
+ {
+ "Authorization": f"Bearer {token}",
+ "Accept": "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ }
+ )
+ return s
+
+
+def list_loaf_repos(session: requests.Session) -> list[str]:
+ """Return names of all public Loaf_* repos in the org."""
+ repos = []
+ page = 1
+ while True:
+ r = session.get(
+ f"{API}/orgs/{ORG}/repos",
+ params={"type": "public", "per_page": 100, "page": page},
+ )
+ r.raise_for_status()
+ batch = r.json()
+ if not batch:
+ break
+ repos.extend(repo["name"] for repo in batch if repo["name"].startswith("Loaf_"))
+ page += 1
+ return sorted(repos)
+
+
+def fetch_manifest(session: requests.Session, repo: str) -> dict | None:
+ """Fetch and parse loaf.yaml from the repo's default branch."""
+ url = f"https://raw.githubusercontent.com/{ORG}/{repo}/main/loaf.yaml"
+ r = session.get(url)
+ if r.status_code == 404:
+ print(f" SKIP {repo}: no loaf.yaml", flush=True)
+ return None
+ r.raise_for_status()
+ try:
+ return yaml.safe_load(r.text)
+ except yaml.YAMLError as e:
+ print(f" WARN {repo}: YAML parse error — {e}", flush=True)
+ return None
+
+
+def extract(manifest: dict, repo: str) -> dict:
+ entry = {"repo": repo}
+ for key, fn in EXTRACT:
+ try:
+ entry[key] = fn(manifest)
+ except Exception:
+ entry[key] = None
+ entry["url"] = f"https://feastorg.github.io/{repo}/"
+ return entry
+
+
+def sort_key(entry: dict) -> tuple:
+ status_rank = (
+ STATUS_ORDER.index(entry["status"]) if entry["status"] in STATUS_ORDER else 99
+ )
+ role_rank = ROLE_ORDER.index(entry["role"]) if entry["role"] in ROLE_ORDER else 99
+ return (status_rank, role_rank, entry.get("id") or "")
+
+
+def main() -> None:
+ session = gh_session()
+
+ print(f"Listing Loaf_* repos in {ORG}...", flush=True)
+ repos = list_loaf_repos(session)
+ print(f"Found {len(repos)} repos.", flush=True)
+
+ loaves = []
+ for repo in repos:
+ print(f" Fetching {repo}...", flush=True)
+ manifest = fetch_manifest(session, repo)
+ if manifest is None:
+ continue
+ loaves.append(extract(manifest, repo))
+
+ loaves.sort(key=sort_key)
+
+ summary: dict[str, int] = {}
+ for entry in loaves:
+ key = entry.get("status") or "unknown"
+ summary[key] = summary.get(key, 0) + 1
+
+ output = {
+ "generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "total": len(loaves),
+ "summary": summary,
+ "loaves": loaves,
+ }
+
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+ OUTPUT.write_text(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
+ print(f"\nWrote {len(loaves)} loaves to {OUTPUT}", flush=True)
+ for status, count in sorted(summary.items()):
+ print(f" {status}: {count}", flush=True)
+
+
+if __name__ == "__main__":
+ main()