diff --git a/CLAUDE.md b/CLAUDE.md
index f9e21d8..c0b7162 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -464,6 +464,7 @@ Add-on skills (activated when corresponding add-on is installed):
- `maker-lab.md` — STEM education companion for kids: scaffolded AI tutor, hint-ladder pedagogy, age-banded personas (kid/tween/adult), solo/family/classroom modes, guest sidecar
- `gotosocial.md` — GoToSocial ActivityPub microblog: post, follow, search, moderate (block_user/mute inline; defederate/block_domain/import_blocklist queued for operator confirmation), media prune, federation health
- `writefreely.md` — WriteFreely federated blog: create/update/publish/unpublish posts, list collections, fetch public posts, export; minimalist publisher (no comments, no moderation queue — WF is publish-oriented only)
+- `matrix-dendrite.md` — Matrix homeserver on Dendrite: create/join/leave rooms, send messages, sync, invite users, federation health; appservice registration prep for F.12 bridges; :8448-vs-well-known either/or federation story
- `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS
- `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download
- `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read
diff --git a/bundles/matrix-dendrite/.env.example b/bundles/matrix-dendrite/.env.example
new file mode 100644
index 0000000..8836055
--- /dev/null
+++ b/bundles/matrix-dendrite/.env.example
@@ -0,0 +1,32 @@
+# Matrix-Dendrite — required config
+
+# Public server_name — the part that appears in Matrix user IDs
+# (@alice:example.com). If you use .well-known delegation, set this to
+# your apex (example.com). If you run dendrite directly at a subdomain,
+# set to the subdomain (matrix.example.com).
+MATRIX_SERVER_NAME=example.com
+
+# Domain Caddy reverse-proxies the client-server API on. Usually a
+# subdomain (matrix.example.com) even when MATRIX_SERVER_NAME is the
+# apex — .well-known on the apex delegates to this.
+MATRIX_HOST=matrix.example.com
+
+# Postgres password for the bundled database. Generate with:
+# openssl rand -base64 32 | tr -d '/+=' | head -c 40
+MATRIX_POSTGRES_PASSWORD=
+
+# Internal URL the Crow MCP server uses to reach Dendrite over the
+# crow-federation docker network.
+MATRIX_URL=http://dendrite:8008
+
+# Filled AFTER the admin account is registered (see scripts/post-install.sh
+# output). Obtain via POST /_matrix/client/v3/login.
+MATRIX_ACCESS_TOKEN=
+MATRIX_USER_ID=@admin:example.com
+
+# Populated on first boot by the dendrite entrypoint (printed to logs).
+# Copy the "Registration shared secret: XXX" line here.
+MATRIX_REGISTRATION_SHARED_SECRET=
+
+# Host data directory override.
+# MATRIX_DATA_DIR=/mnt/nvme/matrix-dendrite
diff --git a/bundles/matrix-dendrite/docker-compose.yml b/bundles/matrix-dendrite/docker-compose.yml
new file mode 100644
index 0000000..86170e1
--- /dev/null
+++ b/bundles/matrix-dendrite/docker-compose.yml
@@ -0,0 +1,99 @@
+# Matrix-Dendrite — federated real-time chat.
+#
+# Two-container bundle: dendrite + postgres. Both on crow-federation; no
+# host port publish. Caddy reverse-proxies :443 → dendrite:8008 for the
+# client-server API. Federation (:8448) is handled via either:
+# (a) caddy_add_matrix_federation_port forwarding :8448 → dendrite:8448
+# (router must forward 8448/tcp), OR
+# (b) caddy_set_wellknown matrix-server on the apex domain
+#
+# Data:
+# ~/.crow/matrix-dendrite/dendrite/ signing keys, media, jetstream
+# ~/.crow/matrix-dendrite/postgres/ Postgres data dir
+# First-boot entrypoint generates signing keys + dendrite.yaml if absent.
+
+networks:
+ crow-federation:
+ external: true
+ default:
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ container_name: crow-dendrite-postgres
+ networks:
+ - default
+ environment:
+ POSTGRES_USER: dendrite
+ POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD}
+ POSTGRES_DB: dendrite
+ # Single-database install: Dendrite accepts a single DB URI and
+ # manages its own schema namespaces internally.
+ volumes:
+ - ${MATRIX_DATA_DIR:-~/.crow/matrix-dendrite}/postgres:/var/lib/postgresql/data
+ init: true
+ mem_limit: 1g
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U dendrite"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 20s
+
+ dendrite:
+ image: matrixdotorg/dendrite-monolith:v0.13.8
+ container_name: crow-dendrite
+ networks:
+ - default
+ - crow-federation
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME}
+ MATRIX_POSTGRES_URL: "postgres://dendrite:${MATRIX_POSTGRES_PASSWORD}@postgres:5432/dendrite?sslmode=disable"
+ DENDRITE_TRACE_HTTP: "0"
+ volumes:
+ - ${MATRIX_DATA_DIR:-~/.crow/matrix-dendrite}/dendrite:/etc/dendrite
+ entrypoint:
+ - sh
+ - -c
+ - |
+ set -e
+ CFG=/etc/dendrite/dendrite.yaml
+ KEY=/etc/dendrite/matrix_key.pem
+ if [ ! -f "$$CFG" ]; then
+ # First boot — generate signing key + config
+ /usr/bin/generate-keys --private-key "$$KEY"
+ # Generate minimal config via the bundled template approach.
+ # Dendrite's "generate-config" tool wants to print to stdout.
+ /usr/bin/generate-config \
+ --server "$${MATRIX_SERVER_NAME}" \
+ --db "$${MATRIX_POSTGRES_URL}" \
+ --private-key "$$KEY" \
+ > "$$CFG"
+ # Enable registration with shared secret for the host-side
+ # create-account helper. Patch the generated yaml in place:
+ SECRET=$$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 48)
+ # shellcheck disable=SC2016
+ sed -i "s|^\\s*registration_shared_secret:.*| registration_shared_secret: \"$$SECRET\"|" "$$CFG"
+ # Ensure federation + metrics are on
+ sed -i "s|^\\s*disable_federation:.*| disable_federation: false|" "$$CFG"
+ echo "Generated /etc/dendrite/dendrite.yaml with fresh signing key."
+ echo "Registration shared secret: $$SECRET"
+ echo "(copy this into MATRIX_REGISTRATION_SHARED_SECRET in .env)"
+ fi
+ exec /usr/bin/dendrite \
+ --config /etc/dendrite/dendrite.yaml \
+ --http-bind-address 0.0.0.0:8008 \
+ --https-bind-address 0.0.0.0:8448
+ init: true
+ mem_limit: 4g
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8008/_matrix/client/versions >/dev/null 2>&1 || exit 1"]
+ interval: 30s
+ timeout: 10s
+ retries: 10
+ start_period: 60s
diff --git a/bundles/matrix-dendrite/manifest.json b/bundles/matrix-dendrite/manifest.json
new file mode 100644
index 0000000..7c26782
--- /dev/null
+++ b/bundles/matrix-dendrite/manifest.json
@@ -0,0 +1,77 @@
+{
+ "id": "matrix-dendrite",
+ "name": "Matrix (Dendrite)",
+ "version": "1.0.0",
+ "description": "Matrix homeserver on Dendrite — federated real-time chat with end-to-end encryption. Lighter than Synapse, monolithic binary. Joins the Matrix federation graph via HTTPS federation (:8448 or .well-known delegation).",
+ "type": "bundle",
+ "author": "Crow",
+ "category": "federated-comms",
+ "tags": ["matrix", "dendrite", "chat", "federation", "e2ee", "realtime"],
+ "icon": "message-circle",
+ "docker": { "composefile": "docker-compose.yml" },
+ "server": {
+ "command": "node",
+ "args": ["server/index.js"],
+ "envKeys": ["MATRIX_URL", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID", "MATRIX_SERVER_NAME"]
+ },
+ "panel": "panel/matrix-dendrite.js",
+ "panelRoutes": "panel/routes.js",
+ "skills": ["skills/matrix-dendrite.md"],
+ "consent_required": true,
+ "install_consent_messages": {
+ "en": "Dendrite joins the public Matrix federation graph. Your homeserver becomes addressable at the domain you configure; any room you join, create, or publish is replicated (including message history) to every other server with a member in that room — and that replication cannot be fully recalled. Federation requires EITHER port 8448/tcp forwarded from your router to this host (Caddy will request a dedicated cert via caddy_add_matrix_federation_port), OR a .well-known/matrix/server JSON delegation on your apex domain (caddy_set_wellknown). Neither works without explicit setup. Dendrite stores all room state + message history + media in Postgres; federated joins of large rooms (e.g. Matrix HQ) can add tens of GB within days. Automatic media retention trimming is not yet wired up in this bundle — monitor disk. If your homeserver is reported for abuse, major homeservers (matrix.org, synapse.matrix.org) may defederate your domain; a poisoned domain cannot easily be rehabilitated. Dendrite itself is hardware-gated to refuse install on hosts with <8 GB RAM — the 2 GB minimum is only realistic when the homeserver isn't federated with busy rooms.",
+ "es": "Dendrite se une al grafo público de federación de Matrix. Tu homeserver será direccionable en el dominio que configures; cualquier sala a la que te unas, crees o publiques se replica (incluido el historial de mensajes) a todos los servidores que tengan un miembro en esa sala — y esa replicación no puede recuperarse completamente. La federación requiere O el puerto 8448/tcp reenviado desde tu router a este host (Caddy solicitará un certificado dedicado vía caddy_add_matrix_federation_port) O una delegación JSON en .well-known/matrix/server en tu dominio raíz (caddy_set_wellknown). Ninguno funciona sin configuración explícita. Dendrite almacena todo el estado de sala + historial + medios en Postgres; las uniones federadas a salas grandes (p. ej. Matrix HQ) pueden añadir decenas de GB en días. El recorte automático de retención de medios aún no está cableado en este paquete — monitoriza el disco. Si tu homeserver es reportado por abuso, los homeservers principales (matrix.org, synapse.matrix.org) pueden dejar de federarse con tu dominio; un dominio envenenado no puede rehabilitarse fácilmente. Dendrite se niega a instalarse en hosts con <8 GB RAM — el mínimo de 2 GB solo es realista cuando el homeserver no está federado con salas muy activas."
+ },
+ "requires": {
+ "env": ["MATRIX_SERVER_NAME", "MATRIX_POSTGRES_PASSWORD"],
+ "bundles": ["caddy"],
+ "min_ram_mb": 2048,
+ "recommended_ram_mb": 4096,
+ "min_disk_mb": 10000,
+ "recommended_disk_mb": 100000
+ },
+ "env_vars": [
+ {
+ "name": "MATRIX_SERVER_NAME",
+ "description": "The homeserver's public domain — this is the server_name that appears in user IDs (@alice:example.com). Typically an apex (example.com) with a .well-known/matrix/server delegation, OR a subdomain you'll expose on :8448 directly (matrix.example.com).",
+ "required": true
+ },
+ {
+ "name": "MATRIX_HOST",
+ "description": "Domain Caddy reverse-proxies the client-server API from. If using .well-known delegation, this differs from MATRIX_SERVER_NAME (e.g., server_name=example.com, host=matrix.example.com). If using :8448, usually the same as server_name.",
+ "required": false
+ },
+ {
+ "name": "MATRIX_POSTGRES_PASSWORD",
+ "description": "Password for the dendrite Postgres role (the bundle provisions Postgres inside the compose).",
+ "required": true,
+ "secret": true
+ },
+ {
+ "name": "MATRIX_URL",
+ "description": "Internal URL the Crow MCP server uses to reach Dendrite's client-server API (over the crow-federation docker network).",
+ "default": "http://dendrite:8008",
+ "required": false
+ },
+ {
+ "name": "MATRIX_ACCESS_TOKEN",
+ "description": "Access token for the admin account (obtain via POST /_matrix/client/v3/login after registering the admin via the Dendrite CLI).",
+ "required": false,
+ "secret": true
+ },
+ {
+ "name": "MATRIX_USER_ID",
+ "description": "Admin user's full Matrix ID (@admin:example.com). Used by the MCP server to scope tools to the admin context.",
+ "required": false
+ },
+ {
+ "name": "MATRIX_REGISTRATION_SHARED_SECRET",
+ "description": "Dendrite's registration shared secret (generated on first boot; used by the register-admin.sh script). Do not expose.",
+ "required": false,
+ "secret": true
+ }
+ ],
+ "ports": [],
+ "webUI": null,
+ "notes": "Two containers: dendrite + postgres. Both on crow-federation network; no host port publish. Federation needs EITHER caddy_add_matrix_federation_port (forwards 8448/tcp from router) OR caddy_set_wellknown matrix-server on the apex. Initial admin must be registered via `docker exec crow-dendrite create-account` after first boot."
+}
diff --git a/bundles/matrix-dendrite/package.json b/bundles/matrix-dendrite/package.json
new file mode 100644
index 0000000..49fb6ed
--- /dev/null
+++ b/bundles/matrix-dendrite/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "crow-matrix-dendrite",
+ "version": "1.0.0",
+ "description": "Dendrite (Matrix homeserver) MCP server — rooms, messages, sync, invites, federation health",
+ "type": "module",
+ "main": "server/index.js",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.12.0",
+ "zod": "^3.24.0"
+ }
+}
diff --git a/bundles/matrix-dendrite/panel/matrix-dendrite.js b/bundles/matrix-dendrite/panel/matrix-dendrite.js
new file mode 100644
index 0000000..b10238e
--- /dev/null
+++ b/bundles/matrix-dendrite/panel/matrix-dendrite.js
@@ -0,0 +1,182 @@
+/**
+ * Crow's Nest Panel — Matrix-Dendrite: status + rooms + federation health.
+ * XSS-safe (textContent + createElement only).
+ */
+
+export default {
+ id: "matrix-dendrite",
+ name: "Matrix (Dendrite)",
+ icon: "message-circle",
+ route: "/dashboard/matrix-dendrite",
+ navOrder: 72,
+ category: "federated-comms",
+
+ async handler(req, res, { layout }) {
+ const content = `
+
+
+
Matrix Dendrite homeserver
+
+
+
+
+
+
+
+
+
Notes
+
+ - Federation needs either port 8448 forwarded OR .well-known/matrix/server delegation on the apex.
matrix_federation_health surfaces both paths.
+ - Message posting via MCP sends plaintext. E2EE rooms need a real Matrix client (Element) that holds device keys.
+ - Backup includes the signing key — loss = identity loss, leak = impersonation. Encrypt the archive.
+
+
+
+
+ `;
+ res.send(layout({ title: "Matrix", content }));
+ },
+};
+
+function script() {
+ return `
+ function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
+ function row(label, value) {
+ const r = document.createElement('div'); r.className = 'mx-row';
+ const b = document.createElement('b'); b.textContent = label;
+ const s = document.createElement('span'); s.textContent = value == null ? '—' : String(value);
+ r.appendChild(b); r.appendChild(s); return r;
+ }
+ function err(msg) { const d = document.createElement('div'); d.className = 'np-error'; d.textContent = msg; return d; }
+
+ async function loadStatus() {
+ const el = document.getElementById('mx-status'); clear(el);
+ try {
+ const res = await fetch('/api/matrix-dendrite/status'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ const card = document.createElement('div'); card.className = 'mx-card';
+ card.appendChild(row('Server name', d.server_name || '(unset)'));
+ card.appendChild(row('Internal URL', d.url));
+ card.appendChild(row('Client-server API versions', (d.versions || []).join(', ') || '—'));
+ card.appendChild(row('Authenticated', d.whoami?.user_id || (d.has_token ? '(token set but whoami failed)' : '(no token)')));
+ el.appendChild(card);
+ } catch (e) { el.appendChild(err('Cannot reach Dendrite.')); }
+ }
+
+ async function loadFederation() {
+ const el = document.getElementById('mx-fed'); clear(el);
+ try {
+ const res = await fetch('/api/matrix-dendrite/federation-health'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ const card = document.createElement('div'); card.className = 'mx-card';
+ const badge = document.createElement('span');
+ badge.className = 'mx-fed-badge ' + (d.federation_ok ? 'mx-fed-ok' : 'mx-fed-bad');
+ badge.textContent = d.federation_ok ? 'FEDERATION OK' : 'FEDERATION ISSUES';
+ card.appendChild(badge);
+ card.appendChild(row('Server name', d.server_name));
+ card.appendChild(row('.well-known m.server', d.well_known || '(none — using :8448 direct)'));
+ if (d.errors && d.errors.length) {
+ const eh = document.createElement('div'); eh.className = 'mx-errors-head'; eh.textContent = 'Errors:';
+ card.appendChild(eh);
+ for (const er of d.errors) {
+ const li = document.createElement('div'); li.className = 'mx-err-item';
+ li.textContent = typeof er === 'string' ? er : JSON.stringify(er);
+ card.appendChild(li);
+ }
+ }
+ if (d.warnings && d.warnings.length) {
+ const wh = document.createElement('div'); wh.className = 'mx-warn-head'; wh.textContent = 'Warnings:';
+ card.appendChild(wh);
+ for (const w of d.warnings) {
+ const li = document.createElement('div'); li.className = 'mx-warn-item';
+ li.textContent = typeof w === 'string' ? w : JSON.stringify(w);
+ card.appendChild(li);
+ }
+ }
+ el.appendChild(card);
+ } catch (e) { el.appendChild(err('Federation tester failed: ' + e.message)); }
+ }
+
+ async function loadRooms() {
+ const el = document.getElementById('mx-rooms'); clear(el);
+ try {
+ const res = await fetch('/api/matrix-dendrite/rooms'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ if (!d.rooms || d.rooms.length === 0) {
+ const i = document.createElement('div'); i.className = 'np-idle';
+ i.textContent = 'No joined rooms yet. Try matrix_join_room { room: "#matrix:matrix.org" }.';
+ el.appendChild(i); return;
+ }
+ for (const r of d.rooms) {
+ const c = document.createElement('div'); c.className = 'mx-room';
+ const h = document.createElement('div'); h.className = 'mx-room-head';
+ const t = document.createElement('b'); t.textContent = r.name || r.alias || '(unnamed)';
+ h.appendChild(t);
+ if (r.alias && r.alias !== (r.name || '')) {
+ const a = document.createElement('span'); a.className = 'mx-room-alias'; a.textContent = r.alias;
+ h.appendChild(a);
+ }
+ c.appendChild(h);
+ const id = document.createElement('div'); id.className = 'mx-room-id'; id.textContent = r.room_id;
+ c.appendChild(id);
+ el.appendChild(c);
+ }
+ if (d.count > d.rooms.length) {
+ const more = document.createElement('div'); more.className = 'mx-more';
+ more.textContent = 'Showing ' + d.rooms.length + ' of ' + d.count + ' rooms.';
+ el.appendChild(more);
+ }
+ } catch (e) { el.appendChild(err('Cannot load rooms: ' + e.message)); }
+ }
+
+ loadStatus();
+ loadFederation();
+ loadRooms();
+ `;
+}
+
+function styles() {
+ return `
+ .mx-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; }
+ .mx-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; }
+ .mx-section { margin-bottom: 1.8rem; }
+ .mx-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase;
+ letter-spacing: 0.05em; margin: 0 0 0.7rem; }
+ .mx-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
+ border-radius: 10px; padding: 1rem; }
+ .mx-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); }
+ .mx-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 160px; }
+ .mx-fed-badge { display: inline-block; font-size: .8rem; font-weight: 600; padding: .3rem .6rem;
+ border-radius: 6px; letter-spacing: .05em; margin-bottom: .7rem; }
+ .mx-fed-ok { background: rgba(34,197,94,.15); color: #22c55e; }
+ .mx-fed-bad { background: rgba(239,68,68,.15); color: #ef4444; }
+ .mx-errors-head, .mx-warn-head { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em;
+ margin: .6rem 0 .2rem; color: var(--crow-text-muted); }
+ .mx-err-item { font-size: .8rem; color: #ef4444; font-family: ui-monospace, monospace; padding: .15rem 0; }
+ .mx-warn-item { font-size: .8rem; color: #eab308; font-family: ui-monospace, monospace; padding: .15rem 0; }
+ .mx-room { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
+ border-radius: 8px; padding: .6rem .9rem; margin-bottom: .4rem; }
+ .mx-room-head { display: flex; gap: .5rem; align-items: baseline; }
+ .mx-room-head b { color: var(--crow-text-primary); font-size: .9rem; }
+ .mx-room-alias { font-size: .8rem; color: var(--crow-accent); font-family: ui-monospace, monospace; }
+ .mx-room-id { font-size: .7rem; color: var(--crow-text-muted); font-family: ui-monospace, monospace; margin-top: .2rem; }
+ .mx-more { font-size: .8rem; color: var(--crow-text-muted); text-align: center; padding: .4rem 0; }
+ .mx-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; }
+ .mx-notes li { margin-bottom: .3rem; }
+ .mx-notes code { font-family: ui-monospace, monospace; background: var(--crow-bg);
+ padding: 1px 4px; border-radius: 3px; font-size: .8em; }
+ .np-idle, .np-loading { color: var(--crow-text-muted); font-size: 0.9rem; padding: 1rem;
+ background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; }
+ .np-error { color: #ef4444; font-size: 0.9rem; padding: 1rem;
+ background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; }
+ `;
+}
diff --git a/bundles/matrix-dendrite/panel/routes.js b/bundles/matrix-dendrite/panel/routes.js
new file mode 100644
index 0000000..40b8308
--- /dev/null
+++ b/bundles/matrix-dendrite/panel/routes.js
@@ -0,0 +1,87 @@
+/**
+ * Matrix-Dendrite panel API routes — status, joined rooms, federation health.
+ */
+
+import { Router } from "express";
+
+const URL_BASE = () => (process.env.MATRIX_URL || "http://dendrite:8008").replace(/\/+$/, "");
+const TOKEN = () => process.env.MATRIX_ACCESS_TOKEN || "";
+const SERVER_NAME = () => process.env.MATRIX_SERVER_NAME || "";
+const TIMEOUT = 15_000;
+
+async function mx(path, { noAuth } = {}) {
+ const ctl = new AbortController();
+ const t = setTimeout(() => ctl.abort(), TIMEOUT);
+ try {
+ const headers = {};
+ if (!noAuth && TOKEN()) headers.Authorization = `Bearer ${TOKEN()}`;
+ const r = await fetch(`${URL_BASE()}${path}`, { signal: ctl.signal, headers });
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
+ const text = await r.text();
+ return text ? JSON.parse(text) : {};
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+export default function matrixDendriteRouter(authMiddleware) {
+ const router = Router();
+
+ router.get("/api/matrix-dendrite/status", authMiddleware, async (_req, res) => {
+ try {
+ const versions = await mx("/_matrix/client/versions", { noAuth: true }).catch(() => null);
+ const who = TOKEN() ? await mx("/_matrix/client/v3/account/whoami").catch(() => null) : null;
+ res.json({
+ server_name: SERVER_NAME(),
+ url: URL_BASE(),
+ versions: versions?.versions?.slice(-3) || null,
+ has_token: Boolean(TOKEN()),
+ whoami: who,
+ });
+ } catch (err) {
+ res.json({ error: `Cannot reach Dendrite: ${err.message}` });
+ }
+ });
+
+ router.get("/api/matrix-dendrite/rooms", authMiddleware, async (_req, res) => {
+ try {
+ if (!TOKEN()) return res.json({ error: "MATRIX_ACCESS_TOKEN not set" });
+ const { joined_rooms = [] } = await mx("/_matrix/client/v3/joined_rooms");
+ const withNames = [];
+ for (const rid of joined_rooms.slice(0, 50)) {
+ const name = await mx(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/state/m.room.name`).catch(() => null);
+ const alias = await mx(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/state/m.room.canonical_alias`).catch(() => null);
+ withNames.push({ room_id: rid, name: name?.name || null, alias: alias?.alias || null });
+ }
+ res.json({ count: joined_rooms.length, rooms: withNames });
+ } catch (err) {
+ res.json({ error: err.message });
+ }
+ });
+
+ router.get("/api/matrix-dendrite/federation-health", authMiddleware, async (_req, res) => {
+ try {
+ const target = SERVER_NAME();
+ if (!target) return res.json({ error: "MATRIX_SERVER_NAME not set" });
+ const ctl = new AbortController();
+ const t = setTimeout(() => ctl.abort(), 30_000);
+ try {
+ const r = await fetch(`https://federationtester.matrix.org/api/report?server_name=${encodeURIComponent(target)}`, { signal: ctl.signal });
+ const json = await r.json();
+ res.json({
+ server_name: target,
+ federation_ok: json.FederationOK,
+ well_known: json.WellKnownResult?.["m.server"] || null,
+ errors: (json.Errors || []).slice(0, 10),
+ warnings: (json.Warnings || []).slice(0, 10),
+ });
+ } finally {
+ clearTimeout(t);
+ }
+ } catch (err) {
+ res.json({ error: err.message });
+ }
+ });
+
+ return router;
+}
diff --git a/bundles/matrix-dendrite/scripts/backup.sh b/bundles/matrix-dendrite/scripts/backup.sh
new file mode 100755
index 0000000..6a1146f
--- /dev/null
+++ b/bundles/matrix-dendrite/scripts/backup.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# Matrix-Dendrite backup: pg_dump + signing key + media store + config.
+set -euo pipefail
+
+STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
+BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/matrix-dendrite"
+DATA_DIR="${MATRIX_DATA_DIR:-$HOME/.crow/matrix-dendrite}"
+
+mkdir -p "$BACKUP_ROOT"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+# Postgres dump — use pg_dumpall to capture roles + schemas in one file
+if docker ps --format '{{.Names}}' | grep -qw crow-dendrite-postgres; then
+ docker exec -e PGPASSWORD="${MATRIX_POSTGRES_PASSWORD:-}" crow-dendrite-postgres \
+ pg_dump -U dendrite -Fc -f /tmp/dendrite-${STAMP}.pgcustom dendrite
+ docker cp "crow-dendrite-postgres:/tmp/dendrite-${STAMP}.pgcustom" "$WORK/dendrite.pgcustom"
+ docker exec crow-dendrite-postgres rm "/tmp/dendrite-${STAMP}.pgcustom"
+fi
+
+# Dendrite signing keys + config + media store
+tar -C "$DATA_DIR/dendrite" -cf "$WORK/dendrite-state.tar" . 2>/dev/null || true
+
+OUT="${BACKUP_ROOT}/matrix-dendrite-${STAMP}.tar.zst"
+if command -v zstd >/dev/null 2>&1; then
+ tar -C "$WORK" -cf - . | zstd -T0 -19 -o "$OUT"
+else
+ OUT="${BACKUP_ROOT}/matrix-dendrite-${STAMP}.tar.gz"
+ tar -C "$WORK" -czf "$OUT" .
+fi
+echo "wrote $OUT ($(du -h "$OUT" | cut -f1))"
+echo "NOTE: the signing key in dendrite-state.tar IS federation identity."
+echo " Restoring to a different MATRIX_SERVER_NAME will break federation."
+echo " Keep this backup encrypted — loss = identity loss, leak = impersonation."
diff --git a/bundles/matrix-dendrite/scripts/post-install.sh b/bundles/matrix-dendrite/scripts/post-install.sh
new file mode 100755
index 0000000..060992a
--- /dev/null
+++ b/bundles/matrix-dendrite/scripts/post-install.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Matrix-Dendrite post-install hook.
+#
+# The entrypoint does the heavy lifting (signing key + config generation),
+# but first-boot timing means we need a health wait before printing the
+# registration shared secret + next-step guidance.
+set -euo pipefail
+
+BUNDLE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+ENV_FILE="${BUNDLE_DIR}/.env"
+if [ -f "$ENV_FILE" ]; then
+ set -a; . "$ENV_FILE"; set +a
+fi
+
+# Wait for Dendrite to become healthy (first boot can take 60+ seconds)
+echo "Waiting for Dendrite to report healthy (up to 120s)…"
+for i in $(seq 1 24); do
+ if docker inspect crow-dendrite --format '{{.State.Health.Status}}' 2>/dev/null | grep -qw healthy; then
+ echo " → healthy"
+ break
+ fi
+ sleep 5
+done
+
+# Extract the generated registration shared secret from logs (entrypoint
+# prints it on first boot)
+SECRET=$(docker logs crow-dendrite 2>&1 | grep -oE 'Registration shared secret: [A-Za-z0-9+/=]+' | tail -1 | cut -d: -f2 | xargs || true)
+
+cat <' --admin
+
+EOF
+
+if [ -n "${SECRET:-}" ]; then
+ echo " Registration shared secret (copy to .env as MATRIX_REGISTRATION_SHARED_SECRET):"
+ echo " ${SECRET}"
+ echo ""
+fi
+
+cat <"}'
+ Paste access_token and user_id into .env as MATRIX_ACCESS_TOKEN and
+ MATRIX_USER_ID, then restart the MCP server.
+
+ 4. Verify federation end-to-end:
+ matrix_federation_health { server_name: "${MATRIX_SERVER_NAME:-example.com}" }
+
+EOF
diff --git a/bundles/matrix-dendrite/server/index.js b/bundles/matrix-dendrite/server/index.js
new file mode 100644
index 0000000..bd85c99
--- /dev/null
+++ b/bundles/matrix-dendrite/server/index.js
@@ -0,0 +1,8 @@
+#!/usr/bin/env node
+
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { createMatrixDendriteServer } from "./server.js";
+
+const server = await createMatrixDendriteServer();
+const transport = new StdioServerTransport();
+await server.connect(transport);
diff --git a/bundles/matrix-dendrite/server/server.js b/bundles/matrix-dendrite/server/server.js
new file mode 100644
index 0000000..b2cd823
--- /dev/null
+++ b/bundles/matrix-dendrite/server/server.js
@@ -0,0 +1,500 @@
+/**
+ * Matrix (Dendrite) MCP Server
+ *
+ * Exposes Matrix's client-server API through MCP. Dendrite speaks the
+ * standard Matrix v3 API so these tools also work against Synapse or
+ * other homeservers if someone points MATRIX_URL elsewhere.
+ *
+ * Tools:
+ * matrix_status — reachability, version, federation mode
+ * matrix_joined_rooms — list rooms the admin account is in
+ * matrix_create_room — create a room (public / private / DM)
+ * matrix_join_room — join by ID or alias (triggers federation)
+ * matrix_leave_room — leave a room
+ * matrix_send_message — send a message event to a room
+ * matrix_room_messages — paginated message history
+ * matrix_sync — one-shot sync (not long-poll; panel uses
+ * a different long-poll endpoint)
+ * matrix_invite_user — invite a user to a room
+ * matrix_register_appservice — write an appservice YAML registration
+ * (used by F.12.1 matrix-bridges bundle)
+ * matrix_federation_health — check outgoing federation with the
+ * Matrix Federation Tester
+ *
+ * Rate limiting follows the F.0 shared pattern: content-producing verbs
+ * (create_room, send_message, join_room, invite_user) are wrapped.
+ */
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { z } from "zod";
+
+const MATRIX_URL = (process.env.MATRIX_URL || "http://dendrite:8008").replace(/\/+$/, "");
+const MATRIX_ACCESS_TOKEN = process.env.MATRIX_ACCESS_TOKEN || "";
+const MATRIX_USER_ID = process.env.MATRIX_USER_ID || "";
+const MATRIX_SERVER_NAME = process.env.MATRIX_SERVER_NAME || "";
+const MATRIX_FEDERATION_TESTER = "https://federationtester.matrix.org/api/report";
+
+let wrapRateLimited = null;
+let getDb = null;
+
+async function loadSharedDeps() {
+ try {
+ const rl = await import("../../../servers/shared/rate-limiter.js");
+ wrapRateLimited = rl.wrapRateLimited;
+ } catch {
+ wrapRateLimited = () => (_, h) => h;
+ }
+ try {
+ const db = await import("../../../servers/db.js");
+ getDb = db.createDbClient;
+ } catch {
+ getDb = null;
+ }
+}
+
+async function mxFetch(path, { method = "GET", body, noAuth, timeoutMs = 20_000 } = {}) {
+ const url = `${MATRIX_URL}${path}`;
+ const headers = { "Content-Type": "application/json" };
+ if (!noAuth && MATRIX_ACCESS_TOKEN) {
+ headers.Authorization = `Bearer ${MATRIX_ACCESS_TOKEN}`;
+ }
+ const ctl = new AbortController();
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
+ try {
+ const res = await fetch(url, {
+ method,
+ headers,
+ body: body ? JSON.stringify(body) : undefined,
+ signal: ctl.signal,
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ const snippet = text.slice(0, 600);
+ if (res.status === 401) {
+ throw new Error(`Matrix auth failed (401). Set MATRIX_ACCESS_TOKEN. Obtain via POST /_matrix/client/v3/login.`);
+ }
+ if (res.status === 403) {
+ throw new Error(`Matrix forbidden (403)${snippet ? ": " + snippet : ""}`);
+ }
+ throw new Error(`Matrix ${res.status} ${res.statusText}${snippet ? " — " + snippet : ""}`);
+ }
+ if (!text) return {};
+ try {
+ return JSON.parse(text);
+ } catch {
+ return { raw: text };
+ }
+ } catch (err) {
+ if (err.name === "AbortError") throw new Error(`Matrix request timed out: ${path}`);
+ if (err.cause?.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) {
+ throw new Error(
+ `Cannot reach Dendrite at ${MATRIX_URL}. Verify the container is up and on the crow-federation network (docker ps | grep crow-dendrite).`,
+ );
+ }
+ throw err;
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+/**
+ * Resolve a room alias (#room:server) to an internal room ID (!id:server)
+ * via federation-aware directory lookup. Passes through if input is
+ * already an ID.
+ */
+async function resolveRoom(aliasOrId) {
+ if (aliasOrId.startsWith("!")) return aliasOrId;
+ if (aliasOrId.startsWith("#")) {
+ const out = await mxFetch(`/_matrix/client/v3/directory/room/${encodeURIComponent(aliasOrId)}`);
+ if (out.room_id) return out.room_id;
+ throw new Error(`Could not resolve alias ${aliasOrId}: ${JSON.stringify(out).slice(0, 200)}`);
+ }
+ throw new Error(`Not a Matrix room ID or alias: ${aliasOrId}`);
+}
+
+function requireAuth() {
+ if (!MATRIX_ACCESS_TOKEN) {
+ return { content: [{ type: "text", text: "Error: MATRIX_ACCESS_TOKEN required. POST /_matrix/client/v3/login to obtain one." }] };
+ }
+ return null;
+}
+
+export async function createMatrixDendriteServer(options = {}) {
+ await loadSharedDeps();
+
+ const server = new McpServer(
+ { name: "crow-matrix-dendrite", version: "1.0.0" },
+ { instructions: options.instructions },
+ );
+
+ const limiter = wrapRateLimited
+ ? wrapRateLimited({ db: getDb ? getDb() : null })
+ : (_, h) => h;
+
+ // --- matrix_status ---
+ server.tool(
+ "matrix_status",
+ "Report Dendrite status: reachability, server version, whoami, federation mode (disabled/enabled), and the canonical Matrix federation tester verdict for this server.",
+ {},
+ async () => {
+ try {
+ const [versions, whoami, caps] = await Promise.all([
+ mxFetch("/_matrix/client/versions", { noAuth: true }),
+ MATRIX_ACCESS_TOKEN ? mxFetch("/_matrix/client/v3/account/whoami").catch(() => null) : Promise.resolve(null),
+ MATRIX_ACCESS_TOKEN ? mxFetch("/_matrix/client/v3/capabilities").catch(() => null) : Promise.resolve(null),
+ ]);
+ return {
+ content: [{
+ type: "text",
+ text: JSON.stringify({
+ server_name: MATRIX_SERVER_NAME || null,
+ url: MATRIX_URL,
+ versions: versions?.versions || null,
+ unstable_features: versions?.unstable_features || {},
+ whoami: whoami || null,
+ room_version: caps?.capabilities?.["m.room_versions"]?.default || null,
+ has_access_token: Boolean(MATRIX_ACCESS_TOKEN),
+ }, null, 2),
+ }],
+ };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ },
+ );
+
+ // --- matrix_joined_rooms ---
+ server.tool(
+ "matrix_joined_rooms",
+ "List rooms the authenticated user is in, with a name hint for each.",
+ {},
+ async () => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const { joined_rooms = [] } = await mxFetch("/_matrix/client/v3/joined_rooms");
+ const withNames = await Promise.all(
+ joined_rooms.slice(0, 100).map(async (rid) => {
+ const name = await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/state/m.room.name`).catch(() => null);
+ const canonical = await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/state/m.room.canonical_alias`).catch(() => null);
+ return {
+ room_id: rid,
+ name: name?.name || null,
+ canonical_alias: canonical?.alias || null,
+ };
+ }),
+ );
+ return { content: [{ type: "text", text: JSON.stringify({ count: joined_rooms.length, rooms: withNames }, null, 2) }] };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ },
+ );
+
+ // --- matrix_create_room ---
+ server.tool(
+ "matrix_create_room",
+ "Create a new room. Rate-limited: 10/hour.",
+ {
+ name: z.string().max(255).optional(),
+ topic: z.string().max(500).optional(),
+ alias_localpart: z.string().max(100).optional().describe("Local part of the canonical alias — 'chat' becomes #chat:yourdomain"),
+ visibility: z.enum(["public", "private"]).optional().describe("Directory visibility. Default private."),
+ preset: z.enum(["public_chat", "private_chat", "trusted_private_chat"]).optional(),
+ invite: z.array(z.string().max(320)).max(50).optional().describe("User IDs to invite at creation time."),
+ is_direct: z.boolean().optional().describe("Mark as a 1:1 DM room."),
+ },
+ limiter("matrix_create_room", async (args) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const body = {
+ ...(args.name ? { name: args.name } : {}),
+ ...(args.topic ? { topic: args.topic } : {}),
+ ...(args.alias_localpart ? { room_alias_name: args.alias_localpart } : {}),
+ visibility: args.visibility || "private",
+ ...(args.preset ? { preset: args.preset } : {}),
+ ...(args.invite ? { invite: args.invite } : {}),
+ ...(args.is_direct != null ? { is_direct: args.is_direct } : {}),
+ };
+ const out = await mxFetch("/_matrix/client/v3/createRoom", { method: "POST", body });
+ return { content: [{ type: "text", text: JSON.stringify({ room_id: out.room_id, room_alias: out.room_alias || null }, null, 2) }] };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ }),
+ );
+
+ // --- matrix_join_room ---
+ server.tool(
+ "matrix_join_room",
+ "Join a room by ID or alias. If the room lives on another server, Dendrite federates the join (may take several seconds). Rate-limited: 30/hour.",
+ { room: z.string().min(1).max(500).describe("Room ID (!id:server) or alias (#room:server)") },
+ limiter("matrix_join_room", async ({ room }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ // /join/{roomIdOrAlias} handles both; URL-encode the whole thing
+ const out = await mxFetch(`/_matrix/client/v3/join/${encodeURIComponent(room)}`, { method: "POST", body: {} });
+ return { content: [{ type: "text", text: JSON.stringify({ joined: out.room_id || room }, null, 2) }] };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ }),
+ );
+
+ // --- matrix_leave_room ---
+ server.tool(
+ "matrix_leave_room",
+ "Leave a room. Destructive — your messages stay but you lose access. Rate-limited: 30/hour.",
+ {
+ room: z.string().min(1).max(500),
+ confirm: z.literal("yes"),
+ },
+ limiter("matrix_leave_room", async ({ room }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const rid = await resolveRoom(room);
+ await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/leave`, { method: "POST", body: {} });
+ return { content: [{ type: "text", text: JSON.stringify({ left: rid }, null, 2) }] };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ }),
+ );
+
+ // --- matrix_send_message ---
+ server.tool(
+ "matrix_send_message",
+ "Send a text (or notice, or HTML-formatted) message to a room. Rate-limited: 20/hour.",
+ {
+ room: z.string().min(1).max(500),
+ body: z.string().min(1).max(20_000).describe("Plain-text body."),
+ formatted_body: z.string().max(50_000).optional().describe("Optional HTML-formatted body (org.matrix.custom.html)."),
+ msgtype: z.enum(["m.text", "m.notice", "m.emote"]).optional().describe("Default m.text."),
+ },
+ limiter("matrix_send_message", async ({ room, body, formatted_body, msgtype }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const rid = await resolveRoom(room);
+ // Use PUT /send/{type}/{txnId} for idempotency; a random txn id works.
+ const txn = `crow-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+ const content = {
+ msgtype: msgtype || "m.text",
+ body,
+ ...(formatted_body ? { format: "org.matrix.custom.html", formatted_body } : {}),
+ };
+ const out = await mxFetch(
+ `/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/send/m.room.message/${encodeURIComponent(txn)}`,
+ { method: "PUT", body: content },
+ );
+ return { content: [{ type: "text", text: JSON.stringify({ event_id: out.event_id, room_id: rid }, null, 2) }] };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ }),
+ );
+
+ // --- matrix_room_messages ---
+ server.tool(
+ "matrix_room_messages",
+ "Paginated message history for a room. Returns the most recent N events by default.",
+ {
+ room: z.string().min(1).max(500),
+ limit: z.number().int().min(1).max(100).optional(),
+ from: z.string().max(300).optional().describe("Opaque pagination token; omit to start from the latest."),
+ direction: z.enum(["b", "f"]).optional().describe("b = backwards (default, newest-first), f = forwards."),
+ },
+ async ({ room, limit, from, direction }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const rid = await resolveRoom(room);
+ const params = new URLSearchParams({
+ limit: String(limit ?? 20),
+ dir: direction || "b",
+ });
+ if (from) params.set("from", from);
+ const out = await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/messages?${params}`);
+ const summary = (out.chunk || []).map((e) => ({
+ event_id: e.event_id,
+ type: e.type,
+ sender: e.sender,
+ ts: e.origin_server_ts,
+ body: e.content?.body,
+ msgtype: e.content?.msgtype,
+ }));
+ return {
+ content: [{
+ type: "text",
+ text: JSON.stringify({ room_id: rid, count: summary.length, events: summary, next_from: out.end, prev_from: out.start }, null, 2),
+ }],
+ };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ },
+ );
+
+ // --- matrix_sync (one-shot) ---
+ server.tool(
+ "matrix_sync",
+ "One-shot sync with a short server-side timeout. Returns new events, invites, and joined-room deltas since `since` (or the full initial state if absent). For long-poll streaming use the panel's SSE bridge.",
+ {
+ since: z.string().max(500).optional().describe("Opaque since-token from a prior sync; omit for initial sync (heavy — use sparingly)."),
+ timeout_ms: z.number().int().min(0).max(30_000).optional().describe("Server-side long-poll timeout in ms. Default 1500 for MCP latency."),
+ },
+ async ({ since, timeout_ms }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const params = new URLSearchParams({ timeout: String(timeout_ms ?? 1500) });
+ if (since) params.set("since", since);
+ const out = await mxFetch(`/_matrix/client/v3/sync?${params}`, { timeoutMs: (timeout_ms || 1500) + 10_000 });
+ // Shrink payload: just summarize room deltas instead of returning
+ // the entire rooms tree (full sync can be megabytes).
+ const joinedDelta = Object.entries(out.rooms?.join || {}).map(([rid, state]) => ({
+ room_id: rid,
+ timeline_events: state.timeline?.events?.length || 0,
+ notification_count: state.unread_notifications?.notification_count || 0,
+ }));
+ const invites = Object.keys(out.rooms?.invite || {});
+ return {
+ content: [{
+ type: "text",
+ text: JSON.stringify({ next_batch: out.next_batch, joined_rooms_delta: joinedDelta, invites }, null, 2),
+ }],
+ };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ },
+ );
+
+ // --- matrix_invite_user ---
+ server.tool(
+ "matrix_invite_user",
+ "Invite a user to a room. Rate-limited: 10/hour.",
+ {
+ room: z.string().min(1).max(500),
+ user_id: z.string().min(3).max(320).describe("Full Matrix ID (@alice:example.com)"),
+ reason: z.string().max(500).optional(),
+ },
+ limiter("matrix_invite_user", async ({ room, user_id, reason }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const rid = await resolveRoom(room);
+ const body = { user_id, ...(reason ? { reason } : {}) };
+ await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/invite`, { method: "POST", body });
+ return { content: [{ type: "text", text: JSON.stringify({ invited: user_id, room_id: rid }, null, 2) }] };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ }),
+ );
+
+ // --- matrix_register_appservice (F.12 prep) ---
+ server.tool(
+ "matrix_register_appservice",
+ "Register a Matrix appservice (used by F.12 matrix-bridges bundle). Writes the registration YAML into Dendrite's config dir. Does NOT restart Dendrite — the caller is responsible for a restart-with-health-wait since Dendrite only reloads appservice registrations at startup.",
+ {
+ id: z.string().min(1).max(100).describe("Unique appservice id (e.g., 'mautrix-signal')."),
+ url: z.string().url().max(500).describe("Bridge URL Dendrite will push events to."),
+ hs_token: z.string().min(16).max(200).describe("Token Dendrite uses to authenticate to the bridge."),
+ as_token: z.string().min(16).max(200).describe("Token the bridge uses to authenticate to Dendrite."),
+ sender_localpart: z.string().min(1).max(100).describe("User localpart that represents the bridge bot."),
+ namespace_users: z.array(z.string().max(200)).max(20).optional().describe("Regex list of user-ID patterns the bridge owns."),
+ namespace_aliases: z.array(z.string().max(200)).max(20).optional().describe("Regex list of room-alias patterns the bridge owns."),
+ namespace_rooms: z.array(z.string().max(200)).max(20).optional().describe("Regex list of room-ID patterns the bridge owns."),
+ protocols: z.array(z.string().max(100)).max(10).optional().describe("Third-party protocols the bridge announces."),
+ rate_limited: z.boolean().optional(),
+ },
+ async (args) => {
+ try {
+ // We don't directly write the file from the MCP server (we don't
+ // have host filesystem access to Dendrite's config dir). Instead
+ // we return the YAML + the intended path, and the bundle's
+ // post-install / bridges meta-bundle writes it via an exec into
+ // the dendrite container. This keeps the surface declarative.
+ const yaml = [
+ `# crow-generated appservice registration for ${args.id}`,
+ `id: ${args.id}`,
+ `url: ${args.url}`,
+ `as_token: ${args.as_token}`,
+ `hs_token: ${args.hs_token}`,
+ `sender_localpart: ${args.sender_localpart}`,
+ `rate_limited: ${args.rate_limited ?? false}`,
+ `namespaces:`,
+ ` users:`,
+ ...(args.namespace_users || []).map((r) => ` - exclusive: true\n regex: ${JSON.stringify(r)}`),
+ ...(!args.namespace_users?.length ? [" []"] : []),
+ ` aliases:`,
+ ...(args.namespace_aliases || []).map((r) => ` - exclusive: true\n regex: ${JSON.stringify(r)}`),
+ ...(!args.namespace_aliases?.length ? [" []"] : []),
+ ` rooms:`,
+ ...(args.namespace_rooms || []).map((r) => ` - exclusive: false\n regex: ${JSON.stringify(r)}`),
+ ...(!args.namespace_rooms?.length ? [" []"] : []),
+ ...(args.protocols?.length ? [`protocols:`, ...args.protocols.map((p) => ` - ${p}`)] : []),
+ "",
+ ].join("\n");
+
+ return {
+ content: [{
+ type: "text",
+ text: JSON.stringify({
+ install_path: `/etc/dendrite/appservices/${args.id}.yaml`,
+ yaml,
+ next_steps: [
+ `docker cp crow-dendrite:/etc/dendrite/appservices/${args.id}.yaml`,
+ `edit /etc/dendrite/dendrite.yaml to add 'app_service_api: { config_files: [appservices/${args.id}.yaml] }'`,
+ "docker compose -f bundles/matrix-dendrite/docker-compose.yml restart dendrite",
+ "(Dendrite reads appservice registrations only at startup.)",
+ ],
+ }, null, 2),
+ }],
+ };
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ },
+ );
+
+ // --- matrix_federation_health ---
+ server.tool(
+ "matrix_federation_health",
+ "Ask the public Matrix Federation Tester (federationtester.matrix.org) whether this server federates correctly. Exercises both .well-known delegation and :8448 reachability — the canonical test for new Matrix installs.",
+ {
+ server_name: z.string().max(253).optional().describe("Override MATRIX_SERVER_NAME (e.g., to test a second domain)."),
+ },
+ async ({ server_name }) => {
+ try {
+ const target = server_name || MATRIX_SERVER_NAME;
+ if (!target) {
+ return { content: [{ type: "text", text: "Error: server_name required (set MATRIX_SERVER_NAME or pass explicitly)." }] };
+ }
+ const url = `${MATRIX_FEDERATION_TESTER}?server_name=${encodeURIComponent(target)}`;
+ const ctl = new AbortController();
+ const timer = setTimeout(() => ctl.abort(), 30_000);
+ try {
+ const res = await fetch(url, { signal: ctl.signal });
+ const json = await res.json();
+ return {
+ content: [{
+ type: "text",
+ text: JSON.stringify({
+ server_name: target,
+ federation_ok: json.FederationOK,
+ dns_result: json.DNSResult?.Hosts ? Object.keys(json.DNSResult.Hosts) : null,
+ well_known_result: json.WellKnownResult?.["m.server"] || null,
+ connection_report_count: Object.keys(json.ConnectionReports || {}).length,
+ errors: json.Errors || [],
+ warnings: json.Warnings || [],
+ }, null, 2),
+ }],
+ };
+ } finally {
+ clearTimeout(timer);
+ }
+ } catch (err) {
+ return { content: [{ type: "text", text: `Error: ${err.message}` }] };
+ }
+ },
+ );
+
+ return server;
+}
diff --git a/bundles/matrix-dendrite/skills/matrix-dendrite.md b/bundles/matrix-dendrite/skills/matrix-dendrite.md
new file mode 100644
index 0000000..16d89cb
--- /dev/null
+++ b/bundles/matrix-dendrite/skills/matrix-dendrite.md
@@ -0,0 +1,174 @@
+---
+name: matrix-dendrite
+description: Matrix homeserver on Dendrite — federated real-time chat with E2EE. Rooms, messages, sync, federation health.
+triggers:
+ - "matrix"
+ - "dendrite"
+ - "matrix room"
+ - "#room:server"
+ - "@user:server"
+ - "join matrix"
+ - "send matrix message"
+ - "e2ee chat"
+tools:
+ - matrix_status
+ - matrix_joined_rooms
+ - matrix_create_room
+ - matrix_join_room
+ - matrix_leave_room
+ - matrix_send_message
+ - matrix_room_messages
+ - matrix_sync
+ - matrix_invite_user
+ - matrix_register_appservice
+ - matrix_federation_health
+---
+
+# Matrix on Dendrite — federated real-time chat
+
+Dendrite is a lightweight Go-based Matrix homeserver (vs. Synapse's Python). It speaks the full Matrix v3 client-server API and federates over the Matrix graph. This bundle runs Dendrite + Postgres in two containers on the shared `crow-federation` network.
+
+## Hardware
+
+Gated by F.0's hardware check. Refused on hosts with less than **2 GB effective RAM after committed bundles + 512 MB host reserve**. Recommended 4 GB+; Pi class (4-8 GB total) will get warnings unless you're the only federated bundle installed. Disk grows fast under federated joins — plan for 100 GB+ if you join any major room (Matrix HQ alone is tens of GB of state).
+
+## The federation port decision — pick one
+
+Matrix federation happens on port **8448/tcp**. You have two mutually-exclusive ways to satisfy this:
+
+### Option A: open :8448 on the router
+
+```
+caddy_add_matrix_federation_port {
+ "domain": "matrix.example.com",
+ "upstream_8448": "dendrite:8448"
+}
+```
+
+Caddy requests a second Let's Encrypt cert for `matrix.example.com:8448` and reverse-proxies into Dendrite. Requires your router/firewall forward 8448/tcp to this host.
+
+### Option B: apex `.well-known/matrix/server` delegation
+
+```
+caddy_set_wellknown {
+ "domain": "example.com",
+ "kind": "matrix-server",
+ "opts": { "delegate_to": "matrix.example.com:443" }
+}
+```
+
+This publishes `/.well-known/matrix/server` on the apex declaring that Matrix federation for `@user:example.com` lives at `matrix.example.com:443`. No :8448 needed — federation rides HTTPS on 443.
+
+**Either works. Not both.** `caddy_add_matrix_federation_port` refuses to run when the same domain already has matrix-server delegation — the F.0 tool enforces this.
+
+Pair whichever path you chose with the client-server proxy:
+
+```
+caddy_add_federation_site {
+ "domain": "matrix.example.com",
+ "upstream": "dendrite:8008",
+ "profile": "matrix"
+}
+```
+
+Verify the whole setup end-to-end with:
+
+```
+matrix_federation_health { "server_name": "example.com" }
+```
+
+This calls the public Matrix Federation Tester and returns its structured verdict.
+
+## First-run bootstrap
+
+The entrypoint generates a signing key and `dendrite.yaml` on first boot, then prints the registration shared secret to the container log:
+
+```
+Registration shared secret: <48-char base64>
+```
+
+1. Copy that secret into `.env` as `MATRIX_REGISTRATION_SHARED_SECRET`.
+2. Register the admin account:
+ ```bash
+ docker exec crow-dendrite \
+ create-account --config /etc/dendrite/dendrite.yaml \
+ --username admin --password '' --admin
+ ```
+3. Log in to get a client-server access token:
+ ```bash
+ curl -X POST https://matrix.example.com/_matrix/client/v3/login \
+ -H 'Content-Type: application/json' \
+ -d '{"type":"m.login.password","user":"admin","password":""}'
+ ```
+4. Paste `access_token` and `user_id` into `.env` as `MATRIX_ACCESS_TOKEN` and `MATRIX_USER_ID`, then restart the MCP server.
+
+## Common workflows
+
+### Create a room
+
+```
+matrix_create_room {
+ "name": "Project Crow",
+ "topic": "Development chat",
+ "visibility": "private",
+ "preset": "private_chat",
+ "invite": ["@alice:matrix.org"]
+}
+```
+
+### Join a federated room
+
+```
+matrix_join_room { "room": "#matrix:matrix.org" }
+```
+
+First federation join against a given remote server can take 10+ seconds while Dendrite fetches the room state. Subsequent joins to rooms on that server are fast.
+
+### Send a message
+
+```
+matrix_send_message {
+ "room": "#crow-dev:example.com",
+ "body": "hello, matrix"
+}
+```
+
+For HTML / formatted messages add `formatted_body` (HTML) alongside `body` (fallback plaintext).
+
+### Read recent history
+
+```
+matrix_room_messages { "room": "#crow-dev:example.com", "limit": 20 }
+```
+
+Paginate back further by passing the returned `next_from` as the next call's `from`.
+
+### One-shot sync (poll, not stream)
+
+```
+matrix_sync {} # initial (heavy — use sparingly)
+matrix_sync { "since": "s42_15_0_1" } # delta
+```
+
+Matrix has a long-polling sync endpoint; `matrix_sync` returns a compact summary (joined-room deltas + invite count + `next_batch`) rather than the full tree. For a real streaming feed use the panel's SSE bridge.
+
+## Encryption
+
+Dendrite supports E2EE, but key material lives in each client (your Element / Fluffychat / etc.). The Crow MCP server does NOT handle encryption keys — it posts plaintext events. For encrypted rooms, you need a dedicated Matrix client with device keys; use Dendrite as the homeserver but let Element handle the ciphertext. Posting an MCP message to an E2EE room will send plaintext (visible only to you on your devices; other members will see an unencrypted event).
+
+## Moderation
+
+Matrix moderation is room-scoped (not instance-scoped like Mastodon/GoToSocial). Admins can ban users from specific rooms via Matrix's standard membership events. Instance-wide federation blocklists (server ACLs) are a room-level feature — ban a remote server from a specific room via `m.room.server_acl` state events.
+
+This bundle does not yet expose moderation verbs; room bans + server ACLs land in a follow-up once the full moderation taxonomy is exercised. For now, use a Matrix client (Element) for moderation actions.
+
+## F.12 appservice prep
+
+`matrix_register_appservice` produces a YAML registration file + write-it-into-dendrite instructions. This is the entry point the F.12.1 matrix-bridges meta-bundle uses to install mautrix-signal / mautrix-telegram / mautrix-whatsapp. You won't typically invoke this directly — install a bridge, and the bridge's post-install calls this tool then restarts Dendrite for you.
+
+## Troubleshooting
+
+- **"Matrix request timed out"** — Dendrite is often slow on first boot (DB migrations + key bootstrap). First-run healthcheck has a 60s grace period. If the timeout persists, check `docker logs crow-dendrite` for signing-key errors.
+- **Federation tester says "No address found"** — neither `.well-known/matrix/server` nor `:8448` reachable. Run `matrix_federation_health` again after DNS propagates; give it 2–3 minutes.
+- **"Cert for :8448 is staging"** — the F.0 `caddy_cert_health` surfaces this. Matrix peers reject staging certs; wait for real cert issuance.
+- **Disk growing fast after joining Matrix HQ** — expected. Matrix backfills room state + media aggressively. Consider unjoining large rooms or purging media via Dendrite admin API (not yet wired as an MCP tool).
diff --git a/registry/add-ons.json b/registry/add-ons.json
index 19ca52e..64b48e7 100644
--- a/registry/add-ons.json
+++ b/registry/add-ons.json
@@ -3110,6 +3110,46 @@
"webUI": null,
"notes": "No host port publish. After install: caddy_add_federation_site { domain: WF_HOST, upstream: 'writefreely:8080', profile: 'activitypub' }. Admin account is created via the first-run web UI (no CLI bootstrap)."
},
+ {
+ "id": "matrix-dendrite",
+ "name": "Matrix (Dendrite)",
+ "description": "Matrix homeserver on Dendrite — federated real-time chat with end-to-end encryption. Lighter than Synapse, monolithic binary. Joins the Matrix federation graph via HTTPS federation (:8448 or .well-known delegation).",
+ "type": "bundle",
+ "version": "1.0.0",
+ "author": "Crow",
+ "category": "federated-comms",
+ "tags": ["matrix", "dendrite", "chat", "federation", "e2ee", "realtime"],
+ "icon": "message-circle",
+ "docker": { "composefile": "docker-compose.yml" },
+ "server": {
+ "command": "node",
+ "args": ["server/index.js"],
+ "envKeys": ["MATRIX_URL", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID", "MATRIX_SERVER_NAME"]
+ },
+ "panel": "panel/matrix-dendrite.js",
+ "panelRoutes": "panel/routes.js",
+ "skills": ["skills/matrix-dendrite.md"],
+ "consent_required": true,
+ "requires": {
+ "env": ["MATRIX_SERVER_NAME", "MATRIX_POSTGRES_PASSWORD"],
+ "bundles": ["caddy"],
+ "min_ram_mb": 2048,
+ "recommended_ram_mb": 4096,
+ "min_disk_mb": 10000,
+ "recommended_disk_mb": 100000
+ },
+ "env_vars": [
+ { "name": "MATRIX_SERVER_NAME", "description": "Public homeserver domain (appears in user IDs as @alice:).", "required": true },
+ { "name": "MATRIX_HOST", "description": "Domain Caddy reverse-proxies the client-server API on. May differ from MATRIX_SERVER_NAME when using .well-known delegation.", "required": false },
+ { "name": "MATRIX_POSTGRES_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true },
+ { "name": "MATRIX_ACCESS_TOKEN", "description": "Admin access token (from POST /_matrix/client/v3/login after registering the admin via the CLI).", "required": false, "secret": true },
+ { "name": "MATRIX_USER_ID", "description": "Admin user's full Matrix ID (@admin:example.com).", "required": false },
+ { "name": "MATRIX_REGISTRATION_SHARED_SECRET", "description": "Dendrite's registration shared secret (printed to logs on first boot).", "required": false, "secret": true }
+ ],
+ "ports": [],
+ "webUI": null,
+ "notes": "Two containers (dendrite + postgres). Federation: pick EITHER caddy_add_matrix_federation_port (router forwards :8448) OR caddy_set_wellknown matrix-server on the apex. Initial admin registered via `docker exec crow-dendrite create-account`."
+ },
{
"id": "developer-kit",
"name": "Developer Kit",
diff --git a/skills/superpowers.md b/skills/superpowers.md
index a17e36f..0850f29 100644
--- a/skills/superpowers.md
+++ b/skills/superpowers.md
@@ -81,6 +81,7 @@ This is the master routing skill. Consult this **before every task** to determin
| "schedule", "remind me", "every day at", "recurring" | "programar", "recuérdame", "cada día a las" | scheduling | crow-memory |
| "toot", "post to fediverse", "follow @user@...", "mastodon", "gotosocial", "activitypub" | "publicar en fediverso", "tootear", "seguir @usuario@...", "mastodon", "gotosocial" | gotosocial | crow-gotosocial |
| "writefreely", "federated blog", "long-form post", "publish article", "blog to fediverse" | "writefreely", "blog federado", "artículo largo", "publicar al fediverso" | writefreely | crow-writefreely |
+| "matrix", "dendrite", "join #room:server", "send @user:server", "e2ee chat", "matrix room" | "matrix", "dendrite", "unirse a #sala:servidor", "mensaje a @usuario:servidor", "chat e2ee" | matrix-dendrite | crow-matrix-dendrite |
| "tutor me", "teach me", "quiz me", "help me understand" | "enséñame", "explícame", "evalúame" | tutoring | crow-memory |
| "wrap up", "summarize session", "what did we do" | "resumir sesión", "qué hicimos" | session-summary | crow-memory |
| "change language", "speak in..." | "cambiar idioma", "háblame en..." | i18n | crow-memory |