Matrix
Run ZeroClaw in Matrix rooms, including end-to-end encrypted (E2EE) rooms.
Who can talk to the agent
Inbound senders are gated against the peer set resolved for the bound
agent, drawn from the peer_groups config the agent belongs to. Matching strips
a leading @ and is case-insensitive against the channel’s native sender
identifier. An empty set denies everyone; a set containing "*" accepts
anyone; otherwise only the listed external peers (and peer agents) are accepted.
This is separate from gateway pairing (gateway.require_pairing), which
authenticates HTTP/WebSocket clients, not chat-channel senders.
A peer group for matrix sets channel to matrix, lists the allowed senders in
external_peers (for matrix, the full Matrix user ID, @user:server.tld; ["*"] accepts anyone), optionally
names peer agents for cross-agent dispatch, an ignore blocklist, and an
output_modality (mirror, voice, or text). See Peer Groups
for the field reference.
Where to set this:
Gateway dashboard
Open /config/peer_groups in the web dashboard.
zerocode
In the Config pane, under Peer groups.
Common failure mode this guide targets:
“Matrix is configured correctly, checks pass, but the bot does not respond.”
Fast FAQ
If Matrix appears connected but there’s no reply, validate these first:
- Sender is in the agent’s peer set (for testing:
external_peers = ["*"]). - Bot account has joined the exact target room.
- Credentials belong to the bot account (
whoamicheck on the token path, see §5C). - Encrypted room can be decrypted:
recovery_keyset (recommended) or keys shared to the bot device. - Daemon was restarted after config changes.
1. Requirements
Before testing message flow:
- The bot account is joined to the target room.
- Credentials authenticate the bot account: either
user_id+password(recommended, see §2) or anaccess_token(token path, §3). allowed_roomsincludes 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:serverentry for this allowlist. (Aliases are resolved only for outbound delivery targets such as crondelivery.to.) Find a room’s canonical ID in its client (in Element: Room settings → Advanced → Internal room ID).- A peer group authorizes the sender (
external_peers = ["*"]for open testing, see §6). - For E2EE rooms, the bot can decrypt: a
recovery_key(recommended) restores keys automatically, or keys are shared to the bot device manually.
2. Configuration
access_token 🔑
Matrix access token for the bot account. When unset, the channel falls back to password login using user_id + password.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.access_token field.
zerocode
In the Config pane, set the channels.matrix.<alias>.access_token field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.access_token # masked input, stored encrypted
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__access_token=
ack_reactions
Override for the top-level [channels].ack_reactions. When None, falls back to the channels-wide default. When set explicitly (true/false), takes precedence for this Matrix instance only.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.ack_reactions field.
zerocode
In the Config pane, set the channels.matrix.<alias>.ack_reactions field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.ack_reactions <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__ack_reactions=
allowed_rooms
Allowed Matrix room IDs. Empty = allow all rooms the bot has joined. Entries are matched literally against the canonical room ID (!abc:server) of each incoming message; #room:server aliases are not resolved for this allowlist (they are resolved only for outbound delivery targets such as cron delivery.to).
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.allowed_rooms field.
zerocode
In the Config pane, set the channels.matrix.<alias>.allowed_rooms field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.allowed_rooms <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__allowed_rooms=
approval_timeout_secs
Seconds to wait for operator approval on always_ask tools before auto-denying.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.approval_timeout_secs field.
zerocode
In the Config pane, set the channels.matrix.<alias>.approval_timeout_secs field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.approval_timeout_secs <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__approval_timeout_secs=
device_id
Optional Matrix device ID.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.device_id field.
zerocode
In the Config pane, set the channels.matrix.<alias>.device_id field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.device_id <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__device_id=
draft_update_interval_ms
Minimum interval (ms) between draft message edits in Partial mode.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.draft_update_interval_ms field.
zerocode
In the Config pane, set the channels.matrix.<alias>.draft_update_interval_ms field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.draft_update_interval_ms <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__draft_update_interval_ms=
excluded_tools
Tools excluded from this channel’s tool spec. When set, these tools are not exposed to the model when responding via this channel.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.excluded_tools field.
zerocode
In the Config pane, set the channels.matrix.<alias>.excluded_tools field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.excluded_tools <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__excluded_tools=
homeserver*
Matrix homeserver URL (e.g. "https://matrix.org").
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.homeserver field.
zerocode
In the Config pane, set the channels.matrix.<alias>.homeserver field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.homeserver <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__homeserver=
interrupt_on_new_message
Whether to interrupt an in-flight agent response when a new message arrives.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.interrupt_on_new_message field.
zerocode
In the Config pane, set the channels.matrix.<alias>.interrupt_on_new_message field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.interrupt_on_new_message <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__interrupt_on_new_message=
mention_only
When true, only respond to messages that @-mention the bot in groups. Direct messages are always processed.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.mention_only field.
zerocode
In the Config pane, set the channels.matrix.<alias>.mention_only field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.mention_only <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__mention_only=
multi_message_delay_ms
Delay (ms) between sending each paragraph in MultiMessage mode.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.multi_message_delay_ms field.
zerocode
In the Config pane, set the channels.matrix.<alias>.multi_message_delay_ms field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.multi_message_delay_ms <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__multi_message_delay_ms=
password 🔑
Optional login password for Matrix account (used for initial login flow).
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.password field.
zerocode
In the Config pane, set the channels.matrix.<alias>.password field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.password # masked input, stored encrypted
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__password=
recovery_key 🔑
Optional Matrix recovery key for automatic E2EE key backup restore. When set, ZeroClaw recovers room keys and cross-signing secrets on startup.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.recovery_key field.
zerocode
In the Config pane, set the channels.matrix.<alias>.recovery_key field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.recovery_key # masked input, stored encrypted
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__recovery_key=
reply_in_thread
When true (default), replies are sent as thread replies. Starts a new thread from the incoming message when none exists. When false, only continues existing threads.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.reply_in_thread field.
zerocode
In the Config pane, set the channels.matrix.<alias>.reply_in_thread field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.reply_in_thread <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__reply_in_thread=
reply_min_interval_secs
Per-(channel, recipient) outbound pacing floor in seconds. Range: 0..=REPLY_MIN_INTERVAL_MAX_SECS (0 disables).
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.reply_min_interval_secs field.
zerocode
In the Config pane, set the channels.matrix.<alias>.reply_min_interval_secs field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.reply_min_interval_secs <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__reply_min_interval_secs=
reply_queue_depth_max
Per-(channel, recipient) outbound pacing queue depth. Range: 0..=REPLY_QUEUE_DEPTH_CEILING. When reply_min_interval_secs > 0 and this value is 0, the pacing wrapper substitutes DEFAULT_REPLY_QUEUE_DEPTH (16). When the queue is full, the newest send is dropped and a WARN is logged.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.reply_queue_depth_max field.
zerocode
In the Config pane, set the channels.matrix.<alias>.reply_queue_depth_max field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.reply_queue_depth_max <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__reply_queue_depth_max=
stream_mode
Streaming mode for progressive response delivery. "off" (default): single message. "partial": edit-in-place draft. "multi_message": paragraph-split delivery.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.stream_mode field.
zerocode
In the Config pane, set the channels.matrix.<alias>.stream_mode field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.stream_mode <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__stream_mode=
user_id
Optional Matrix user ID (e.g. "@bot:matrix.org").
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.user_id field.
zerocode
In the Config pane, set the channels.matrix.<alias>.user_id field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.user_id <value>
Environment variable
Export the override (POSIX shells; drop into ~/.bashrc, ~/.zshrc, .env, or a Dockerfile). Replace <alias> with the literal alias:
export ZEROCLAW_channels__matrix__<alias>__user_id=
Matrix is configured as a [channels.matrix.<alias>] block. Set it through any of these surfaces:
Gateway dashboard
Open /config/channels/matrix in the web dashboard.
zerocode
In the Config pane, under Channels.
Recommended setup: password + recovery key
The official, lowest-friction way to run Matrix is to let ZeroClaw log in fresh and manage its own device identity:
- Omit
device_id. Let the homeserver assign one at login. ZeroClaw saves the assigned id tosession.jsonand reuses it on every restart, so there is no value for you to look up, copy, or keep in sync. Pinning adevice_idby hand is the single most common source of broken key sharing. - Omit
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. - Set
password. Withaccess_tokenabsent,user_id+passwordperform the login. - Set
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.
channels.matrix.<alias>.passwordis a secret. Stored encrypted, never in plainconfig.toml. Set it through one of these, which encrypt on write:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.password field there.
zerocode
In the Config pane, set the channels.matrix.<alias>.password field (input is masked).
zeroclaw config
zeroclaw config set channels.matrix.<alias>.password # prompts for masked input, stores encrypted
channels.matrix.<alias>.access_tokenis a secret. Stored encrypted, never in plainconfig.toml. Set it through one of these, which encrypt on write:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.access_token field there.
zerocode
In the Config pane, set the channels.matrix.<alias>.access_token field (input is masked).
zeroclaw config
zeroclaw config set channels.matrix.<alias>.access_token # prompts for masked input, stores encrypted
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.
About user_id and device_id
- For the recommended password + recovery-key setup, set
user_idand leavedevice_idunset: the homeserver assigns and ZeroClaw persists it. - ZeroClaw reads identity from Matrix
/_matrix/client/v3/account/whoami. - Only on the
access_tokenpath do you setdevice_idmanually: 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).
Threads and context
When a Matrix conversation happens in a thread, that thread is its own
conversation. ZeroClaw derives a distinct session key per thread, so every
thread carries an independent context window and history: messages in one
thread never bleed into another, and the agent does not see a sibling thread’s
earlier turns. For Matrix this is controlled by reply_in_thread: when it is on, top-level messages open a thread and each thread is a separate conversation; when off, replies post at the channel root and history is keyed by sender and target instead of by thread.
- Isolation is the point. Each thread’s context is self-contained: it does not leak outside the thread, and nothing from outside the thread leaks in. Parallel threads hold separate conversational state, so unrelated tasks never contaminate each other.
- Long threads grow context. A thread accumulates history while it stays active, so a very long thread eventually fills the model’s context window like any other long conversation. Start a new thread to reset.
- In-flight work is scoped per thread. A new message in one thread does not cancel an in-flight response in another; each thread’s task stands alone.
Set the thread behavior on any surface:
Gateway dashboard
Open /config/channels/matrix and toggle the channels.matrix.<alias>.reply_in_thread field.
zerocode
In the Config pane, set the channels.matrix.<alias>.reply_in_thread field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.reply_in_thread true # thread replies on
zeroclaw config set channels.matrix.<alias>.reply_in_thread false # replies at the channel root
3. Token path (alternative): obtaining 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).
Step 1: Mint a token via password login
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):
sh
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"}
Step 2: Apply both values to ZeroClaw
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.
Notes
- Keep a copy of the token when you first paste it. Secrets are encrypted at rest and
zeroclaw config getwill 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. - Reuse the same
device_idon 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. - Rotating the access token later without re-running the wizard: update the
access_tokenfield in your config (see §2), thenzeroclaw service restart. - Token shows as expired or invalid at startup: mint a new one with the same curl, repeat Step 2.
4. Quick validation
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:
- ZeroClaw logs show the Matrix listener starting with no repeated sync/auth errors.
- In an encrypted room, the bot can read and reply to encrypted messages from allowed users.
5. Troubleshooting “no response”
Work through in order.
A. Room and membership
- Confirm the bot account has joined the room.
- If you put a room in
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.
B. Sender allowlist (peer groups)
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.
C. Token and identity
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:
sh
curl -sS -H "Authorization: Bearer <access_token>" \
"https://your.homeserver/_matrix/client/v3/account/whoami"
- Returned
user_idmust match the bot account. - If
device_idis missing from the response, set it manually (see §5H). - Rotate the access token: update the
access_tokenfield in your config (see §2), thenzeroclaw service restart.
D. E2EE-specific checks
- The bot device must have received room keys from trusted devices.
- If keys haven’t been shared to this device, encrypted events cannot be decrypted.
- Verify device trust and key sharing from a trusted Matrix session.
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).- If recipients see bot messages as “unverified”, verify/sign the bot device from a trusted Matrix session and keep
device_idstable across restarts.
E. Log levels
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:
sh
RUST_LOG=info,matrix_sdk=info,matrix_sdk_base=info,matrix_sdk_crypto=info zeroclaw daemon
F. Message formatting (Markdown)
- ZeroClaw sends Matrix replies as markdown-capable
m.room.messagetext content. - Matrix clients that support
formatted_bodyrender emphasis, lists, and code blocks. - If formatting appears as plain text: check client capability first, then confirm ZeroClaw is running a build with markdown-enabled Matrix output.
G. Fresh start test
After config changes, restart the daemon and send a new message. Old timeline history won’t be replayed.
H. Finding device_id for an existing token
You 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.
Option 1: whoami (easiest)
sh
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.
Option 2: From Element or another Matrix client
- Log in as the bot account in Element.
- Settings → Sessions.
- Copy the Device ID for the active session.
- Set
device_idin your config (see §2), thenzeroclaw service restart. Keepdevice_idstable: changing it forces a new device registration, which breaks existing key sharing and verification.
H (continued). Crypto-store deletion recovery
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.
Fix: fresh login
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.
sh
zeroclaw service stop -
Get a fresh access token and
device_id:sh
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_tokenanddevice_id. -
Delete the local crypto store:
sh
rm -rf ~/.zeroclaw/state/matrix/ -
Apply the new credentials: set
access_token(secret, see §2) anddevice_idin your config. -
Restart:
sh
zeroclaw service start
What to expect on first restart
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 ifrecovery_keyis set; see §5I).- New messages decrypt and work normally.
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.
I. Recovery key (recommended for E2EE)
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.
Step 1: Get your recovery key from Element
- Log into the bot account in Element (web or desktop).
- Settings → Security & Privacy → Encryption → Secure Backup.
- If backup is already set up, your recovery key was shown when you first enabled it. If you saved it, use that.
- If backup isn’t set up, click “Set up Secure Backup” → “Generate a Security Key”. Element shows the key (it looks like
EsTj 3yST y93F SLpB ...); copy it somewhere safe. - Continue past the key display: Element then asks you to re-enter the key in a confirmation box to prove you saved it. Paste it and continue to finish setup. This is the same value you put in
recovery_key. - (Optional) Log out of the bot’s Element session once the key is saved: click the account menu → All settings → Account, then Remove this device. Leaving it logged in is fine; removing it just keeps the device list tidy.
Step 2: Add the recovery key to ZeroClaw
Apply the recovery key to ZeroClaw:
channels.matrix.<alias>.recovery_keyis a secret. Stored encrypted, never in plainconfig.toml. Set it through one of these, which encrypt on write:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.recovery_key field there.
zerocode
In the Config pane, set the channels.matrix.<alias>.recovery_key field (input is masked).
zeroclaw config
zeroclaw config set channels.matrix.<alias>.recovery_key # prompts for masked input, stores encrypted
Then zeroclaw service restart. The recovery key is encrypted at rest immediately.
Step 3: Restart
sh
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.
6. Debug logging
Matrix-channel-specific diagnostics:
sh
RUST_LOG=zeroclaw::channels::matrix=debug zeroclaw daemon
Surfaces:
- Session restore confirmation
- Each sync cycle completion
- OTK conflict flag state
- Health check results
- Transient vs. fatal sync error classification
For SDK-level detail as well:
sh
RUST_LOG=zeroclaw::channels::matrix=debug,matrix_sdk_crypto=debug zeroclaw daemon
7. Operational notes
- Keep Matrix tokens out of logs and screenshots.
- Start with permissive
external_peers = ["*"], tighten to explicit user IDs once verified. - Always use canonical room IDs in
allowed_rooms: aliases are not resolved for the inbound allowlist (they are resolved only for outbounddelivery.to). - Threading: when
channels.matrix.reply_in_threadistrue(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 context: the first inbound message ZeroClaw sees in any given thread is prefixed with
[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. - Inline-reply media:
channels.matrix.mention_only = truemakes 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’sm.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. - Attachments thread alongside text:
room.send_attachmentcalls carry anAttachmentConfig::reply(...)withEnforceThread::Threadedwhen a thread anchor is present, so PDFs / images / voice notes land inside the bot’s thread instead of the main timeline. - Outbound media markers: the agent emits
[image:url|path],[file:url|path],[voice:url|path],[video:...],[audio:...](and uppercase /[document:...]aliases) inside its reply text; ZeroClaw fetches the bytes (HTTP forhttp(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. - Voice messages (MSC3245): inbound
m.audioevents carrying theorg.matrix.msc3245.voicefield 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 asm.audiowith the voice flag + zero-waveform set so Element renders the bubble as a voice note. See Model Providers for transcription provider setup. - Acknowledgement reactions: controlled by
channels.matrix.ack_reactions(defaulttrue). When on, the bot reacts with 👀 while processing and ✅ when done. Set tofalseto keep rooms reaction-free. - Persistent sessions: on first successful login, ZeroClaw writes
~/.zeroclaw/state/matrix/session.json(user_id + device_id + access_token + optional refresh_token). Subsequent restarts callrestore_session()from that blob: no re-login. The matrix-rust-sdk SQLite crypto store lives alongside it at~/.zeroclaw/state/matrix/store/. Oncesession.jsonexists, rotatingaccess_tokenin config has no effect until the file is deleted: the saved token wins. Deletesession.jsonto force a re-login from config values. - Cross-signing: when
recovery_keymatches what is sealed in your account’s server-side secret storage, ZeroClaw runsrecovery().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 configuringrecovery_key. - Cron delivery:
delivery.toshould 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
Matrix streams replies via the stream_mode setting:
off(default): the whole reply posts as one message once the agent finishes. Simplest, and it never shows a half-written answer.partial: the bot posts a draft immediately and edits it in place as the answer streams in.draft_update_interval_mspaces the edits; raise it if Matrix rate-limits them.multi_message: each paragraph posts as its own message, separated bymulti_message_delay_ms. Good for long answers that would otherwise be one wall of text.
Set it on any surface:
Gateway dashboard
Open /config/channels/matrix and set the channels.matrix.<alias>.stream_mode field.
zerocode
In the Config pane, set the channels.matrix.<alias>.stream_mode field.
zeroclaw config
zeroclaw config set channels.matrix.<alias>.stream_mode <value>
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.
8. Auto-recovery from corrupted local state
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.
- Orphan crypto state. A
store/directory exists butsession.jsondoesn’t (manual cleanup, interrupted prior install, etc.). Logging in fresh on top of orphaned crypto state reproducesDuplicate one-time keys/SigningKeyChangedconflicts that don’t self-heal. StateStoreDataKey::OneTimeKeyAlreadyUploadedflag 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.
See also
- Network deployment
- Config reference: generated from the live schema
- Channels overview