Skip to content

Chat

In-call chat runs over text streams on the topic lk.chat. Tele joins every live call as a hidden participant and saves each message with the call, so late joiners and your backend can read the history.

flowchart LR
  A[Alice's app] -- sendText topic=lk.chat --> LK[(Media server)]
  LK --> B[Bob's app]
  LK --> R[Tele chat recorder<br/>hidden participant]
  R --> DB[(Call history)]

Send and receive

Use the lk.chat topic on every platform so web, Flutter and mobile clients can talk to each other.

// send
await room.localParticipant.sendText(text, { topic: "lk.chat" });

// receive
room.registerTextStreamHandler("lk.chat", async (reader, { identity }) => {
  const body = await reader.readAll();
  const sender = room.getParticipantByIdentity(identity);
  addMessage({ id: reader.info.id, body, userId: sender?.attributes.user_id, name: sender?.name });
});

useChat() from @livekit/components-react also works. It sends on lk.chat and listens there too. Don't register your own lk.chat handler while useChat is mounted: only one handler per topic is allowed.

await room.localParticipant?.sendText(text, options: SendTextOptions(topic: 'lk.chat'));

room.registerTextStreamHandler('lk.chat', (reader, identity) async {
  final body = await reader.readAll();
  // look up the participant by identity for name / attributes['user_id']
});

Requires livekit_client ≥ 2.12.0 for non-ASCII text (Bangla, emoji). The prebuilt chat widget in livekit_components uses an older format; build a small chat UI on lk.chat instead.

room.localParticipant.sendText(text, StreamTextOptions(topic = "lk.chat"))

room.registerTextStreamHandler("lk.chat") { reader, identity ->
    val body = reader.readAll()
}
try await room.localParticipant.sendText(text, for: "lk.chat")

try await room.registerTextStreamHandler(for: "lk.chat") { reader, identity in
    let body = try await reader.readAll()
}

Sending requires the participant to be connected. All join tokens include the data permission.

History

Load earlier messages when a user joins, or show them on a call-detail screen afterwards:

curl "$TELE_API/v1/calls/$CALL_ID/messages?limit=200" -H "Authorization: Bearer $USER_TOKEN"
{
  "messages": [
    { "id": "01a131e2-…", "stream_id": "TS_x2…", "user_id": "bob", "name": "Bob",
      "body": "হ্যালো 👋", "sent_at": "2026-10-13T09:01:12Z" }
  ]
}
  • Paging: pass the last message's id as ?after=<id> to get the next page. Messages are ordered by sent_at, and limit defaults to 200 (maximum 500).
  • Deduplication: stream_id matches reader.info.id from the media SDK. Use it to avoid showing a message twice when a live message and the history overlap.
  • Access: call members (user token) and your backend (service token) can read the history.

Guaranteed delivery (optional)

The recorder saves messages as they pass through the room. If your app needs a stronger guarantee, for example for compliance, it can also post each message it sends:

curl -X POST $TELE_API/v1/calls/$CALL_ID/messages -H "Authorization: Bearer $USER_TOKEN" \
  -d '{"stream_id": "TS_x2…", "body": "হ্যালো 👋", "sent_at": "2026-10-13T09:01:12Z"}'

Messages are deduplicated by stream_id within the call: 201 means newly stored, and 200 means it was already recorded. The sender is always the user in the token.

Limits

  • Messages are recorded up to 4,000 characters; longer messages are truncated in the history. The fallback endpoint rejects them.
  • Messages from participants who aren't Tele users (such as bots) aren't recorded.