How a client receives live chat events over a single WebSocket, and the full event catalogue.
Yama exposes one WebSocket endpoint (Rails Action Cable) at /cable. You authenticate with the same user JWT as the REST API, passed as a token query parameter (a browser cannot set headers on a WebSocket, so the token goes in the URL):
wss://yama.modulotech.fr/cable?token=<jwt>
wss://yama.modulotech.fr/cable · Staging: wss://staging.yama.modulotech.fr/cable. Always over wss:// (TLS). An invalid, expired or revoked token (after logout) is refused at the handshake.After the handshake, subscribe to a single channel — UserChannel. There are no parameters: the server already knows who you are from the token, and pushes every event relevant to you onto this one stream (messages from your rooms, status changes, unread counts, mutes, membership changes, read receipts). Using the Action Cable protocol directly:
// 1. subscribe ws.send(JSON.stringify({ command: "subscribe", identifier: JSON.stringify({ channel: "UserChannel" }) })); // 2. every event arrives as a "message" frame; the payload is in .message ws.onmessage = (e) => { const frame = JSON.parse(e.data); if (frame.type) return; // ping / confirm / reject envelopes const event = frame.message; // { "type": "...", ... } switch (event.type) { case "message_created": /* ... */ break; case "unread_updated": /* ... */ break; // ... } };
With a native Action Cable client (e.g. the actioncable JS package or a Swift/Kotlin port), just point the consumer at the URL above and subscribe to { channel: "UserChannel" }; each event is delivered to your received(data) callback.
Every event is a JSON object with a type field, event-specific fields, and a per-recipient muted boolean. Treat unknown types as no-ops (forward-compatible).
muted (on every event) is computed for you: true when your account is muted, or — for a room-scoped event — that room is muted for you. Use it to decide whether to surface a notification (banner/sound). State-sync events (status, membership, unread…) carry it too for consistency; you can ignore it there.A new message was posted in a room you belong to (you also receive your own, for multi-device sync). The muted flag is computed for you: it is true when this room is muted for you or your account is muted — i.e. don't surface a notification (banner/sound) when true. The unread count still updates via a separate unread_updated.
{
"type": "message_created",
"muted": false, // true -> suppress the notification for this recipient
"message": {
"id": "uuid", "room_id": "uuid", "user_id": "uuid",
"content": "Hello", "created_at": "2026-06-19T10:00:00Z",
"file": { "filename": "photo.jpg", "url": "/rails/active_storage/..." } // or null
}
}
A room member (possibly you, on another device) read messages up to a given one. Use it for "seen" indicators. The reader's own unread count is refreshed via a separate unread_updated.
{
"type": "message_read",
"room_id": "uuid",
"up_to_message_id": "uuid", // this and all earlier messages are read
"user_id": "uuid", // who read
"read_at": "2026-06-19T10:01:00Z",
"muted": false
}
Your unread count for a room changed (a message arrived, or you read/unread some). Authoritative — replace your local counter with this value.
{ "type": "unread_updated", "room_id": "uuid", "unread_count": 3, "muted": false }
A user you share a room with (or you, on another device) changed availability. status is one of connected, disconnected, unknown, muted.
{ "type": "status_changed", "user_id": "uuid", "status": "connected", "muted": false }
muted here reflects your account mute only (this event is not tied to a room).
You changed the per-room mute (e.g. from another device). muted_until is the expiry of a timed mute, or null when unmuted or muted with no end.
{ "type": "room_muted", "room_id": "uuid", "muted": true, "muted_until": "2026-06-19T12:00:00Z" }
{ "type": "room_unmuted", "room_id": "uuid", "muted": false, "muted_until": null }
Note: the account-wide mute is conveyed as a status_changed with status: "muted", not as a room event.
You were added to a room (you joined, or an admin added you). Add it to your room list.
{ "type": "room_added", "room": { "id": "uuid", "name": "Support", "slug": "support" }, "muted": false }
You were removed from a room (you left, or an admin removed you). Drop it from your room list.
{ "type": "room_removed", "room_id": "uuid", "muted": false }
Another user joined or left a room you belong to. Update that room's member list / presence.
{ "type": "member_joined", "room_id": "uuid", "user_id": "uuid", "muted": false }
{ "type": "member_left", "room_id": "uuid", "user_id": "uuid", "muted": false }
| type | when | who receives it |
|---|---|---|
message_created | message posted | all room members |
message_read | member reads messages | all room members |
unread_updated | your unread count changes | you |
status_changed | availability/account-mute changes | you + room co-members |
room_muted / room_unmuted | you mute/unmute a room | you (all devices) |
room_added | you are added to a room | you |
room_removed | you are removed from a room | you |
member_joined / member_left | another user joins/leaves your room | other room members |
GET /api/v1/rooms, GET /api/v1/rooms/:slug/messages) and treat events as incremental updates afterwards.unread_updated carries the authoritative count — prefer it over incrementing locally.muted_until passes, even without a room_unmuted event. You can expire it locally too.