docs/book/src/channels/matrix.md
Run ZeroClaw in Matrix rooms, including end-to-end encrypted (E2EE) rooms.
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:
allowed_users (for testing: ["*"]).whoami check — see §5C).device_id) and key sharing.Before testing message flow:
room_id is correct:
!room:server)#alias:server) — ZeroClaw resolves itallowed_users allows the sender (["*"] for open testing).All config management goes through zeroclaw config or zeroclaw onboard. Do not hand-edit ~/.zeroclaw/config.toml.
Easiest: run the wizard and let it prompt for every Matrix field:
zeroclaw onboard --channels-only
Or set individual fields after onboarding:
zeroclaw config set channels.matrix.homeserver https://matrix.example.com
zeroclaw config set channels.matrix.room-id '!room:matrix.example.com'
zeroclaw config set channels.matrix.access-token # prompts, input masked
zeroclaw config set channels.matrix.user-id @bot:matrix.example.com
zeroclaw config set channels.matrix.device-id ABCDEF1234
zeroclaw config set channels.matrix.allowed-users '["*"]' # open for testing
zeroclaw config set channels.matrix.ack-reactions true # default: true (👀 → ✅)
zeroclaw config set channels.matrix.reply-in-thread true # default: true
Required: homeserver, access-token, room-id. Strongly recommended for E2EE: user-id and device-id. For the full field index, see the Config reference.
Don't have an
access-tokenyet? See §3 below — it walks through the Matrix password-login API call that mints a token plus a stabledevice_idin one shot. If you only need to look updevice_idfor a token you already have, see §5H.
user-id and device-id/_matrix/client/v3/account/whoami.whoami doesn't return device_id, set device-id manually — critical for E2EE session restore.access-token and device-idBrand-new bot accounts need a Matrix access token before ZeroClaw can connect. Element doesn't expose the token directly, so the canonical path is a one-shot password-login API call that returns both the access token and a stable device ID together.
If your operator account already has a token (e.g. you copied it from another deployment), 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"}
zeroclaw config set channels.matrix.access-token # paste the access_token (input is masked)
zeroclaw config set channels.matrix.device-id NEWDEVICE
zeroclaw config set channels.matrix.user-id @bot:example.com
zeroclaw service restart
The wizard (zeroclaw onboard --channels-only) prompts for these same fields if you'd rather work through it interactively.
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.zeroclaw config set channels.matrix.access-token # prompts, masked
zeroclaw service restart
zeroclaw onboard --channels-only
zeroclaw service restart # or `zeroclaw daemon` to run foreground
Send a plain-text message in the configured Matrix room. Confirm:
Work through in order.
#...), verify it resolves to the expected canonical room.allowed_users = [], all inbound messages are denied.zeroclaw config set channels.matrix.allowed-users '["*"]'
zeroclaw service restart
About
$MATRIX_TOKENin the snippets below. Secrets in ZeroClaw are encrypted at rest and intentionally not retrievable viazeroclaw config get— it prints[masked]for any secret field. You have two options:
- Get a fresh token by re-running the password-login curl from §3 Step 1. Export the
access_tokenit returns. Good for validation and recovery paths — doesn't affect what's in your config.- Keep a copy of the token when you first paste it into
zeroclaw onboardorzeroclaw config set channels.matrix.access-token. A one-time side-effect — write it to a scratch note if you want to run these curl checks later.The non-secret fields are retrievable:
bashMATRIX_HOMESERVER=$(zeroclaw config get channels.matrix.homeserver) MATRIX_USER=$(zeroclaw config get channels.matrix.user-id)
With MATRIX_TOKEN set, validate the token server-side:
curl -sS -H "Authorization: Bearer $MATRIX_TOKEN" \
"$MATRIX_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).zeroclaw config set channels.matrix.access-token # prompts, masked
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 tokenUse this when you already have an access token (e.g. inherited from another deployment) and need to look up its device_id. For brand-new bots, see §3 — the password-login flow there returns both values together.
ZeroClaw needs a stable device_id for E2EE session restore. Without it, a new device is registered every restart, breaking key sharing and device verification.
whoami (easiest)curl -sS -H "Authorization: Bearer $MATRIX_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.
zeroclaw config set channels.matrix.device-id ABCDEF1234
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.
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:
rm -rf ~/.zeroclaw/state/matrix/
Apply the new credentials:
zeroclaw config set channels.matrix.access-token <new_token>
zeroclaw config set channels.matrix.device-id <new_device_id>
Restart:
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 ....Either path works. The onboarding wizard is easier for fresh installs; zeroclaw config set is preferred for existing installs.
Option A — during onboarding:
zeroclaw onboard --channels-only
When prompted:
E2EE recovery key (or Enter to skip): EsTj 3yST y93F SLpB jJsz ...
Input is masked. The key is encrypted at rest.
Option B — existing installs:
zeroclaw config set channels.matrix.recovery-key # input masked
zeroclaw service restart
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:
RUST_LOG=zeroclaw::channels::matrix=debug zeroclaw daemon
Surfaces:
For SDK-level detail as well:
RUST_LOG=zeroclaw::channels::matrix=debug,matrix_sdk_crypto=debug zeroclaw daemon
allowed_users, tighten to explicit user IDs once verified.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 [transcription] 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. Default transcription provider is Groq's hosted Whisper API — set transcription.default-provider = "local_whisper" and transcription.local-whisper.url for fully on-device transcription.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.channels.matrix.stream-mode):
off (default) — reply posts as a single message once the agent finishes.partial — initial draft posted immediately, edited in place every draft-update-interval-ms as the agent generates output. Tool-execution status is shown by the same edit pipeline.multi_message — no initial draft. Each \n\n-bounded paragraph posts as its own threaded message, separated by multi-message-delay-ms. Code-fence-aware: blank lines inside fenced blocks aren't treated as paragraph breaks.~/.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.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.