# Matrix Agent Communications

Synapse-based Matrix homeserver plus two dedicated tools — `matrix-mcp` (FastMCP HTTP server) and `matrix-channel` (Claude Code Channel plugin) — that give every agent in the system two-way messaging with the operator. Deployed as the primary agent notification and communication layer, replacing ntfy for most events.

## Why It Exists

Before Matrix, agents sent ntfy push notifications for status updates and blocked tasks. ntfy is fire-and-forget: no threading, no history, no way for the agent to read prior messages in a conversation. The Matrix stack adds:

- **Persistent history** per agent room — missed notifications don't disappear
- **Two-way interaction** — operator messages route back to the agent session via matrix-channel
- **Structured artifact posting** — agents post files, logs, and reports directly to Matrix rooms
- **Unified inbox** — all agent activity visible in Element Web without a separate app

Matrix is the sole notification layer for all agent activity — cron jobs, task lifecycle events, approvals, and failures. Shell scripts use `send-matrix.sh` (a curl-based helper that sources credentials from `~/.claude-secrets/matrix.env`) rather than MCP, since cron jobs run outside any Claude Code session.

## Architecture

```
┌─────────────────────────────────────────────────────┐
│  Agent session (Claude Code)                        │
│  mcp__matrix__send_matrix_message                   │
│  mcp__matrix__post_artifact                         │
│  mcp__matrix__get_matrix_messages                   │
└──────────────────┬──────────────────────────────────┘
                   │ HTTP (127.0.0.1:8487)
                   ▼
┌─────────────────────────────────────────────────────┐
│  matrix-mcp (FastMCP, PM2, port 8487)               │
│  - send_matrix_message: text + markdown formatting  │
│  - post_artifact: file → Matrix media upload        │
│  - get_matrix_messages: read room history           │
│  - list_matrix_rooms: enumerate joined rooms        │
└──────────────────┬──────────────────────────────────┘
                   │ Matrix client API (HTTPS)
                   ▼
┌─────────────────────────────────────────────────────┐
│  Synapse v1.151.0 (Docker)                          │
│  + PostgreSQL 16 (Docker)                           │
│  server_name: matrix.yourdomain                     │
│  client API: https://matrix.yourdomain/_matrix/     │
└──────────────────┬──────────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────────┐
│  Element Web v1.12.15 (Docker)                      │
│  SWAG proxy: element.yourdomain                     │
│  Operator web client for all agent rooms            │
└─────────────────────────────────────────────────────┘
```

**matrix-channel** sits on the interactive Claude Code session side:

```
Operator types in Element Web
  → Matrix room
    → matrix-channel (Node.js Channel plugin, Claude Code)
      → injects message as user input into active Claude Code session
        → agent replies, posts back to room via matrix-mcp
```

This enables permission relay — when an agent needs approval for a destructive action, it can post the prompt to Matrix, wait for a reply, and receive the operator's response within the same session.

## Stack Containers

| Container | Image | Purpose |
|-----------|-------|---------|
| `matrix-synapse` | `matrixdotorg/synapse:v1.151.0` | Homeserver |
| `matrix-db` | `postgres:16-alpine` | Synapse PostgreSQL backend |
| `matrix-element` | `vectorim/element-web:v1.12.15` | Browser client |

All three run on `claudebox-net`. Only Synapse's client API (`/_matrix/`) is exposed externally via SWAG. The admin API (`/_synapse/admin/`) is restricted to LAN CIDRs (`192.168.0.0/16`, `10.10.0.0/16`) at the proxy — external requests return 403. LAN access still requires a valid Synapse admin token (enforced natively by Synapse).

**PM2 service:** `matrix-admin-bot` — Python asyncio bot running as `@matrix-admin-bot:yourdomain`. Provisions and manages agent Matrix accounts via admin commands gated by an `allowed_senders` allowlist. See [matrix-admin-bot](#matrix-admin-bot) below.

## Room Structure

11 rooms, one per agent plus shared coordination rooms:

| Room | Purpose |
|------|---------|
| `#claudebox` | Homelab ops agent; also receives cron failures and warnings |
| `#dev` | Development agent |
| `#homelab-ops` | Multi-host infrastructure agent |
| `#research` | Research agent |
| `#security` | Security audit agent; also receives workspace-scan security alerts |
| `#helm-build` | Helm/forge build agent |
| `#outreach` | Outreach agent |
| `#writer` | Documentation agent |
| `#events` | Cron success/info: backup OK, dep updates, repo-sync auto-commits, qmd repo adds |
| `#memory-sync` | Memory pipeline: memory-sync, memory-promote, memory-pipeline results |
| `#approvals` | Task approval requests from task-dispatcher |
| `#task-queue` | Task lifecycle: dead-letters, auto-approved, stale tasks |
| `#announcements` | Cross-agent broadcasts |
| `#general` | Operator + system channel |

Each agent's `CLAUDE.md` includes a `## Communications` section specifying its assigned room and instructing it to use `mcp__matrix__send_matrix_message` for activity updates and `mcp__matrix__post_artifact` for logs and build output.

## matrix-mcp Tools

| Tool | What It Does |
|------|-------------|
| `send_matrix_message` | Send text or markdown message to a room by short name (`dev`, `writer`, etc.) |
| `post_artifact` | Upload a file from the allowlisted paths and post a formatted link to the room |
| `get_matrix_messages` | Fetch recent messages from a room (used for reply polling) |
| `list_matrix_rooms` | Enumerate all rooms the bot account is joined to |

**post_artifact path allowlist:** `~/repos/`, `~/.claude/comms/`, `~/.claude/memory/`. Files outside these trees cannot be posted — prevents agents from inadvertently exfiltrating appdata configs, Docker secrets, or compose files.

**Markdown rendering:** Message bodies run through `bleach.clean()` with a Matrix-spec allowlist before being sent as `formatted_body`. Permitted tags include standard inline/block formatting plus Matrix-specific attributes (`data-mx-color`, `mxc://` URLs). Disallowed tags are stripped, not escaped — raw HTML cannot pass through.

## matrix-admin-bot

`matrix-admin-bot` is a PM2-managed Python asyncio bot that provisions and manages agent Matrix accounts on the Synapse homeserver. It runs as `@matrix-admin-bot:yourdomain`, listens in the `#matrix-admin` room and DMs from the operator, and executes account management commands via the Synapse admin API.

**Commands:**

| Command | What It Does |
|---------|-------------|
| `!create-account @agent:yourdomain [displayname]` | Creates account via admin API; captures initial `device_id` |
| `!join-room @agent:yourdomain !room_id:yourdomain` | Force-joins agent to room via admin API |
| `!list-rooms @agent:yourdomain` | Lists all rooms the agent is currently in |
| `!audit-memberships` | Compares all agents' joined rooms against their `allowed_rooms` config |
| `!revoke-token @agent:yourdomain` | Suspends account (`locked: true` — reversible, does not destroy room associations) |
| `!restore-token @agent:yourdomain` | Re-enables suspended account (`locked: false`) |
| `!rotate-token @agent:yourdomain` | Rotates access token; writes new token atomically to the agent's scoped-mcp config file |
| `!status` | Reports bot uptime, homeserver URL, agents in config, bot MXID |

**Token rotation flow:** delete old device → request admin login token → exchange for `access_token` + `device_id` → atomic write to `~/.claude-secrets/scoped-mcp-<agent>.yml` → update bot config. Rotation is SIGTERM-safe: in-flight command tasks are tracked in `_command_tasks` and excluded from the async cancel loop, giving multi-step commands until PM2's `kill_timeout` (5000 ms) to finish.

**Deployment:** PM2 service `matrix-admin-bot` on claudebox, virtualenv at `~/repos/personal/matrix-admin-bot/venv/`. Config at `~/.claude-secrets/matrix-admin-bot.yml` (chmod 600).

**Security posture:** MXID input validated against `^@[\w.=-]+:yourdomain$` before any API call. `room_id` validated with `_ROOM_ID_RE` regex before URL interpolation. `!join-room` enforces the target agent's `allowed_rooms` — full `!room_id:yourdomain` form required, no substring resolution or bypass. All secrets file writes use `.tmp` + `os.replace()` (atomic), with explicit `os.open(..., 0o600)` so mode survives the rename. Authorization gates all messages by sender identity — never by room membership.

## Task Dispatcher Integration

`task-dispatcher.py` routes all state transition notifications to Matrix via a `matrix_notify()` helper that calls matrix-mcp over HTTP. The routing table:

| Event | Destination |
|-------|------------|
| Task submitted | `#announcements` |
| Task approved / rejected | `#announcements` + agent's room |
| Task completed | Agent's room |
| Build handoff created | Target agent's room |
| Security audit requested | `#security` |
| Pending-approval gate | `#approvals` |
| Dead-letter / routing exhausted | `#task-queue` (high-priority) |
| Stale approved task (>24h) | `#task-queue` (deduplicated to once/24h) |

## Cron Job Integration

All PM2 cron jobs send notifications via `send-matrix.sh` (`~/scripts/send-matrix.sh`), a curl-based shell helper that sources credentials from `~/.claude-secrets/matrix.env`, resolves short room names to Matrix room IDs, and POSTs via the Synapse client API. This runs without MCP — cron jobs have no Claude Code session context.

Room routing for cron notifications:

| Category | Room | Examples |
|----------|------|---------|
| Success / info | `#events` | Backup OK, dep update report, repo-sync auto-commit, qmd repo add |
| Failure / warning | `#claudebox` | Resource alerts, backup failures, qmd reindex failure, workspace scan |
| Memory pipeline | `#memory-sync` | memory-sync, memory-promote-daily, memory-pipeline results |
| Task lifecycle | `#approvals` / `#task-queue` | Approval requests, dead-letters, stale tasks |

## matrix-channel

`matrix-channel` is a Node.js Claude Code Channel plugin that connects a Matrix room to an active interactive Claude Code session. It polls a specified room for new messages and injects them as user input into the running session.

**Use case:** An agent running interactively posts a question to its Matrix room. The operator replies in Element Web. `matrix-channel` picks up the reply and feeds it back to the agent without requiring a separate chat window or manual copy-paste.

**Tag injection protection:** Incoming messages are sanitized before injection — `</claude-channel>` (case-insensitive) in the message body is escaped to prevent tag-boundary injection into the channel event stream.

## Security

- **SWAG admin allowlist:** `/_synapse/admin/` restricted to LAN CIDRs (`192.168.0.0/16`, `10.10.0.0/16`) at the proxy — external requests return 403; LAN access requires a valid Synapse admin token (enforced natively)
- **homeserver.yaml permissions:** Mode 640 — readable only by the Synapse process user, not world-readable
- **post_artifact allowlist:** High-risk paths (`/opt/appdata/`, Docker compose trees) excluded; agents needing to post from those trees copy files into `~/.claude/comms/artifacts/` first
- **HTML sanitization:** All markdown content passes through `bleach.clean()` with explicit tag/attr/protocol allowlists; `html.escape()` used for user-controlled strings embedded in HTML context
- **Docker hardening:** `no-new-privileges:true`, `cap_drop: ALL` with minimal per-service `cap_add`, memory limits per container (Synapse 2 GB, PostgreSQL 1 GB, Element Web 256 MB)
- **matrix-admin-bot atomic writes:** secrets files use `.tmp` + `os.replace()` with `os.open(..., 0o600)` — a crash or SIGTERM mid-write leaves the old file intact rather than producing an empty or partial file
- **matrix-admin-bot SIGTERM safety:** `!rotate-token` tracks the in-flight coroutine in `_command_tasks`; the signal handler skips that set when cancelling tasks, so a PM2 restart during rotation doesn't leave the agent locked out with no valid token

## Related Docs

- [inter-agent-communication.md](inter-agent-communication.md) — agent handoff patterns and queue mechanics
- [task-dispatcher.md](task-dispatcher.md) — full routing table and dispatcher lifecycle
- [ntfy-mcp.md](ntfy-mcp.md) — ntfy-mcp (MCP infrastructure; cron scripts use send-matrix.sh instead)
- [agent-bus.md](agent-bus.md) — NATS-backed event log for structured agent activity
