Conversation channel
topic = "conversation:<conversation_id>"
Join it while the user has a conversation open. Only members can join;
see Socket & channels. Every member who
has joined gets every event below, the sender included, with one
exception: member:read is filtered by the workspace's read-receipt
setting.
In the SDK, poolse.realtime.conversation(id) returns this channel.
The React hooks (useMessages, useTyping, usePresence,
useMembers, useReactions, useLiveCall) join it for you.
Client → server
typing
Tell the others this user is typing. The payload is ignored; send {}.
["1", "7", "conversation:6f1c…", "typing", {}]
- No reply on success.
- The server turns these pings into
typing:startandtyping:stop(below). Send one every second or two while the user types. You never need to send a stop; the server stops on its own. - Limit: 10 per second per socket and conversation. Over the limit the reply is an error:
{ "status": "error", "response": { "reason": "rate_limited", "retry_after_ms": 412 } }
The SDK sends it through channel.sendTyping(), or signalTyping()
from useTyping.
Any other event sent on this topic is ignored, with no reply.
Server → client
| Event | When |
|---|---|
presence_state | Right after you join. |
presence_diff | Someone joins or leaves the channel. |
message:new | A message was sent, including thread replies and call-log system messages. |
message:updated | A message was edited. |
message:deleted | A message was deleted. |
reaction:added | A user added a reaction. |
reaction:removed | A user removed a reaction. |
typing:start | A user started typing. |
typing:stop | A user stopped typing or sent a message. |
member:read | A member's read cursor moved. Filtered by read-receipt settings. |
call:started | A call or room went live in this conversation. |
call:participants | Someone joined or left the live call. |
call:finished | The call or room ended. |
presence_state
The users joined to this channel right now, pushed to you once after
your join. The standard Phoenix Presence shape, keyed by poolse user id.
A user with two devices has two metas entries.
{
"2b7e…": {
"metas": [
{ "online_at": 1793952000, "external_id": "acme-alice", "phx_ref": "F2x…" }
]
}
}
| Field | Type | Meaning |
|---|---|---|
| key | uuid | The user's poolse id. |
metas[].online_at | integer | Unix seconds when that device joined. |
metas[].external_id | string | Your id for the user. |
metas[].phx_ref | string | Phoenix's id for that presence entry. |
"Online" here means has this conversation open, not is using the app. poolse has no app-wide presence.
presence_diff
{
"joins": { "9c41…": { "metas": [ { "online_at": 1793952033, "external_id": "acme-bob", "phx_ref": "F2y…" } ] } },
"leaves": {}
}
Apply leaves, then joins, to your copy of the state.
usePresence(conversationId) does this and returns online, a
Set of user ids.
message:new
The full message. It is the same shape the REST API returns.
{
"id": "8d0e…",
"tenant_id": "6578…",
"conversation_id": "6f1c…",
"sender_id": "2b7e…",
"sender_external_id": "acme-alice",
"type": "text",
"body": "ship it",
"reply_to_id": null,
"thread_root_id": null,
"reply_count": 0,
"quoted_message_id": null,
"quoted_message": null,
"mentions": [],
"reactions": {},
"attachments": [],
"edited_at": null,
"deleted_at": null,
"sequence": 185,
"inserted_at": "2026-10-27T09:14:03.112233Z",
"updated_at": "2026-10-27T09:14:03.112233Z"
}
| Field | Type | Meaning |
|---|---|---|
id | uuid | Message id. If the client supplied id when sending, it is that id. |
sender_id | uuid | null | Poolse id of the sender. null for system messages. |
sender_external_id | string | null | Your id for the sender. null for system messages. |
type | "text" | "custom" | "system" | custom bodies are JSON strings your app defines. system messages are written by poolse (call records). |
body | string | null | The text. "" for a message with attachments and no text. For system, a JSON string (see Call log). |
reply_to_id | uuid | null | The message this one replies to in a thread. |
thread_root_id | uuid | null | The thread's root message. Set on every thread reply, null on top-level messages. |
reply_count | integer | Always 0 here. A new message has no replies yet. |
quoted_message_id | uuid | null | The quoted message, if this is a quote reply. |
quoted_message | object | null | Preview of the quoted message: { id, sender_id, sender_external_id, body, deleted_at, inserted_at }. body is cut to 200 characters plus …. |
mentions | uuid[] | Poolse ids of mentioned members. |
reactions | object | Emoji → array of user ids. {} on a new message. |
attachments | object[] | { id, tenant_id, message_id, sender_id, content_type, byte_size, sha256, original_filename, status, inserted_at, updated_at }. Fetch the bytes with GET /v1/attachments/:id/download-url. |
edited_at, deleted_at | timestamp | null | null on a new message. |
sequence | integer | Per-conversation order, increasing by one with each message stored (thread replies and call logs included). |
A thread reply arrives here too. Filter on thread_root_id to show it
only in the thread view. A message:new also ends the sender's typing
indicator (see typing:stop).
message:updated
An edit. Same shape as message:new, with the new body and
edited_at set. Only body can change. reply_count here is not
reliable; keep the value you have.
message:deleted
{ "id": "8d0e…", "conversation_id": "6f1c…", "deleted_at": "2026-10-27T09:20:11.000000Z" }
The message stays in history as a tombstone. Its body is gone and its attachments are deleted. Replace it in your list with a "message deleted" row.
reaction:added / reaction:removed
{ "message_id": "8d0e…", "conversation_id": "6f1c…", "emoji": "👍", "user_id": "9c41…" }
user_id is the poolse id of the person who reacted. No event fires
when the change is a no-op, such as reacting twice with the same
emoji. To update your copy of reactions, add or remove user_id
under emoji.
typing:start / typing:stop
{ "user_id": "2b7e…", "external_id": "acme-alice" }
typing:startfires on a user's firsttypingping. Further pings while they keep typing do not repeat it.typing:stopfires 2 seconds after their last ping, or at once when they send a message.- The typist's own devices receive these too. Ignore events whose
user_idis the current user.
useTyping(conversationId) returns typing, a Set of user ids
currently typing.
member:read
A member advanced their read cursor in this conversation, through
POST /v1/conversations/:id/read or a notification's "Mark as read".
{
"user_id": "9c41…",
"conversation_id": "6f1c…",
"last_read_message_id": "8d0e…",
"last_read_at": "2026-10-27T09:15:40.000000Z"
}
Who receives it depends on the workspace's read-receipt setting (see Read receipts & unread):
| Setting | Who gets the event |
|---|---|
on (default) | Everyone joined to the channel. |
off | Only the reader's own devices. |
user | The reader's own devices, plus each member who shares their own read receipts. Only sent at all if the reader shares theirs. |
The reader always gets their own, so read state stays in sync across
their devices. Mark a message as read for a member when its sequence
is at or below the sequence of last_read_message_id.
call:started
A call was placed or a room was opened in this conversation. Show a "Join" banner.
{
"cv": 1,
"call": {
"id": "4a69…",
"conversation_id": "6f1c…",
"conversation_name": "Engineering",
"media": "video",
"kind": "call",
"state": "live",
"started_by": { "user_id": "2b7e…", "external_id": "acme-alice", "display_name": "Alice" },
"started_at": "2026-10-27T09:30:00.000Z",
"ended_at": null,
"participants": [
{ "user_id": "2b7e…", "external_id": "acme-alice", "display_name": "Alice" }
],
"ring_states": { "9c41…": "ringing" },
"video_allowed": true,
"max_participants": 16
}
}
| Field | Type | Meaning |
|---|---|---|
cv | 1 | Payload version. |
call.id | uuid | The call id. Use it with POST /v1/calls/:id/join. |
call.media | "audio" | "video" | Always "audio" when the workspace has video off. |
call.kind | "call" | "room" | A call rang the other members. A room rings nobody. |
call.state | "live" | "finished" | Always "live" here. |
call.started_by | member | Who placed it. Members here use user_id, external_id and display_name. |
call.started_at | ISO timestamp | null | When it started. |
call.ended_at | null | Always null in this event. |
call.participants | member[] | Who is in the call now. For a call, the caller is in from the start. A room starts empty and fills from the media server. |
call.ring_states | object | User id → ringing, answered, declined, or missed, for each person rung. {} for a room. |
call.video_allowed | boolean | The workspace's video setting. Hide the camera button when false. |
call.max_participants | integer | The workspace's limit on people per call (2–16). |
The same summary comes back from GET /v1/conversations/:id/call. See
Calls.
call:participants
The roster of the live call changed.
{ "cv": 1, "call_id": "4a69…", "participants": ["2b7e…", "9c41…"] }
participants is the full list of poolse user ids in the call now. It
is not a diff and has no names. Resolve names from your member list.
call:finished
The call or room is over. Drop the banner.
{
"cv": 1,
"call_id": "4a69…",
"ended_at": "2026-10-27T09:34:25.000Z",
"duration_ms": 265000,
"joined_user_ids": ["2b7e…", "9c41…"],
"reason": "finished"
}
| Field | Type | Meaning |
|---|---|---|
ended_at | ISO timestamp | When it ended. |
duration_ms | integer | null | Connected time. For a call, from the first answer; null if nobody answered. For a room, from when it opened. |
joined_user_ids | uuid[] | Everyone who was in it at any point, caller included. |
reason | string | finished, cancelled (the caller hung up while it rang), no_answer, declined_all (everyone rung declined), or busy_all (everyone rung was on another call). |
The same event also goes to each involved user's
user channel. A
call-log message follows as
message:new.
Not on this channel
- Member added or removed: there is no event here. A removed member's
own channel is closed with
phx_close; see Socket & channels. - Conversation metadata changes (
name,avatar_url,custom_data) have no event. - Inbox updates and unread counts are on the
user channel as
conversation:updated.