`services.hyperhive.agent.matrix.enable` was a second source of truth for a fact the account set already carried: after ①-③ the hive-internal `main` account is an ordinary `matrixAccounts` entry, so "does this agent have matrix" and "does this agent have an account" were the same question asked twice, with the boolean able to disagree. The option is gone and a non-empty `matrixAccounts` now gates the daemon unit, its token path-watcher and the injected `extraMcpServers.matrix` entry. That is only a real condition because `matrixAccounts.main` is itself gated: it is declared when `matrix.url != null`, never unconditionally. A `main` with no homeserver is an account the daemon can never log in as, so declaring one always would have made the signal trivially true and turned matrix on for every agent in every hive. With the URL gate, the empty set is reachable exactly for an agent the hive gave no homeserver and whose operator declared no account of its own — the state the old `enable = false` expressed. Assertions: "extras require enable" is deleted, having become the definition of the thing it checked (an external-only account with its own homeserver is now rendered rather than rejected). `main.tokenFile` stays pinned, re-guarded on `? main` instead of on the flag, since `main` is absent whenever the URL is null and an unguarded index would throw there. Both spellings of the option get `mkRemovedOptionModule`, following ../host-modules/deploy.nix's registrationTokenFile pair rather than a silent delete: the definition whose meaning changes is `false`, and left undeclared it would be ignored and hand the agent the tools its operator turned off. Failing the eval with the replacement spelling is the only outcome that cannot. module-eval gains the three arms — URL, nothing, external-only — with the middle one carrying why it exists: it is the only thing in the suite that would notice `main` becoming unconditional again. Refs #4475
9.2 KiB
Matrix MCP tools and extra MCP servers
Built-in matrix MCP (mcp__matrix__*)
When the agent has at least one services.hyperhive.agent.matrixAccounts
entry, the harness autoinjects hive-matrix-daemon's
streamable-http endpoint as a second MCP server (no stdio bridge —
see Architecture below). There is no matrix.enable switch: the account
set is the switch, and the module declares the hive-internal main
account for you as soon as services.hyperhive.agent.matrix.url is
non-null — which hive-c0re fills in per agent whenever the host-level
matrix tuwunel is configured. Tools land as mcp__matrix__<name>:
Messaging
Unread guard: send_message, send_dm, send_file, and
send_reply are all rejected if the room has unread messages — call
read_room then mark_read on the latest event before sending to a
room you haven't read yet.
send_message(room, body)— send a markdown message;roomaccepts!id:serveror#alias:server, daemon resolves eithersend_dm(user_id, body)— open (or reuse) the DM room withuser_idand postbodyto itopen_dm(user_id)— resolve (find-or-create) the DM room withuser_idand return its room id without sending anything. Use the returned id with room-based tools (send_message,send_file, …) to deliver into a DM whensend_dm's body parameter is inconvenient (for example for file attachments)send_reply(room, event_id, body)— threaded reply to a specific eventsend_reaction(room, event_id, key)— react to a message with an emoji keysend_file(room, path, caption?)— upload a local file and post it as a room attachment; MIME type is inferred from the file extension, 50 MiB cap; optionalcaptionis sent as a follow-up text messagesend_redact(room, event_id, reason?)— redact (delete) a specific event; works on your own events, redacting others' requires moderator power level
Reading
read_room(room, limit?)— recent timeline events; media attachments appear inline as[file: name],[image: name],[audio: name], or[video: name]markers — pass the event id todownload_fileto retrieve the attachmentdownload_file(room, event_id, dest_path?)— download a media attachment from a message event to a local file and return its path; pair withread_roomwhich surfaces attachments as the markers abovelist_rooms()— enumerate joined rooms ({ id, canonical_alias, name, member_count }per room)list_room_members(room)— members of a room
Room membership
invite_user(room, user_id)— invite@user:serverinto a room you're already in; you must have a high enough power level. The invitee sees a pending invite and resolves it viaresolve_invite.resolve_invite(room, action)— accept or reject a pending invite.actionis"accept"(join the room) or"reject"(decline and leave). This is the path for invites;join_roomis for joining a public room you weren't invited to.join_room(room)— join a public room by id or alias. Also accepts a pending invite if one exists, but preferresolve_invitefor invites (it can reject too).list_invites()— rooms this agent has been invited to but not yet joined ({ id, canonical_alias, name }per room).
Receipts
mark_read(room, event_id)— advance the read receipt
Multiple accounts
services.hyperhive.agent.matrixAccounts (declared in agent.nix) is
the agent's full set of matrix identities — for example an
external-facing account alongside the internal one — and, being
non-empty, is what turns matrix on for the agent at all. Each entry is
keyed by account name and specifies tokenFile (bearer token,
provisioned out-of-band; basename must start with matrix-token),
sessionDir (per-account matrix-sdk sqlite state — crypto keys +
cache), and an optional homeserver (defaults to
services.hyperhive.agent.matrix.url). The hive-internal account is
always named main and is always the primary; the matrix module
declares it for you as an ordinary entry of this map, from
services.hyperhive.agent.matrix.url + agent state, so what you add
here are the further accounts. Its tokenFile is pinned to
<state>/matrix-token (hive-c0re provisions it there), and the
dashboard's link-account route refuses to create an account by that
name.
main is present exactly when that URL is non-null, which is the whole
mechanism behind "accounts are the enable signal": an agent with no
homeserver and no account of its own has an empty set, so it gets no
daemon, no path watcher and no injected MCP entry. That is how you give
an agent no matrix tools — it replaces the removed
services.hyperhive.agent.matrix.enable = false, which now fails
evaluation with a message saying so. Declaring an external account with
its own homeserver is enough on its own; a hive homeserver is not
required.
Every matrix tool above takes an optional account parameter (a name
from this map) to act as that identity instead of the primary one.
Declaring an extra account also changes what the agent receives: wake
bodies and invite todos gain an [acct:<name>] prefix — see
Architecture below.
Architecture
hive-matrix-daemon is a single long-running process (one per agent
container, systemd service in nix/agent-modules/matrix.nix) — no
stdio bridge, no separate bin. It owns the matrix-sdk Client + sync
loop per configured account and serves the matrix tool surface
directly over streamable-http on services.hyperhive.agent.mcp.matrixHttpPort
(declared in services.hyperhive.agent.extraMcpServers.matrix as
{ type = "http"; url = ...; }). Same shape as hive-bash-daemon and
the built-in hyperhive surface (hive-mcp-http) — claude reconnects
to the stable URL every turn instead of respawning a stdio child.
Silently exits when <state>/matrix-token is absent (account not yet
provisioned); the systemd.paths.hive-matrix-daemon watcher restarts
it the moment hive-c0re provisions the token.
Incoming room events wake the agent via AgentRequest::Wake with
from: "matrix". The wake body format depends on the unread state:
- Single room, one message:
[matrix] <sender> in <room>: <body> — use read_room to view, mark_read to clear - Single room, multiple messages:
[matrix] N unread in <room> — use read_room to view, mark_read to clear - Multiple rooms:
[matrix] unread messages:followed by a bulleted list (- <room>: <sender>: <body>or- <room>: N unreadper room)
Multi-account prefix: when the daemon serves more than one account
(services.hyperhive.agent.matrixAccounts), it prefixes every wake body and every invite todo
below with [acct:<name>] , so a wake arrives as
[acct:ccc] [matrix] <sender> in <room>: …. A single-account agent gets the
formats exactly as written — the daemon adds nothing — which is why matching
on a leading [matrix] works right up until you declare a second account.
hive-matrix-daemon includes the same per-room breakdown in the UnreadMatrix
entry get_loose_ends returns, so unread rooms surface in the
loose-ends list between turns.
Invite wakes: the daemon sweeps invited_rooms() after every sync
callback (deliberately not a one-shot m.room.member event handler —
a one-shot signal that raced a socket-down window was dropped with no
retry, leaving the agent deaf until manually prompted) and upserts a
todo (keyed invite:<room>) for each pending invite on the harness's
in-agent socket, which drives a turn. The daemon does not
autojoin — the agent calls list_invites to see pending invites and
resolve_invite to accept or reject them.
Pending invites as loose ends: the daemon upserts pending invites as
keyed todos, which appear in get_loose_ends output as
[matrix] invited to <room> (<room_id>) — use list_invites to see pending invites, resolve_invite to accept or reject. The keyed todo
is cleared when a resolve_invite (or join_room) call resolves the
invite.
See docs/integrations/matrix.md for the homeserver setup,
provisioning flow, and federation config.
Extra MCP servers (per-agent)
Each agent's NixOS config can declare additional MCP servers via
services.hyperhive.agent.extraMcpServers.<key> = { type, command, args, env, url, allowedTools } — type = "stdio" (the default, uses command/args/
env) or type = "http" (uses url, a long-lived streamable-http
endpoint — see hive-bash-daemon and hive-matrix-daemon above for
the "http" shape). The module writes the map to
/etc/hyperhive/extra-mcp.json; the harness reads it at boot and
merges every entry into --mcp-config (under mcpServers.<key>)
and --allowedTools (as mcp__<key>__<pattern>).
The agent's flake.nix forwards every flake input to agent.nix as
the flakeInputs module arg, so you pull in external MCP-server flakes
by adding them to inputs.* and reference them as
flakeInputs.<name>.packages.${pkgs.system}.default — the resolved
sha lands in the agent's own flake.lock and rolls up to meta's.
allowedTools defaults to ["*"], which expands to
mcp__<key>__* (every tool from that server autoapproved). Restrict
to specific tool names when you want finer control.