Problem
Bridge authors (XMPP, Matrix, Discord, etc.) cannot deliver DMs in
real-time through riverctl. The only mechanism to read DMs is
riverctl dm list — a blocking fetch-and-exit command. There is no
streaming equivalent of riverctl message stream for the
direct_messages sub-state.
This means any bridge built on riverctl must either:
- Poll
dm list on a timer (wasteful, adds latency, complexity
of task lifecycle management)
- Require the user to manually trigger a fetch (poor UX — the
user must explicitly "check for DMs" instead of receiving them as
they arrive)
Current workaround
The riverjabber XMPP gateway (a slidge-based bridge) currently
requires the user to run a check-dms chat command to fetch DMs on
demand. This works but is cumbersome — it feels like "checking your
inbox" rather than chat.
Proposed solution
A riverctl dm stream subcommand that outputs JSONL events when new
DMs arrive in a room, following the same pattern as riverctl message stream:
riverctl dm stream <ROOM_ID> [--subscribe] [--format json] [--initial-messages N]
Output format: JSONL, one event per DM, with fields matching the
existing dm list --format json output:
{
"counterparty": "MAOKOLK5",
"counterparty_nickname": "alice",
"direction": "incoming",
"body": "hello there",
"purge_token": "aabbccdd11223344aabbccdd11223344",
"timestamp": "2026-07-31T14:22:00+00:00",
"timestamp_unix": 1785511320,
"is_invitation": false
}
The --initial-messages N flag (matching message stream's
equivalent) would emit the last N DMs on startup, so bridges can
backfill recent history when they connect.
This is a CLI-only change — no protocol or node changes needed
The subscription mechanism in subscribe_and_stream()
(cli/src/api.rs) already receives UpdateNotification events for
the entire room contract state, which includes
direct_messages. On each notification, it re-fetches the full
ChatRoomStateV1 via get_room() — this state already contains
direct_messages. The current code simply processes only
recent_messages from that state and ignores direct_messages.
A dm stream command would use the same ContractRequest::Subscribe
mechanism, the same UpdateNotification handling, and the same
get_room() re-fetch — it would just process
room_state.direct_messages instead of
room_state.recent_messages. The node, the contract, and the
protocol are unaffected.
Impact
Every bridge built on riverctl would benefit. Without this, DMs on
any CLI-bridged client require manual fetches or polling. With it,
DMs flow naturally as they arrive — same experience as the River
webUI, which already gets real-time DM updates via the same
subscription mechanism.
References
subscribe_and_stream() — cli/src/api.rs:5284 (subscribes to
room contract, re-fetches full state on each UpdateNotification,
currently processes only recent_messages)
dm list JSON output — cli/src/commands/dm.rs:759-787
direct_messages in ChatRoomStateV1 —
common/src/room_state/direct_messages.rs
Problem
Bridge authors (XMPP, Matrix, Discord, etc.) cannot deliver DMs in
real-time through riverctl. The only mechanism to read DMs is
riverctl dm list— a blocking fetch-and-exit command. There is nostreaming equivalent of
riverctl message streamfor thedirect_messagessub-state.This means any bridge built on riverctl must either:
dm liston a timer (wasteful, adds latency, complexityof task lifecycle management)
user must explicitly "check for DMs" instead of receiving them as
they arrive)
Current workaround
The riverjabber XMPP gateway (a slidge-based bridge) currently
requires the user to run a
check-dmschat command to fetch DMs ondemand. This works but is cumbersome — it feels like "checking your
inbox" rather than chat.
Proposed solution
A
riverctl dm streamsubcommand that outputs JSONL events when newDMs arrive in a room, following the same pattern as
riverctl message stream:Output format: JSONL, one event per DM, with fields matching the
existing
dm list --format jsonoutput:{ "counterparty": "MAOKOLK5", "counterparty_nickname": "alice", "direction": "incoming", "body": "hello there", "purge_token": "aabbccdd11223344aabbccdd11223344", "timestamp": "2026-07-31T14:22:00+00:00", "timestamp_unix": 1785511320, "is_invitation": false }The
--initial-messages Nflag (matchingmessage stream'sequivalent) would emit the last N DMs on startup, so bridges can
backfill recent history when they connect.
This is a CLI-only change — no protocol or node changes needed
The subscription mechanism in
subscribe_and_stream()(
cli/src/api.rs) already receivesUpdateNotificationevents forthe entire room contract state, which includes
direct_messages. On each notification, it re-fetches the fullChatRoomStateV1viaget_room()— this state already containsdirect_messages. The current code simply processes onlyrecent_messagesfrom that state and ignoresdirect_messages.A
dm streamcommand would use the sameContractRequest::Subscribemechanism, the same
UpdateNotificationhandling, and the sameget_room()re-fetch — it would just processroom_state.direct_messagesinstead ofroom_state.recent_messages. The node, the contract, and theprotocol are unaffected.
Impact
Every bridge built on riverctl would benefit. Without this, DMs on
any CLI-bridged client require manual fetches or polling. With it,
DMs flow naturally as they arrive — same experience as the River
webUI, which already gets real-time DM updates via the same
subscription mechanism.
References
subscribe_and_stream()—cli/src/api.rs:5284(subscribes toroom contract, re-fetches full state on each
UpdateNotification,currently processes only
recent_messages)dm listJSON output —cli/src/commands/dm.rs:759-787direct_messagesinChatRoomStateV1—common/src/room_state/direct_messages.rs