hyperhive/docs/tools/matrix.md
atlas e3864fe787 docs/matrix: name the [acct:<name>] prefix a multi-account agent receives
`matrix.md` documents the exact text of every inbound matrix signal —
three wake-body shapes and the invite loose-end — and none of them
mention that the daemon prefixes `[acct:<name>] ` when it serves more
than one account.

`wake::tag_account` is live on both documented paths (`timeline.rs:75`
for unread wakes, `:139` for invite todos), so an agent with an extra
account receives `[acct:ccc] [matrix] @a:s in #x: hi` where the page
promises a body starting `[matrix]`. The example is not hypothetical:
the matrix module uses `matrix-token-ccc` on dmatrix as its worked
example of an extra account.

It stayed invisible because the `None` arm returns the body unchanged,
so every single-account agent sees the documented format exactly. The
page is right for almost every reader and wrong for precisely the
readers its "Multiple accounts" section is written for.

Two placements rather than one. The prefix itself goes next to the wake
formats it corrects, with the worked example and the reason a leading
`[matrix]` match works until a second account exists. A forward pointer
goes in "Multiple accounts", because that is the section someone
configuring extra accounts actually lands on, and it previously covered
only the outbound `account` parameter — the half you pass, not the half
you parse.

Closes #4243.
2026-09-11 18:19:22 +02:00

8 KiB

Matrix MCP tools and extra MCP servers

Built-in matrix MCP (mcp__matrix__*)

When hyperhive.matrix.enable = true and the host-level matrix tuwunel is configured, the harness autoinjects hive-matrix-daemon's streamable-http endpoint as a second MCP server (no stdio bridge — see Architecture below). 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; room accepts !id:server or #alias:server, daemon resolves either
  • send_dm(user_id, body) — open (or reuse) the DM room with user_id and post body to it
  • open_dm(user_id) — resolve (find-or-create) the DM room with user_id and return its room id without sending anything. Use the returned id with room-based tools (send_message, send_file, …) to deliver into a DM when send_dm's body parameter is inconvenient (for example for file attachments)
  • send_reply(room, event_id, body) — threaded reply to a specific event
  • send_reaction(room, event_id, key) — react to a message with an emoji key
  • send_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; optional caption is sent as a follow-up text message
  • send_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 to download_file to retrieve the attachment
  • download_file(room, event_id, dest_path?) — download a media attachment from a message event to a local file and return its path; pair with read_room which surfaces attachments as the markers above
  • list_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:server into a room you're already in; you must have a high enough power level. The invitee sees a pending invite and resolves it via resolve_invite.
  • resolve_invite(room, action) — accept or reject a pending invite. action is "accept" (join the room) or "reject" (decline and leave). This is the path for invites; join_room is 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 prefer resolve_invite for 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

hyperhive.matrixAccounts (declared in agent.nix) gives an agent additional matrix identities beyond the hive-internal one — for example an external-facing account alongside the internal one. 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 hyperhive.matrix.url). The hive-internal account is always named main, synthesized from hyperhive.matrix.url + agent state — this option only declares extras, and main is a reserved key here. Requires hyperhive.matrix.enable = true.

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 hyperhive.mcp.matrixHttpPort (declared in hyperhive.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 unread per room)

Multi-account prefix: when the daemon serves more than one account (hyperhive.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 hyperhive.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.