From matrix-expert
Expert guidance on Matrix protocol, matrix-rust-sdk, E2EE, MAS OAuth, sliding sync, and bot/client architecture. Use when: working on Matrix client/bot code, debugging token refresh races, implementing E2EE, designing multi-client architecture, dealing with large accounts (3000+ rooms), or any question involving matrix-sdk, MAS, sliding sync, or Matrix auth.
How this skill is triggered — by the user, by Claude, or both
Slash command
/matrix-expert:matrix-expertThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Deep knowledge of the Matrix ecosystem: protocol, matrix-rust-sdk, MAS OAuth, E2EE, sliding
Deep knowledge of the Matrix ecosystem: protocol, matrix-rust-sdk, MAS OAuth, E2EE, sliding sync, and the architectural patterns that make or break real-world apps.
Currency: knowledge current as of 2026-04.
matrix-rust-sdkAPI evolves quickly — verify specific symbol names against the current docs for newer SDK versions.
invalid_grant or M_UNKNOWN_TOKEN/sync/login with the device code / authorization code
flow. Access tokens are short-lived (typically 5 minutes); refresh tokens are single-use..well-known/matrix/client → org.matrix.msc2965.authentication.issuer →
fetch {issuer}/.well-known/openid-configuration for endpoints.mat_... (access) and mar_... (refresh) on MAS. Legacy Synapse issues
opaque syt_... tokens without refresh./_matrix/client/v3/refresh endpoint — both work, the SDK uses
the v3 endpoint for matrix-auth sessions.Every time you refresh, the old refresh token is INVALIDATED. If two processes (or two
Client instances in one process!) both try to refresh using the same on-disk refresh token,
one wins and the other gets invalid_grant forever. This is the #1 source of bugs in
multi-client Matrix apps.
Rules:
matrix-rust-sdk auth modelClient::builder().handle_refresh_tokens() → SDK auto-refreshes on 401. Without it, you
must call client.refresh_access_token() yourself.restore_session() takes a MatrixSession that wraps SessionTokens { access_token, refresh_token } plus identity fields (user_id, device_id). refresh_token is
Option<String> — if None, auto-refresh is disabled for that session even if the
builder enabled it.Client::set_session_callbacks(reload_cb, save_cb) — save_cb fires after refresh so you
can persist new tokens to disk. reload_cb is only used for OAuth cross-process mode,
NOT for matrix-auth refresh. So you can't rely on it to re-read disk before refresh in the
matrix-auth path.Client is internally Arc<ClientInner> — cloning is cheap; pass it around freely.Client instances in one process = two separate auth_ctx = two refresh
locks = race. Always share ONE Client per process./sync with a large account (3000+ rooms) can take minutes or time out. A full
initial sync downloads all room state, all timelines, all keys.SlidingSyncMode::Growing { batch_size, max } and the server streams room info in batches.required_state to tell the server which state events you need (m.room.name,
m.room.create, m.room.canonical_alias) — otherwise Room::name() returns None and
display_name() falls back to member names.no_timeline_limit() = don't fetch any timeline events (fast, good for room list only)."org.matrix.simplified_msc3575" in
/_matrix/client/versions → unstable_features. MAS-fronted servers (Synapse MAS,
Conduit) support it.RoomListService — the high-level APImatrix_sdk_ui::room_list_service::RoomListService is the recommended high-level API built
on top of sliding sync. It handles list lifecycle, state hydration, filters, and
room-subscription windowing for you. Use it for anything UI-shaped: room lists, dashboards,
chat clients. Drop to raw SlidingSync (skeleton at the bottom of this doc) only when you
need custom list windows, bespoke required_state, or non-UI patterns. Paired with
RoomListService, the EncryptionSyncService (also in matrix_sdk_ui) runs a dedicated
sync loop for to-device messages, key requests, and backup — see the E2EE section below.
m.cross_signing.* and
m.megolm_backup.v1 secrets from server-side secret storage. Apply it with
client.encryption().recovery().recover(&key).EncryptionSettings { auto_enable_backups: true, ... }backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure = lazy
backup fetch (downloads a key only when you try to decrypt a message and fail).
Alternative is BackupDownloadStrategy::OneShot which downloads everything at startup —
slow on large accounts.auto_enable_cross_signing: true + auto_enable_backups: true in EncryptionSettings
makes new logins automatically bootstrap cross-signing and turn on key backup.EncryptionSyncService (matrix_sdk_ui) runs a dedicated sync loop for to-device
messages, key requests, and backup — independent of your main client.sync(). Bots that
only run client.sync() sometimes miss room keys; running the encryption sync service
alongside fixes it. Element uses it by default via the RoomListService integration.matrix-sdk-state.sqlite3 etc). One writer at a time.
Sharing between processes will corrupt it. Separate processes need separate stores.sqlite_store_with_cache_path(state_path, cache_path, passphrase) so the volatile event
cache can live on fast or tmpfs storage while durable state + crypto stay on persistent
storage. Recommended for large accounts."Failed to deserialize RoomInfo" or "could not deserialize ... sqlite". Only fix is
wipe + re-sync. Always back up the store before wiping — it contains your private keys.:server.tld suffix. A v12 room ID looks like !abc...xyz
(no colon, no server). Don't assume : is always there when parsing.Diagnosis: Another process/Client already consumed the refresh token.
Fix order:
Diagnosis: sync_once() on traditional /sync with no timeout OR 30s timeout is too
short to return full state for 3000+ rooms.
Fix: Replace sync_once() with sliding sync. Batch of 100 rooms, loop until
summary.rooms.is_empty() or you hit maximum_number_of_rooms.
Diagnosis order:
client.sync() actually running? Check logs for sync activity.Diagnosis: Access token expired. If it happens at startup, SDK's auto-refresh wasn't enabled, or the refresh token was also dead.
Fix: Build client with .handle_refresh_tokens() AND pass refresh_token in
SessionTokens AND set save_session_callback to persist new tokens to disk.
Diagnosis: Bot's device was never verified. Element treats it as an untrusted device and won't share room keys with it.
Fix: After login, call client.encryption().recovery().recover(&recovery_key).await?.
This downloads cross-signing keys from server secret storage. Now the bot's device can be
cross-signed, and Element will trust it.
One Matrix Client per process, and prefer one process. Client is
Arc<ClientInner> internally — clone is free, pass it around. The TUI, the bot, the
background indexer all share ONE client. Separate processes mean separate crypto
stores, separate refresh locks, separate bugs; spawn tasks on the shared client instead.
Token persistence: disk is truth, memory is cache. Before any refresh, reload from disk. After any refresh, atomic write to disk.
Don't trust sync_once() on large accounts. Always use sliding sync (or
RoomListService) for the initial population.
Provide a clear re-auth path. When a refresh token is truly revoked, the user must be able to run OAuth again without wiping the crypto store. The crypto store survives token changes — only the session is dead.
PID lockfile to prevent two instances racing on the same config dir. fs4::FileExt
advisory lock is sufficient. (fs4 is the maintained fork of the deprecated fs2 —
crates.io search still surfaces fs2 first; don't pick that one.)
Device verification is a one-time setup. On first login, auto-bootstrap cross-signing and auto-enable key backup. Tell the user "enter your recovery key" once, then never again.
Crypto store corruption is not the user's fault. Detect it by error string
("deserialize" + "RoomInfo"/"sqlite"), back it up (don't delete), tell the user
to re-auth.
Client::builder()
.homeserver_url(url)
.sqlite_store(path, None)
.handle_refresh_tokens() // auto-refresh on 401
.with_encryption_settings(EncryptionSettings {
auto_enable_cross_signing: true,
backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure,
auto_enable_backups: true,
})
.build()
.await?;
// After restore_session — clone `path` / `base_config` into each closure separately,
// otherwise the first closure moves them and the second won't compile.
client.set_session_callbacks(
Box::new({
let path = path.clone();
move |_client| {
// Reload session from disk.
// Note: NOT called for matrix-auth refresh (only OAuth cross-process).
// Keep it correct anyway.
let cfg = Config::load(&path)?;
Ok(SessionTokens {
access_token: cfg.access_token,
refresh_token: cfg.refresh_token, // Option<String>
})
}
}),
Box::new({
let path = path.clone();
let base_config = base_config.clone();
move |client| {
// Persist refreshed tokens to disk. CALLED BY SDK after refresh.
let tokens = client.session_tokens().ok_or("no tokens")?;
let mut cfg = base_config.clone();
cfg.access_token = tokens.access_token;
cfg.refresh_token = tokens.refresh_token;
cfg.save(&path)?;
Ok(())
}
}),
)?;
use matrix_sdk::sliding_sync::{SlidingSyncList, SlidingSyncMode, Version};
use matrix_sdk::ruma::events::StateEventType;
let list = SlidingSyncList::builder("rooms")
.sync_mode(SlidingSyncMode::Growing {
batch_size: 100,
maximum_number_of_rooms_to_fetch: None,
})
.no_timeline_limit()
.required_state(vec![
(StateEventType::RoomName, "".to_owned()),
(StateEventType::RoomCreate, "".to_owned()),
(StateEventType::RoomCanonicalAlias, "".to_owned()),
]);
let ss = client.sliding_sync("init")?.version(Version::Native).add_list(list).build().await?;
let stream = ss.sync();
futures::pin_mut!(stream);
loop {
match tokio::time::timeout(Duration::from_secs(60), stream.next()).await {
Ok(Some(Ok(summary))) => {
// Check progress
if let Some(max) = ss.on_list("init", |l| async move { l.maximum_number_of_rooms() }).await.flatten() {
if client.joined_rooms().len() as u32 >= max { break; }
}
if summary.rooms.is_empty() { break; }
}
Ok(Some(Err(e))) => bail!("sliding sync error: {e}"),
Ok(None) => break,
Err(_) => bail!("sliding sync timeout"),
}
}
m.push_rules (override rules .m.rule.contains_user_name /
.m.rule.contains_display_name) rather than substring-matching the body. Intentional
mentions (m.mentions in event content, MSC3952) are the modern path — prefer those when
the server supports them.ruma as a direct dep. Always use matrix_sdk::ruma re-exports. Pulling
ruma directly pins a different version than the SDK expects and produces
"expected ruma::X, found ruma::X" type errors that look identical but are two
different types.http://127.0.0.1:<port>/callback and register that exact URI
with MAS. http://localhost sometimes fails where 127.0.0.1 succeeds, depending on
MAS client config.matrix-rust-sdk moves fast — pin it in any bug report.)expires_in in token responseClient instances? (grep for Client::builder, restore_session)required_state hints set for sliding sync?recover() called)? Is EncryptionSyncService running?org.matrix.simplified_msc3575 in unstable_features?Provides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.
npx claudepluginhub yncyrydybyl/matrix-skill --plugin matrix-expert