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:start and typing: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

EventWhen
presence_stateRight after you join.
presence_diffSomeone joins or leaves the channel.
message:newA message was sent, including thread replies and call-log system messages.
message:updatedA message was edited.
message:deletedA message was deleted.
reaction:addedA user added a reaction.
reaction:removedA user removed a reaction.
typing:startA user started typing.
typing:stopA user stopped typing or sent a message.
member:readA member's read cursor moved. Filtered by read-receipt settings.
call:startedA call or room went live in this conversation.
call:participantsSomeone joined or left the live call.
call:finishedThe 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…" }
    ]
  }
}
FieldTypeMeaning
keyuuidThe user's poolse id.
metas[].online_atintegerUnix seconds when that device joined.
metas[].external_idstringYour id for the user.
metas[].phx_refstringPhoenix'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"
}
FieldTypeMeaning
iduuidMessage id. If the client supplied id when sending, it is that id.
sender_iduuid | nullPoolse id of the sender. null for system messages.
sender_external_idstring | nullYour 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).
bodystring | nullThe text. "" for a message with attachments and no text. For system, a JSON string (see Call log).
reply_to_iduuid | nullThe message this one replies to in a thread.
thread_root_iduuid | nullThe thread's root message. Set on every thread reply, null on top-level messages.
reply_countintegerAlways 0 here. A new message has no replies yet.
quoted_message_iduuid | nullThe quoted message, if this is a quote reply.
quoted_messageobject | nullPreview of the quoted message: { id, sender_id, sender_external_id, body, deleted_at, inserted_at }. body is cut to 200 characters plus ….
mentionsuuid[]Poolse ids of mentioned members.
reactionsobjectEmoji → array of user ids. {} on a new message.
attachmentsobject[]{ 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_attimestamp | nullnull on a new message.
sequenceintegerPer-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:start fires on a user's first typing ping. Further pings while they keep typing do not repeat it.
  • typing:stop fires 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_id is 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):

SettingWho gets the event
on (default)Everyone joined to the channel.
offOnly the reader's own devices.
userThe 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
  }
}
FieldTypeMeaning
cv1Payload version.
call.iduuidThe 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_bymemberWho placed it. Members here use user_id, external_id and display_name.
call.started_atISO timestamp | nullWhen it started.
call.ended_atnullAlways null in this event.
call.participantsmember[]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_statesobjectUser id → ringing, answered, declined, or missed, for each person rung. {} for a room.
call.video_allowedbooleanThe workspace's video setting. Hide the camera button when false.
call.max_participantsintegerThe 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"
}
FieldTypeMeaning
ended_atISO timestampWhen it ended.
duration_msinteger | nullConnected time. For a call, from the first answer; null if nobody answered. For a room, from when it opened.
joined_user_idsuuid[]Everyone who was in it at any point, caller included.
reasonstringfinished, 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.
Conversation channel · Realtime · poolse docs