Flutter SDK chat messages incompatible with web `useChat` protocol

SDK versions affected

  • livekit_client (Flutter): latest
  • @livekit/components-react (Web): latest

Problem

The @livekit/components-react web SDK has a built-in useChat hook that sends and receives chat messages over the lk-chat data channel topic using a specific JSON protocol:

{ "id": "<uuid>", "message": "hello", "timestamp": 1234567890 }

The Flutter SDK has no equivalent built-in chat implementation. When Flutter clients need to send chat messages, they must publish raw data manually — either as plain text or custom JSON — on the same lk-chat topic. This creates two incompatibilities:

1. Flutter → Web: participant name lost

When a Flutter client publishes a message on lk-chat, the web useChat hook either ignores it (if it doesn’t match the expected schema) or renders it without the correct sender name. The web DataReceived handler receives the participant object, but since the message bypasses useChat internals, name resolution has to be done manually via room.remoteParticipants — which is fragile and breaks in edge cases (e.g. when the sender is the local participant).

2. Web dual-send echo causes ghost messages

As a workaround, web clients intercept publishData on the lk-chat topic and send two copies — the original JSON (for useChat) and a plain-text copy (for Flutter). The plain-text copy arrives back at other web participants’ DataReceived handlers, where it bypasses useChat and gets injected into the chat DOM via manual DOM manipulation, appearing with no sender name or as 'Mobile User'.


Root cause

There is no standardised cross-platform chat protocol across LiveKit SDKs. The lk-chat topic and its JSON schema exist only in @livekit/components-react and are undocumented as a cross-SDK contract. Flutter has no useChat equivalent, so cross-platform chat requires fragile workarounds.


Expected behaviour

Either:

  • The Flutter SDK provides a built-in chat API (useChat equivalent) that publishes messages in the same { id, message, timestamp } format on the lk-chat topic, OR
  • The lk-chat protocol is formally documented as a cross-SDK standard so third-party implementations (Flutter, Unity, etc.) can be compatible by default

Workaround currently in use

We are intercepting publishData on the web side to dual-send messages, and using MutationObserver + manual DOM injection to render Flutter messages inside the LiveKit chat UI. This is brittle and breaks whenever the LiveKit chat DOM structure changes.


Questions

  1. Is there a recommended approach for cross-platform chat between Flutter and web clients in the same room?
  2. Are there plans to add a built-in chat API to the Flutter SDK?
  3. Should lk-chat be treated as a stable cross-SDK protocol contract?

Environment

  • Flutter client: Android + iOS
  • Web client: Next.js + @livekit/components-react
  • Both clients in the same LiveKit room
  • LiveKit Cloud

There is currently no clearly documented cross-SDK room-chat contract between React and Flutter clients.

The React SDK provides a built-in chat abstraction through @livekit/components-react, while Flutter does not provide an equivalent room-level chat API. As a result, developers building multi-platform apps are forced into custom and brittle workarounds.

Web and Flutter clients connected to the same LiveKit room.

► Web: Next.js + @livekit/components-react
► Mobile: Flutter on Android and iOS
► Backend: LiveKit Cloud

The React side has a built-in chat path. Flutter requires manual message transport and message parsing. This creates several interoperability failures:

► Flutter to Web (Messages from Flutter do not integrate cleanly with React chat abstraction which requires custom handling.)
► Web to Flutter (Messages from React chat do not appear to be a stable cross-SDK contracts, Flutter implementations requires reverse-engineering it’s behavior.)

Workaround complexity

To bridge the two, you must intercept publish/send, duplicate messages (in multiple formats) and manually render non-native messages in the web UI. This causes multiple issues like:

► ghost / duplicate messages posting
► sender-name inconsistencies
► brittle DOM-level hacks
► Complete internal breakage if UI behavior changes

There does not appear to be any documented, stable, shared room-chat protocol across SDKs that is true. Maybe because React exposes a higher-level chat abstraction. And Flutter exposes lower-level messaging primitives. So Without a common functioning and well documented contracts then interoperability becomes app-specific.

One of the following would resolve this cleanly:

Either a Document and stable cross-SDK room-chat contract is designed with:

► topic naming
► payload schema
► sender identity expectations
► delivery semantics

Or Flutter can develop and provide a first-class Flutter room-chat API that is the equivalent in purpose to the React room chat abstraction or interoperable by default with web clients.

Else they can Officially recommend text streams for cross-platform chat and provide a reference schema, provide examples for React + Flutter interoperability and clarify whether the React built-in chat abstraction is intended only for React-to-React usage.

The best move is to stop trying to make Flutter impersonate React useChat.

Build one shared app-level chat protocol on text stream and use it on both platforms.

Use your own topic, for example:

app.chat.v1

Use one shared JSON payload:

{
  "id": "2a0a8df1-5b5e-4e4e-a2d9-1b8b8d2b2ef1",
  "type": "chat.message",
  "message": "hello",
  "timestamp": 1713800000000,
  "senderId": "participant-123",
  "senderName": "User123"
}

Why this is better:

► one protocol
► one topic
► no dual-send
► no DOM injection
► works the same on web and Flutter
► sender identity is explicit
► you are no longer dependent on undocumented internal chat behavior

Recommended message model (Use this model everywhere):

type ChatMessage = {
  id: string;
  type: 'chat.message';
  message: string;
  timestamp: number;
  senderId: string;
  senderName?: string;
};

Rules:

id: UUID
type: fixed string for future extensibility
message: text content
timestamp: epoch ms
senderId: participant identity
senderName: optional display name fallback

You can also add later:

replyTo
isSystem
metadata
avatarUrl


React implementation, this replaces reliance on useChat for cross-platform rooms.

React hook:

import { useCallback, useEffect, useMemo, useState } from 'react';
import { Room, LocalParticipant, Participant, TextStreamReader } from 'livekit-client';

export type AppChatMessage = {
  id: string;
  type: 'chat.message';
  message: string;
  timestamp: number;
  senderId: string;
  senderName?: string;
};

const CHAT_TOPIC = 'app.chat.v1';

function uuid() {
  return crypto.randomUUID();
}

async function readAll(reader: TextStreamReader): Promise<string> {
  let out = '';
  for await (const chunk of reader) {
    out += chunk;
  }
  return out;
}

export function useCrossPlatformChat(room: Room | undefined) {
  const [messages, setMessages] = useState<AppChatMessage[]>([]);

  useEffect(() => {
    if (!room) return;

    const unregister = room.registerTextStreamHandler(
      CHAT_TOPIC,
      async (reader, participantInfo) => {
        try {
          const raw = await readAll(reader);
          const parsed = JSON.parse(raw) as AppChatMessage;

          if (parsed.type !== 'chat.message' || !parsed.id) return;

          const senderId =
            parsed.senderId ||
            participantInfo?.identity ||
            'unknown';

          const senderName =
            parsed.senderName ||
            participantInfo?.name ||
            participantInfo?.identity ||
            'Unknown User';

          setMessages((prev) => {
            if (prev.some((m) => m.id === parsed.id)) return prev;
            return [
              ...prev,
              {
                ...parsed,
                senderId,
                senderName,
              },
            ];
          });
        } catch (err) {
          console.error('Failed to parse chat message', err);
        }
      }
    );

    return () => {
      unregister?.();
    };
  }, [room]);

  const sendMessage = useCallback(
    async (text: string) => {
      if (!room?.localParticipant || !text.trim()) return;

      const lp = room.localParticipant as LocalParticipant;

      const payload: AppChatMessage = {
        id: uuid(),
        type: 'chat.message',
        message: text.trim(),
        timestamp: Date.now(),
        senderId: lp.identity,
        senderName: lp.name || lp.identity,
      };

      await lp.sendText(JSON.stringify(payload), {
        topic: CHAT_TOPIC,
      });

      setMessages((prev) => {
        if (prev.some((m) => m.id === payload.id)) return prev;
        return [...prev, payload];
      });
    },
    [room]
  );

  return {
    messages,
    sendMessage,
    topic: CHAT_TOPIC,
  };
}

React UI example:

import React, { useState } from 'react';
import { useRoomContext } from '@livekit/components-react';
import { useCrossPlatformChat } from './useCrossPlatformChat';

export function AppChatPanel() {
  const room = useRoomContext();
  const { messages, sendMessage } = useCrossPlatformChat(room);
  const [text, setText] = useState('');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      <div style={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #444', padding: 8 }}>
        {messages.map((m) => (
          <div key={m.id} style={{ marginBottom: 6 }}>
            <strong>{m.senderName || m.senderId}:</strong> {m.message}
          </div>
        ))}
      </div>

      <form
        onSubmit={async (e) => {
          e.preventDefault();
          const value = text.trim();
          if (!value) return;
          await sendMessage(value);
          setText('');
        }}
      >
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="Type a message"
          style={{ width: '100%', padding: 8 }}
        />
      </form>
    </div>
  );
}

Flutter implementation

Dart model:

import 'dart:convert';

class AppChatMessage {
  final String id;
  final String type;
  final String message;
  final int timestamp;
  final String senderId;
  final String? senderName;

  AppChatMessage({
    required this.id,
    required this.type,
    required this.message,
    required this.timestamp,
    required this.senderId,
    this.senderName,
  });

  Map<String, dynamic> toJson() => {
        'id': id,
        'type': type,
        'message': message,
        'timestamp': timestamp,
        'senderId': senderId,
        'senderName': senderName,
      };

  factory AppChatMessage.fromJson(Map<String, dynamic> json) {
    return AppChatMessage(
      id: json['id'] as String,
      type: json['type'] as String,
      message: json['message'] as String,
      timestamp: json['timestamp'] as int,
      senderId: json['senderId'] as String,
      senderName: json['senderName'] as String?,
    );
  }

  String encode() => jsonEncode(toJson());

  factory AppChatMessage.decode(String raw) {
    return AppChatMessage.fromJson(jsonDecode(raw) as Map<String, dynamic>);
  }
}

The chat controller:

import 'dart:async';
import 'package:livekit_client/livekit_client.dart';
import 'package:uuid/uuid.dart';

import 'app_chat_message.dart';

class CrossPlatformChatController {
  static const String topic = 'app.chat.v1';

  final Room room;
  final List<AppChatMessage> messages = [];
  final StreamController<List<AppChatMessage>> _messagesController =
      StreamController<List<AppChatMessage>>.broadcast();

  Stream<List<AppChatMessage>> get stream => _messagesController.stream;

  CrossPlatformChatController(this.room);

  Future<void> initialize() async {
    room.registerTextStreamHandler(
      topic,
      (reader, participantInfo) async {
        try {
          final raw = await _readAll(reader);
          final parsed = AppChatMessage.decode(raw);

          if (parsed.type != 'chat.message') return;
          if (messages.any((m) => m.id == parsed.id)) return;

          final normalized = AppChatMessage(
            id: parsed.id,
            type: parsed.type,
            message: parsed.message,
            timestamp: parsed.timestamp,
            senderId: parsed.senderId.isNotEmpty
                ? parsed.senderId
                : (participantInfo.identity),
            senderName: (parsed.senderName != null &&
                    parsed.senderName!.trim().isNotEmpty)
                ? parsed.senderName
                : (participantInfo.name.isNotEmpty
                    ? participantInfo.name
                    : participantInfo.identity),
          );

          messages.add(normalized);
          _messagesController.add(List.unmodifiable(messages));
        } catch (_) {}
      },
    );
  }

  Future<void> sendMessage(String text) async {
    final trimmed = text.trim();
    if (trimmed.isEmpty) return;

    final local = room.localParticipant;
    if (local == null) return;

    final payload = AppChatMessage(
      id: const Uuid().v4(),
      type: 'chat.message',
      message: trimmed,
      timestamp: DateTime.now().millisecondsSinceEpoch,
      senderId: local.identity,
      senderName: local.name,
    );

    await local.sendText(
      payload.encode(),
      topic: topic,
    );

    if (!messages.any((m) => m.id == payload.id)) {
      messages.add(payload);
      _messagesController.add(List.unmodifiable(messages));
    }
  }

  Future<String> _readAll(dynamic reader) async {
    final buffer = StringBuffer();
    await for (final chunk in reader) {
      buffer.write(chunk);
    }
    return buffer.toString();
  }

  void dispose() {
    _messagesController.close();
  }
}

A Flutter widget example:

import 'package:flutter/material.dart';
import 'cross_platform_chat_controller.dart';
import 'app_chat_message.dart';

class ChatPanel extends StatefulWidget {
  final CrossPlatformChatController controller;

  const ChatPanel({super.key, required this.controller});

  @override
  State<ChatPanel> createState() => _ChatPanelState();
}

class _ChatPanelState extends State<ChatPanel> {
  final TextEditingController _textController = TextEditingController();
  List<AppChatMessage> _messages = [];

  @override
  void initState() {
    super.initState();
    widget.controller.stream.listen((items) {
      if (!mounted) return;
      setState(() {
        _messages = items;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: ListView.builder(
            itemCount: _messages.length,
            itemBuilder: (_, index) {
              final m = _messages[index];
              return ListTile(
                dense: true,
                title: Text(m.senderName ?? m.senderId),
                subtitle: Text(m.message),
              );
            },
          ),
        ),
        Row(
          children: [
            Expanded(
              child: TextField(
                controller: _textController,
                decoration: const InputDecoration(
                  hintText: 'Type a message',
                ),
              ),
            ),
            IconButton(
              icon: const Icon(Icons.send),
              onPressed: () async {
                final text = _textController.text.trim();
                if (text.isEmpty) return;
                await widget.controller.sendMessage(text);
                _textController.clear();
              },
            ),
          ],
        )
      ],
    );
  }
}

Key implementation rules

Do these:

► use one topic only, like app.chat.v1
► use one JSON schema on all platforms
► include senderId and senderName
► dedupe on id
► optimistically add local messages after send
► render from your own message list, not LiveKit’s internal chat DOM

Do not do these:

► do not dual-send
► do not mix plain text and JSON for the same chat feature
► do not inject messages into the built-in React chat DOM
► do not rely on lk-chat or lk.chat unless LiveKit officially documents that contract

Migration path off your workaround:

Create a brand-new topic:

app.chat.v1

Implement the shared JSON model on both sides.

Stop intercepting web publish/send behavior.

Replace React useChat UI with your own AppChatPanel.

Remove MutationObserver and DOM injection.

Once both clients use the new topic, delete all old bridge code.

Completely retire the current useChat bridge and switch the room chat feature to:

► custom React chat UI
► Flutter chat using sendText
► shared topic app.chat.v1
► shared JSON schema
► no fallback plain-text path at all

That gets you out of the fragile zone.