diff --git a/deploy/synapse/.gitignore b/deploy/synapse/.gitignore new file mode 100644 index 000000000..fe47ae372 --- /dev/null +++ b/deploy/synapse/.gitignore @@ -0,0 +1,9 @@ +# Secrets — never commit +.env +synapse-credentials.env + +# Backups +backups/ + +# Generated config backups +homeserver.yaml.bak diff --git a/deploy/synapse/docker-compose.yml b/deploy/synapse/docker-compose.yml new file mode 100644 index 000000000..ece8cc0f7 --- /dev/null +++ b/deploy/synapse/docker-compose.yml @@ -0,0 +1,82 @@ +# Synapse Homeserver — Docker Compose Stack +# Matrix Phase 1: Deploy Synapse on Ezra VPS +# +# Usage: +# cd deploy/synapse +# ./setup.sh # first-time deploy (generates config + keys) +# docker compose up -d # start +# docker compose logs -f # follow logs +# docker compose down # stop +# +# Secrets: +# Never commit .env to version control. +# setup.sh generates secrets automatically. + +services: + synapse-db: + image: postgres:16-alpine + container_name: synapse-db + restart: unless-stopped + volumes: + - synapse_db:/var/lib/postgresql/data + environment: + POSTGRES_USER: synapse + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U synapse"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - synapse_net + logging: + driver: "json-file" + options: + max-size: "20m" + max-file: "3" + + synapse: + image: matrixdotorg/synapse:latest + container_name: synapse + restart: unless-stopped + depends_on: + synapse-db: + condition: service_healthy + volumes: + - synapse_data:/data + env_file: + - .env + environment: + SYNAPSE_CONFIG_PATH: /data/homeserver.yaml + ports: + - "127.0.0.1:8008:8008" # Client-server API (localhost only) + - "8448:8448" # Federation (public) + networks: + - synapse_net + healthcheck: + test: ["CMD", "curl", "-fSs", "http://localhost:8008/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + reservations: + memory: 512M + +volumes: + synapse_data: + synapse_db: + +networks: + synapse_net: + driver: bridge diff --git a/deploy/synapse/homeserver.yaml b/deploy/synapse/homeserver.yaml new file mode 100644 index 000000000..4f67847fc --- /dev/null +++ b/deploy/synapse/homeserver.yaml @@ -0,0 +1,101 @@ +# Synapse Homeserver Configuration +# Generated by setup.sh — edit with care. +# +# Docs: https://matrix-org.github.io/synapse/latest/usage/configuration/config_documentation.html + +# Server name — your Matrix domain (e.g. matrix.example.com) +server_name: "SERVER_NAME_PLACEHOLDER" + +# Signing key — generated by setup.sh +signing_key_path: "/data/signing.key" + +# Trusted key servers (empty = trust only ourselves for our own keys) +trusted_key_servers: [] + +# Report stats to matrix.org (no for sovereignty) +report_stats: false + +# Listeners +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation] + compress: false + +# Database — PostgreSQL +database: + name: psycopg2 + args: + user: synapse + password: "${POSTGRES_PASSWORD}" + database: synapse + host: synapse-db + cp_min: 5 + cp_max: 10 + +# Media store +media_store_path: "/data/media_store" + +# Upload limits +max_upload_size: "50M" + +# URL previews (disable to reduce attack surface) +url_preview_enabled: false + +# Enable room list publishing +enable_room_list_search: true + +# Turn off public registration by default (create users via admin API) +enable_registration: false +enable_registration_without_verification: false + +# Rate limiting +rc_message: + per_second: 0.2 + burst_count: 10 + +rc_registration: + per_second: 0.1 + burst_count: 3 + +rc_login: + address: + per_second: 0.05 + burst_count: 2 + account: + per_second: 0.05 + burst_count: 2 + failed_attempts: + per_second: 0.15 + burst_count: 3 + +# Retention — keep messages for 90 days by default +retention: + enabled: true + default_policy: + min_lifetime: 1d + max_lifetime: 90d + +# Logging +log_config: "/data/log.config" + +# Metrics (optional — enable if running Prometheus) +enable_metrics: false + +# Presence +use_presence: true + +# Federation +federation_verify_certificates: true +federation_sender_instances: 1 + +# Appservice config directory +app_service_config_files: [] + +# Experimental features +experimental_features: + # MSC3440: Threading support + msc3440_enabled: true diff --git a/deploy/synapse/log.config b/deploy/synapse/log.config new file mode 100644 index 000000000..3a787c61c --- /dev/null +++ b/deploy/synapse/log.config @@ -0,0 +1,33 @@ +# Synapse logging configuration +# https://matrix-org.github.io/synapse/latest/usage/configuration/config_documentation.html#log_config + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + console: + class: logging.StreamHandler + formatter: precise + level: INFO + stream: ext://sys.stdout + + file: + class: logging.handlers.RotatingFileHandler + formatter: precise + filename: /data/homeserver.log + maxBytes: 104857600 # 100MB + backupCount: 3 + level: INFO + +loggers: + synapse.storage.SQL: + level: WARNING + synapse.http.client: + level: INFO + +root: + level: INFO + handlers: [console, file] diff --git a/deploy/synapse/manage.sh b/deploy/synapse/manage.sh new file mode 100755 index 000000000..f05fc0838 --- /dev/null +++ b/deploy/synapse/manage.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Synapse Homeserver — Management Utilities +# Usage: ./manage.sh +# +# Commands: +# status Show container status and health +# restart Restart Synapse (preserves data) +# logs Tail Synapse logs +# create-user [admin] +# backup Create timestamped backup of data volumes +# update Pull latest Synapse image and recreate +# teardown Stop and remove everything (DESTRUCTIVE) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${GREEN}[MANAGE]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +COMMAND="${1:-help}" + +case "$COMMAND" in + status) + info "Container status:" + docker compose ps + echo "" + info "Synapse health:" + curl -sfS http://127.0.0.1:8008/health && echo "" || echo "Not responding" + echo "" + info "Disk usage:" + docker system df -v 2>/dev/null | grep -E "synapse|VOLUME" || true + ;; + + restart) + info "Restarting Synapse..." + docker compose restart synapse + info "Waiting for health check..." + sleep 5 + curl -sfS http://127.0.0.1:8008/health && echo "" && info "Synapse is healthy" || warn "Not responding yet" + ;; + + logs) + shift + LINES="${1:-100}" + info "Tailing Synapse logs (last $LINES lines)..." + docker compose logs -f --tail="$LINES" synapse + ;; + + create-user) + USERNAME="${2:?Usage: manage.sh create-user [admin]}" + PASSWORD="${3:?Usage: manage.sh create-user [admin]}" + IS_ADMIN="${4:-false}" + info "Creating user @$USERNAME..." + ADMIN_FLAG="" + if [ "$IS_ADMIN" = "admin" ] || [ "$IS_ADMIN" = "true" ]; then + ADMIN_FLAG="--admin" + fi + docker compose exec -T synapse register_new_matrix_user \ + http://localhost:8008 \ + -c /data/homeserver.yaml \ + -u "$USERNAME" \ + -p "$PASSWORD" \ + $ADMIN_FLAG \ + --no-extra-prompt + ;; + + backup) + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + BACKUP_DIR="./backups/${TIMESTAMP}" + mkdir -p "$BACKUP_DIR" + info "Backing up PostgreSQL..." + docker compose exec -T synapse-db pg_dump -U synapse > "${BACKUP_DIR}/synapse_db.sql" + info "Backing up Synapse data volume..." + docker run --rm \ + -v synapse_data:/source:ro \ + -v "$(pwd)/${BACKUP_DIR}:/backup" \ + alpine tar czf /backup/synapse_data.tar.gz -C /source . + info "Backup complete: $BACKUP_DIR" + ls -lh "$BACKUP_DIR" + ;; + + update) + info "Pulling latest Synapse image..." + docker compose pull synapse + info "Recreating containers..." + docker compose up -d --force-recreate synapse + info "Waiting for health..." + sleep 10 + curl -sfS http://127.0.0.1:8008/health && echo "" && info "Updated and healthy" || warn "Check logs" + ;; + + teardown) + echo -e "${RED}WARNING: This will stop and remove all Synapse containers and volumes.${NC}" + echo -e "${RED}ALL DATA WILL BE LOST. This cannot be undone.${NC}" + echo "" + read -p "Type 'yes-delete-everything' to confirm: " CONFIRM + if [ "$CONFIRM" = "yes-delete-everything" ]; then + info "Stopping containers..." + docker compose down -v + info "Removing volumes..." + docker volume rm synapse_data synapse_db 2>/dev/null || true + info "Teardown complete." + else + info "Aborted." + fi + ;; + + help|*) + echo "Synapse Homeserver Management" + echo "" + echo "Usage: ./manage.sh " + echo "" + echo "Commands:" + echo " status Show container status and health" + echo " restart Restart Synapse" + echo " logs [lines] Tail Synapse logs (default: 100)" + echo " create-user

[admin] Create a new Matrix user" + echo " backup Backup database + data volume" + echo " update Pull latest image and recreate" + echo " teardown Stop and remove everything (DESTRUCTIVE)" + ;; +esac diff --git a/deploy/synapse/setup.sh b/deploy/synapse/setup.sh new file mode 100755 index 000000000..17e6d5134 --- /dev/null +++ b/deploy/synapse/setup.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# Synapse Homeserver — One-Shot Setup Script +# Matrix Phase 1: Deploy Synapse on Ezra VPS +# +# Usage: +# ./setup.sh [admin_user] [admin_password] +# +# Example: +# ./setup.sh matrix.timmy-time.xyz hermes-bot 'secure-pass-123' +# +# What it does: +# 1. Generates .env with secrets +# 2. Prepares homeserver.yaml with correct server name +# 3. Generates signing key +# 4. Starts Synapse + PostgreSQL via Docker Compose +# 5. Waits for Synapse to be healthy +# 6. Registers admin user + bot account +# 7. Outputs Matrix credentials for hermes-agent + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# --- Colors --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${GREEN}[SETUP]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +# --- Args --- +SERVER_NAME="${1:?Usage: $0 [admin_user] [admin_password]}" +ADMIN_USER="${2:-timmy-admin}" +ADMIN_PASS="${3:-$(openssl rand -hex 16)}" +BOT_USER="${4:-hermes-bot}" +BOT_PASS="${5:-$(openssl rand -hex 16)}" + +echo -e "${CYAN}" +echo "╔══════════════════════════════════════════════════╗" +echo "║ Synapse Homeserver — Matrix Phase 1 Deploy ║" +echo "╚══════════════════════════════════════════════════╝" +echo -e "${NC}" +info "Server name: $SERVER_NAME" +info "Admin user: @$ADMIN_USER:$SERVER_NAME" +info "Bot user: @$BOT_USER:$SERVER_NAME" +echo "" + +# --- Preflight --- +info "Preflight checks..." +command -v docker >/dev/null 2>&1 || error "docker not found. Install Docker first." +command -v docker compose version >/dev/null 2>&1 || error "docker compose not found. Install Docker Compose plugin." +info "Docker: $(docker --version | head -1)" +info "Compose: $(docker compose version | head -1)" + +# --- Generate .env --- +info "Generating .env..." +POSTGRES_PASSWORD=$(openssl rand -hex 24) +REGISTRATION_SECRET=$(openssl rand -hex 16) + +cat > .env </dev/null 2>&1 || true +docker volume create synapse_db >/dev/null 2>&1 || true + +# --- Start the stack --- +info "Starting Synapse + PostgreSQL..." +docker compose up -d + +# --- Wait for Synapse to be healthy --- +info "Waiting for Synapse to start (up to 120s)..." +MAX_WAIT=120 +ELAPSED=0 +while [ $ELAPSED -lt $MAX_WAIT ]; do + if curl -sfS http://127.0.0.1:8008/health >/dev/null 2>&1; then + info "Synapse is healthy!" + break + fi + sleep 3 + ELAPSED=$((ELAPSED + 3)) + if [ $((ELAPSED % 15)) -eq 0 ]; then + info "Still waiting... (${ELAPSED}s)" + fi +done + +if [ $ELAPSED -ge $MAX_WAIT ]; then + warn "Synapse did not respond within ${MAX_WAIT}s. Check logs:" + echo " docker compose logs synapse" + error "Aborting registration." +fi + +# --- Register admin user --- +info "Registering admin user @$ADMIN_USER:$SERVER_NAME..." +docker compose exec -T synapse register_new_matrix_user \ + http://localhost:8008 \ + -c /data/homeserver.yaml \ + -u "$ADMIN_USER" \ + -p "$ADMIN_PASS" \ + --admin \ + --no-extra-prompt 2>&1 || { + # User might already exist if re-running + warn "Admin user registration returned non-zero (may already exist)" +} + +# --- Register bot user --- +info "Registering bot user @$BOT_USER:$SERVER_NAME..." +docker compose exec -T synapse register_new_matrix_user \ + http://localhost:8008 \ + -c /data/homeserver.yaml \ + -u "$BOT_USER" \ + -p "$BOT_PASS" \ + --no-admin \ + --no-extra-prompt 2>&1 || { + warn "Bot user registration returned non-zero (may already exist)" +} + +# --- Get bot access token --- +info "Acquiring bot access token..." +BOT_TOKEN_RESPONSE=$(curl -sfS -X POST "http://127.0.0.1:8008/_matrix/client/v3/login" \ + -H 'Content-Type: application/json' \ + -d "{ + \"type\": \"m.login.password\", + \"identifier\": { + \"type\": \"m.id.user\", + \"user\": \"${BOT_USER}\" + }, + \"password\": \"${BOT_PASS}\", + \"device_name\": \"Hermes Agent\" + }") + +BOT_ACCESS_TOKEN=$(echo "$BOT_TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" 2>/dev/null || echo "FAILED_TO_EXTRACT") +BOT_DEVICE_ID=$(echo "$BOT_TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['device_id'])" 2>/dev/null || echo "UNKNOWN") + +if [ "$BOT_ACCESS_TOKEN" = "FAILED_TO_EXTRACT" ]; then + warn "Could not extract bot access token automatically." + warn "Login manually: curl -X POST http://127.0.0.1:8008/_matrix/client/v3/login ..." +fi + +# --- Write credentials file --- +CREDENTIALS_FILE="synapse-credentials.env" +cat > "$CREDENTIALS_FILE" </dev/null || echo '')" +echo " 2. Set up TLS: nginx/certbot reverse proxy for :8008 and :8448" +echo " 3. Copy credentials to hermes-agent: cp ${CREDENTIALS_FILE} ~/.hermes/.env" +echo " 4. Start hermes: hermes gateway --platform matrix" +echo "" +echo " Manage: docker compose logs -f | docker compose restart | docker compose down" +echo " Users: docker compose exec synapse register_new_matrix_user http://localhost:8008 -c /data/homeserver.yaml -u -p " +echo "" diff --git a/docs/synapse-deployment.md b/docs/synapse-deployment.md new file mode 100644 index 000000000..1b9ff924c --- /dev/null +++ b/docs/synapse-deployment.md @@ -0,0 +1,251 @@ +# Synapse Homeserver Deployment Guide + +## Matrix Phase 1: Deploy Synapse on Ezra VPS + +Part of [Epic #269: Matrix Integration — Sovereign Messaging for Timmy](https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/269). + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Ezra VPS (143.198.27.163) │ +│ │ +│ ┌──────────┐ ┌─────────────────────────┐ │ +│ │ Nginx │────▶│ Synapse (Docker) │ │ +│ │ :443→8008│ │ Client API: localhost:8008│ │ +│ │ :8448→8448│ │ Federation: 0.0.0.0:8448│ │ +│ └──────────┘ └──────────┬──────────────┘ │ +│ │ │ +│ ┌────────▼──────────┐ │ +│ │ PostgreSQL 16 │ │ +│ │ (Docker volume) │ │ +│ └───────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ hermes-agent (gateway) │ │ +│ │ MATRIX_HOMESERVER=http://localhost:8008 │ │ +│ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## Prerequisites + +- Docker + Docker Compose plugin on Ezra VPS +- SSH access: `ssh root@143.198.27.163` +- DNS A record pointing to the VPS IP +- (Recommended) Nginx + Certbot for TLS termination + +## Quick Start + +```bash +# SSH into Ezra +ssh root@143.198.27.163 + +# Clone hermes-agent (if not present) +cd /root +git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent.git +cd hermes-agent/deploy/synapse + +# Deploy Synapse +chmod +x setup.sh +./setup.sh matrix.timmy-time.xyz + +# This will: +# 1. Generate .env with database password +# 2. Prepare homeserver.yaml +# 3. Start Synapse + PostgreSQL via Docker Compose +# 4. Wait for health +# 5. Register admin + bot accounts +# 6. Acquire bot access token +# 7. Write synapse-credentials.env +``` + +## Step-by-Step + +### 1. DNS Configuration + +Point your Matrix domain to Ezra's IP: + +``` +Type Name Value +A matrix 143.198.27.163 +``` + +Federation uses SRV records for port discovery, but direct `:8448` works without them. + +### 2. Deploy Synapse + +```bash +cd /root/hermes-agent/deploy/synapse +./setup.sh matrix.timmy-time.xyz hermes-bot 'your-secure-password' +``` + +Arguments: +| Arg | Default | Description | +|-----|---------|-------------| +| `server_name` | (required) | Matrix domain (e.g., `matrix.timmy-time.xyz`) | +| `admin_user` | `timmy-admin` | Admin account username | +| `admin_password` | (random) | Admin account password | +| `bot_user` | `hermes-bot` | Bot account username | +| `bot_password` | (random) | Bot account password | + +### 3. TLS Termination (Nginx) + +Install Nginx + Certbot: + +```bash +apt install -y nginx certbot python3-certbot-nginx + +# Client-server API +cat > /etc/nginx/sites-available/matrix <<'EOF' +server { + listen 443 ssl http2; + server_name matrix.timmy-time.xyz; + + ssl_certificate /etc/letsencrypt/live/matrix.timmy-time.xyz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.timmy-time.xyz/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8008; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 50M; + } +} + +server { + listen 8448 ssl http2; + server_name matrix.timmy-time.xyz; + + ssl_certificate /etc/letsencrypt/live/matrix.timmy-time.xyz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/matrix.timmy-time.xyz/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8008; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +EOF + +ln -sf /etc/nginx/sites-available/matrix /etc/nginx/sites-enabled/ +nginx -t && systemctl reload nginx + +# Get cert +certbot --nginx -d matrix.timmy-time.xyz +``` + +### 4. Wire Hermes Agent + +Copy the generated credentials to hermes-agent's environment: + +```bash +# From synapse-credentials.env, add to ~/.hermes/.env: +MATRIX_HOMESERVER=https://matrix.timmy-time.xyz +MATRIX_ACCESS_TOKEN= +MATRIX_USER_ID=@hermes-bot:matrix.timmy-time.xyz +MATRIX_DEVICE_ID= +MATRIX_ENCRYPTION=true +``` + +Then start the gateway: + +```bash +hermes gateway --platform matrix +``` + +### 5. Verify + +```bash +# Check Synapse health +curl -s https://matrix.timmy-time.xyz/_matrix/client/versions + +# Check federation +curl -s https://matrix.timmy-time.xyz:8448/_matrix/federation/v1/version + +# Check bot is connected +# (should appear online in Element or any Matrix client) +``` + +## Management + +Use the management script for day-to-day operations: + +```bash +cd /root/hermes-agent/deploy/synapse + +./manage.sh status # container health +./manage.sh logs # tail logs +./manage.sh restart # restart Synapse +./manage.sh backup # backup DB + data +./manage.sh update # pull latest image +./manage.sh create-user alice 'password123' +./manage.sh create-user admin 'secret' admin +``` + +## Backups + +```bash +./manage.sh backup +# Creates: backups/YYYYMMDD_HHMMSS/ +# ├── synapse_db.sql (PostgreSQL dump) +# └── synapse_data.tar.gz (media store + keys) +``` + +Automate with cron: + +```bash +# Daily backup at 3 AM +0 3 * * * cd /root/hermes-agent/deploy/synapse && ./manage.sh backup >> /var/log/synapse-backup.log 2>&1 +``` + +## Troubleshooting + +### Synapse won't start +```bash +docker compose logs synapse +# Common: PostgreSQL not ready. Wait for healthcheck. +``` + +### Bot can't connect +```bash +# Verify token is valid +curl -H "Authorization: Bearer $MATRIX_ACCESS_TOKEN" \ + https://matrix.timmy-time.xyz/_matrix/client/v3/account/whoami +``` + +### Federation not working +```bash +# Check port 8448 is open +ss -tlnp | grep 8448 +# Check firewall +ufw status +``` + +### High memory usage +```bash +# Check resource limits in docker-compose.yml +docker stats synapse +# Tune in homeserver.yaml: event_cache_size, caches +``` + +## Security Notes + +- Registration is disabled by default (`enable_registration: false`) +- Rate limiting is enforced on login, registration, and messages +- Federation certificate verification is enabled +- `.env` and `synapse-credentials.env` are `chmod 600` +- Client API binds to `127.0.0.1` only (use Nginx for public access) +- Consider: firewall rules, fail2ban, regular backups + +## References + +- [Synapse Documentation](https://matrix-org.github.io/synapse/latest/) +- [Matrix Spec](https://spec.matrix.org/) +- [Epic #269: Matrix Integration](https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/269) +- [Issue #272: Deploy Synapse on Ezra](https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent/issues/272) +- [Hermes Matrix Setup Guide](docs/matrix-setup.md)