docs/book/src/channels/matrix.md
Run ZeroClaw in Matrix rooms, including end-to-end encrypted (E2EE) rooms.
{{#peer-group matrix}}
Common failure mode this guide targets:
"Matrix is configured correctly, checks pass, but the bot does not respond."
If Matrix appears connected but there's no reply, validate these first:
external_peers = ["*"]).whoami check on the token path, see §5C).recovery_key set (recommended) or keys shared to the bot device.Before testing message flow:
user_id + password (recommended, see §2) or an access_token (token path, §3).allowed_rooms includes the target room (or is empty to allow all rooms the bot has joined). Entries are matched literally against the canonical room ID (!room:server) of each incoming message, so list canonical room IDs here: ZeroClaw does not resolve a #alias:server entry for this allowlist. (Aliases are resolved only for outbound delivery targets such as cron delivery.to.) Find a room's canonical ID in its client (in Element: Room settings → Advanced → Internal room ID).external_peers = ["*"] for open testing, see §6).recovery_key (recommended) restores keys automatically, or keys are shared to the bot device manually.{{#config-fields channels.matrix}}
Matrix is configured as a [channels.matrix.<alias>] block. Set it through any of these surfaces:
{{#config-where channels matrix}}
The official, lowest-friction way to run Matrix is to let ZeroClaw log in fresh and manage its own device identity:
device_id. Let the homeserver assign one at login. ZeroClaw
saves the assigned id to session.json and reuses it on every restart, so
there is no value for you to look up, copy, or keep in sync. Pinning a
device_id by hand is the single most common source of broken key sharing.access_token. When it is unset, ZeroClaw falls back to password
login. A fresh login is also what the auto-recovery path (§8) uses, so the
bot self-heals from corrupted local state without operator action.password. With access_token absent, user_id + password
perform the login.recovery_key. This restores room keys from server-side backup and
cross-signs the freshly registered device automatically on every startup:
no emoji verification, no manual key sharing, no bootstrap. See §5I for how
to get it from Element.So a complete recommended block sets homeserver, user_id, password, and
recovery_key, and leaves access_token and device_id unset.
The access_token + device_id path (§3) still works and is documented in
full for operators who must reuse a pre-existing token, but it requires you to
keep a stable device_id yourself, so prefer password + recovery key unless
you have a specific reason not to.
{{#secret-config channels.matrix.<alias>.password}}
{{#secret-config channels.matrix.<alias>.access_token}}
homeserver is required. For the recommended setup, also set user_id,
password, and recovery_key. access_token and device_id are only needed
for the token-based path in §3; allowed_rooms optionally restricts which
rooms the bot answers in. Authorize senders with a peer group. Full field index: config reference.
Don't have a
recovery_keyyet? See §5I: it walks through generating one in Element. Going the token route instead? See §3 for the password-login API call that mints anaccess_tokenplus a stabledevice_idin one shot. To look updevice_idfor a token you already have, see §5H.
user_id and device_iduser_id and leave
device_id unset: the homeserver assigns and ZeroClaw persists it./_matrix/client/v3/account/whoami.access_token path do you set device_id manually: a token
login carries a device the server already minted, and ZeroClaw needs that
exact id for E2EE session restore (see §5H to find it).{{#thread-context channel="Matrix" prop="reply_in_thread" path="channels.matrix.<alias>.reply_in_thread"}}
access_token and device_id[!IMPORTANT] This section is for the
access_tokenpath only. If you followed the recommended password + recovery-key setup in §2, you can skip it: you do not need an access token or a hand-manageddevice_id.
Use this path when you must reuse a pre-existing token (for example one copied
from another deployment). Element doesn't expose the token directly, so the
canonical way to mint one is a one-shot password-login API call that returns
both the access token and a stable device ID together. The token login carries
a device, so on this path device_id is required and must stay stable.
If your operator account already has a token, skip to §4. If you only need to look up the device_id for an existing token, see §5H Option 1 (whoami) or Option 2 (Element).
Run this once. Replace your.homeserver, the bot username, password, and pick any short device_id string (alphanumeric, no spaces; this is the server-side device label that ZeroClaw will reuse on every restart):
curl -sS -X POST "https://your.homeserver/_matrix/client/v3/login" \
-H "Content-Type: application/json" \
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"YOUR_BOT_USERNAME"},"password":"YOUR_PASSWORD","device_id":"NEW_DEVICE_ID"}'
Response:
{"user_id": "@bot:example.com", "access_token": "syt_...", "device_id": "NEWDEVICE"}
Put access_token, device_id, and user_id from the response into your [channels.matrix.<alias>] block (see §2 for where to set them), then restart: zeroclaw service restart.
zeroclaw config get will print [masked] for the token field; you can't retrieve it later. Stash it in a scratch note if you'll need it for the curl validation snippets in §5C.device_id on every restart: changing it forces a new server-side device registration, which breaks key sharing and verification in encrypted rooms. The auto-recovery path in §8 handles the rare cases where wiping is genuinely the right call.access_token field in your config (see §2), then zeroclaw service restart.Apply the field set in §2 if you haven't yet, then restart with zeroclaw service restart (background) or zeroclaw daemon (foreground). Send a plain-text message in the configured Matrix room. Confirm:
Work through in order.
allowed_rooms, it must be the canonical room ID (!room:server), not a #alias:server. Aliases are not resolved for the allowlist, so an alias entry silently matches nothing. Find the canonical ID in Element via Room settings → Advanced → Internal room ID.The sender must be in the agent's peer set, see Who can talk to the agent at the top of this page. For diagnosis, temporarily set external_peers = ["*"] and restart the daemon.
Secrets are encrypted at rest and not retrievable: zeroclaw config get prints [masked] for any secret field. To run the checks below, use the access token you minted in §3 (or mint a fresh one) and your own homeserver URL.
Validate the token server-side:
<div class="os-tabs-src">curl -sS -H "Authorization: Bearer <access_token>" \
"https://your.homeserver/_matrix/client/v3/account/whoami"
user_id must match the bot account.device_id is missing from the response, set it manually (see §5H).access_token field in your config (see §2), then zeroclaw service restart.matrix_sdk_crypto::backups: Trying to backup room keys but no backup key was found: key backup recovery isn't enabled on this device yet. Non-fatal for message flow; still worth completing (see §5I).device_id stable across restarts.ZeroClaw suppresses matrix_sdk, matrix_sdk_base, and matrix_sdk_crypto to warn by default; they're noisy at info. Restore SDK output for debugging:
RUST_LOG=info,matrix_sdk=info,matrix_sdk_base=info,matrix_sdk_crypto=info zeroclaw daemon
m.room.message text content.formatted_body render emphasis, lists, and code blocks.After config changes, restart the daemon and send a new message. Old timeline history won't be replayed.
device_id for an existing tokenYou only need this on the access_token path (§3). The recommended password +
recovery-key setup omits device_id entirely: the homeserver assigns one and
ZeroClaw persists it, so there is nothing to look up. If you have switched to
the recommended setup, skip this section.
If you really must pin a device_id (because you are reusing an existing
access token rather than logging in with a password), use this to find the one
bound to that token. For brand-new bots on the token path, see §3: the
password-login flow there returns both values together.
ZeroClaw needs a stable device_id for E2EE session restore on the token path. Without it, a new device is registered every restart, breaking key sharing and device verification.
whoami (easiest)curl -sS -H "Authorization: Bearer <access_token>" \
"https://your.homeserver/_matrix/client/v3/account/whoami"
Response includes device_id if the token is bound to a device session:
{"user_id": "@bot:example.com", "device_id": "ABCDEF1234"}
If device_id is missing, the token was created without a device login (e.g. via the admin API). Mint a new token + device_id together via §3.
device_id in your config (see §2), then zeroclaw service restart. Keep device_id stable: changing it forces a new device registration, which breaks existing key sharing and verification.Symptom: Matrix one-time key upload conflict detected; stopping sync to avoid infinite retry loop and the channel becomes unavailable.
Cause: The local crypto store was deleted while the old device still had one-time keys registered on the homeserver. The SDK can't upload new keys because the old keys still exist server-side, causing an infinite OTK conflict loop.
A fresh login creates a new device with a new device_id, sidestepping the OTK conflict entirely (no UIA-gated device deletion required).
Stop ZeroClaw.
<div class="os-tabs-src">zeroclaw service stop
Get a fresh access token and device_id:
curl -sS -X POST "https://matrix.org/_matrix/client/v3/login" \
-H "Content-Type: application/json" \
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"YOUR_BOT_USERNAME"},"password":"YOUR_PASSWORD","device_id":"NEW_DEVICE_ID"}'
Save the returned access_token and device_id.
Delete the local crypto store:
<div class="os-tabs-src">rm -rf ~/.zeroclaw/state/matrix/
Apply the new credentials: set access_token (secret, see §2) and device_id in your config.
Restart:
<div class="os-tabs-src">zeroclaw service start
Our own device might have been deleted: harmless; old device is gone.Failed to decrypt a room event: old messages from before the reset; unrecoverable.Matrix E2EE recovery successful: room keys restored from server backup (only if recovery_key is set; see §5I).Prevention: Don't delete the local state directory without planning a fresh login. If you need a fresh start, get new credentials first, then delete the store, then update config.
A recovery key lets ZeroClaw automatically restore room keys and cross-signing secrets from server-side backup. Device resets, crypto-store deletions, and fresh installs all recover automatically: no emoji verification, no manual key sharing.
EsTj 3yST y93F SLpB ...); copy it somewhere safe.recovery_key.Apply the recovery key to ZeroClaw:
{{#secret-config channels.matrix.<alias>.recovery_key}}
Then zeroclaw service restart. The recovery key is encrypted at rest immediately.
zeroclaw service restart
On startup you should see:
Matrix E2EE recovery successful — room keys and cross-signing secrets restored from server backup.
From now on, even if the local crypto store is deleted, ZeroClaw recovers automatically on next startup.
Matrix-channel-specific diagnostics:
<div class="os-tabs-src">RUST_LOG=zeroclaw::channels::matrix=debug zeroclaw daemon
Surfaces:
For SDK-level detail as well:
<div class="os-tabs-src">RUST_LOG=zeroclaw::channels::matrix=debug,matrix_sdk_crypto=debug zeroclaw daemon
external_peers = ["*"], tighten to explicit user IDs once verified.allowed_rooms: aliases are not resolved for the inbound allowlist (they are resolved only for outbound delivery.to).channels.matrix.reply_in_thread is true (default), every bot reply lives in a thread rooted at the user's message. Top-level user messages open a fresh thread; existing threads are continued. The main room timeline only carries the user-initiated messages.[Thread root from @sender]: <root body> so the agent has the conversation that triggered the reply. Threads the bot itself started skip the preamble. Tracking is in-memory only; after a daemon restart, the next message in each active thread re-injects the preamble exactly once.channels.matrix.mention_only = true makes the bot ignore naked media uploads (no text body to mention against). When the user inline-replies to such a dropped event with a question (@bot can you see this?), ZeroClaw walks the reply's m.relates_to.m.in_reply_to.event_id, fetches the parent event, and pulls its media into the current message: the agent's vision pipeline sees the image even though the original upload was filtered out.room.send_attachment calls carry an AttachmentConfig::reply(...) with EnforceThread::Threaded when a thread anchor is present, so PDFs / images / voice notes land inside the bot's thread instead of the main timeline.[image:url|path], [file:url|path], [voice:url|path], [video:...], [audio:...] (and uppercase / [document:...] aliases) inside its reply text; ZeroClaw fetches the bytes (HTTP for http(s)://, local read otherwise) and uploads as the appropriate Matrix message event. Missing or unreadable targets are non-fatal: the channel logs a warning, drops just that marker, and appends a (note: I couldn't deliver the file at <path>.) line so the operator sees what was attempted instead of a silently-dropped reply.m.audio events carrying the org.matrix.msc3245.voice field are saved to {workspace_dir}/matrix_files/ and run through the agent's configured transcription provider so the agent gets both the transcript text and the source path. Outbound voice notes use the [voice:<url|path>] marker; ZeroClaw uploads as m.audio with the voice flag + zero-waveform set so Element renders the bubble as a voice note. See Model Providers for transcription provider setup.channels.matrix.ack_reactions (default true). When on, the bot reacts with 👀 while processing and ✅ when done. Set to false to keep rooms reaction-free.~/.zeroclaw/state/matrix/session.json (user_id + device_id + access_token + optional refresh_token). Subsequent restarts call restore_session() from that blob: no re-login. The matrix-rust-sdk SQLite crypto store lives alongside it at ~/.zeroclaw/state/matrix/store/. Once session.json exists, rotating access_token in config has no effect until the file is deleted: the saved token wins. Delete session.json to force a re-login from config values.recovery_key matches what is sealed in your account's server-side secret storage, ZeroClaw runs recovery().recover(key) on every startup, the SDK imports your existing master / self-signing / user-signing keys, and the freshly registered device is automatically signed. No bootstrap, no UIA, no key rotation. If your account doesn't yet have cross-signing set up, generate the recovery key in Element (Settings → Security & Privacy → Secure Backup) before configuring recovery_key.delivery.to should be a plain room id (!abc:server) or alias (#room:server). Older configs that wrote <sender>||<room> are tolerated: ZeroClaw extracts the last !/#-prefixed segment and warns about the malformed value.{{#streaming channel="Matrix" mode="stream_mode" path="channels.matrix.<alias>.stream_mode"}}
Matrix specifics: in partial mode, tool-execution status is shown through the same edit pipeline. In multi_message mode each paragraph posts as its own threaded message, and the split is code-fence-aware, so blank lines inside fenced blocks don't break a code block across messages.
The matrix-rust-sdk default SQLite store is single-device and assumes the local view stays in sync with the homeserver. Two failure modes break that assumption irrecoverably; ZeroClaw detects each at startup and (when password + user_id are both configured) auto-wipes ~/.zeroclaw/state/matrix/ and re-authenticates so a fresh device is created server-side.
store/ directory exists but session.json doesn't (manual cleanup, interrupted prior install, etc.). Logging in fresh on top of orphaned crypto state reproduces Duplicate one-time keys / SigningKeyChanged conflicts that don't self-heal.StateStoreDataKey::OneTimeKeyAlreadyUploaded flag set. The SDK persists this key into the state store the first time it sees a duplicate-OTK upload (per the SDK's own comment: "we forgot about some of our one-time keys. This will lead to UTDs."). It survives restarts; the only fix is wipe and re-register.device_id drift is detected but tolerated, not wiped. If channels.matrix.device_id differs from the device id stored in session.json, the channel logs a warning and honors the saved id (which is the value the homeserver actually assigned at login). Wiping on drift would create a recovery loop because auto-recovery itself generates a new id, leaving config and session permanently out of sync.
When recover() itself fails (typically MAC check for the secret storage key failed), the channel logs the homeserver's default secret-storage key id, whether the key event has passphrase info, the whitespace-stripped input length, and the full error chain: these point at which layer rejected the recovery key without leaking the value. Recovery failures are non-fatal (they don't trigger auto-wipe); the bot continues, the new device just won't be cross-signed.
If password + user_id aren't configured, auto-recovery can't run: the channel bails with an actionable error pointing at the two choices: configure them, or rm -rf ~/.zeroclaw/state/matrix/ manually.