> For a complete index of all SignalWire documentation pages, fetch https://signalwire.com/docs/llms.txt

# Messaging & Chat

Text messaging in the Browser SDK lives on the `Address` entity, not
on the `Call`. Every Address — whether a User (Subscriber), room, or
external contact — has a conversation associated with it: an
append-only log of chat messages and call history. You can send a
text to an Address with or without an active call.

The same conversation is shared across all clients of the same
User. Sending a message from your phone client and your laptop
client puts both into the same thread.

## Sending a message

```js
import { firstValueFrom } from "rxjs";

const directory = await firstValueFrom(client.directory$);
const id        = await directory.findAddressIdByURI("/private/alice");
const address   = directory.get(id);

await address.sendText("Heading over to the call now");
```

`findAddressIdByURI` checks the local cache and falls back to the server, so it works even before `directory.loadMore()` has populated `directory.addresses`. See [Address Book & Directory](/docs/browser-sdk/v4/guides/address-book) for the directory-lookup patterns.

`sendText` resolves once the message is accepted by the server.
There's no separate "delivered" / "read" signal in v4 — if you need
those, store delivery state in your own backend.

You can't message your own address. The platform rejects a
join request whose destination is the same fabric address as the
caller with a `422`. If your UI lists the directory, filter
`client.user.addresses[0].id` out before rendering.

Testing two sides of a conversation requires **two different
Subscriber Access Tokens** — one per user. Reusing the same SAT
in two tabs will work for the most recently connected tab, while the
other tab logs `Discarding stale event: conversation.message` as the
platform rotates the session's event channel.

## Reading the conversation

`address.textMessages$` lazy-loads the conversation on first
subscribe and emits a `TextMessageCollection`. The collection itself
is reactive — its `values$` re-emits as new messages arrive or older
ones are paginated in.

```js
address.textMessages$.subscribe((collection) => {
  if (!collection) return;

  collection.values$.subscribe((messages) => {
    chatList.innerHTML = "";
    for (const m of messages) {
      const li = document.createElement("li");
      li.textContent = `${m.text} — ${new Date(m.created).toLocaleTimeString()}`;
      chatList.appendChild(li);
    }
  });
});
```

Each entry is a [`TextMessage`] with `id`, `text`, `created` and a
`fromAddress$` observable — the sender is itself a resolved
\[`Address`], so you can render an avatar / name from the same SDK
data without an extra fetch.

## Paging older messages

`textMessages$` initially loads the most recent page. To pull older
messages, watch `hasMore$` and call `loadMore()`:

```js
collection.hasMore$.subscribe((hasMore) => {
  if (!hasMore) return;
  chatList.onscroll = () => {
    if (chatList.scrollTop < 50) collection.loadMore();
  };
});
```

The "scroll near the top → loadMore" pattern is what the kitchen-sink
demo uses; the same shape works for any direction.

## In-call chat

When you have an active call, the call's address is reachable as
`call.address`. Use that to send chat messages within the call's
conversation:

```js
const sendButton = document.querySelector("#send-chat");
const input      = document.querySelector("#chat-input");

sendButton.onclick = async () => {
  const text = input.value.trim();
  if (!text || !call.address) return;
  await call.address.sendText(text);
  input.value = "";
};

call.address?.textMessages$.subscribe((collection) => {
  collection?.values$.subscribe(renderMessages);
});
```

Even after the call ends, the conversation persists — you can scroll
back through messages from previous calls and send asynchronous
messages between calls.

## Call history

The same conversation log also carries call history — same shape,
same pagination, filtered to call entries instead of chat. Each
entry ([`AddressHistory`]) has `kind`, `status`, `started`, `ended`.

```js
address.history$.subscribe((collection) => {
  collection?.values$.subscribe((entries) => renderCallLog(entries));
});
```

`textMessages$` and `history$` are two filtered views of the same
underlying conversation, so loading one populates both.

Both observables `shareReplay(1)` — late subscribers get the existing
collection without re-fetching.

## Group chat in a room

In a room call, `call.address` is the *room's* address — sending a
chat message there delivers it to everyone in the room's
conversation. Each room thus has one chat thread, persisted across
sessions:

```js
const call = await client.dial("/public/team-standup", { audio: true });

call.address?.textMessages$.subscribe((collection) => {
  collection?.values$.subscribe((messages) => renderChat(messages));
});

sendButton.onclick = () => call.address?.sendText(input.value);
```

For private side-channels within a room (a DM between two
participants), use each participant's `Address` directly — look it
up from `directory.get$(addressId)` using the `Participant.addressId`
field.

## Realtime delivery without a call

Conversations are reactive whether or not a call is active. To run a
chat-only experience (e.g. a support inbox), subscribe to multiple
addresses' `textMessages$` streams and update your UI as messages
land:

```js
directory.addresses$.subscribe((addresses) => {
  for (const address of addresses) {
    address.textMessages$.subscribe((collection) => {
      collection?.values$.subscribe((messages) => {
        const unread = messages.filter((m) => !isRead(m.id));
        updateUnreadBadge(address.id, unread.length);
      });
    });
  }
});

directory.loadMore();
```

Subscribing to `addresses$` (rather than reading the `addresses` snapshot) means new entries that arrive on later pages or via background updates get watched automatically. Track which `address.id`s you've already wired up if you want to avoid double-subscribing on re-emit.

The platform pushes new messages over the same WebSocket the SDK
uses for signaling — no polling required.

## Try it: a tiny chat client

The page below stitches together everything above into one runnable
file. Paste a [Subscriber Access Token](/docs/browser-sdk/v4/guides/authentication),
hit **Connect**, pick a conversation from the dropdown, and you can
read history, paginate older pages, send a text, and toggle the call
log for the same address — all from one `Address` entity.

The demo touches: `client.directory$` (to list addresses),
`address.textMessages$` and the collection's `values$` / `hasMore$` /
`loadMore()` (to render and paginate the thread), `address.sendText()`
(to post), and `address.history$` (to surface the call log for the
same conversation).

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>SignalWire SDK messaging & chat demo</title>
    <style>
      body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
      label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
      input, select { width: 100%; padding: 0.5rem; font: 13px ui-monospace, monospace; box-sizing: border-box; }
      button { margin: 0.5rem 0.5rem 0 0; padding: 0.5rem 1rem; font: 14px system-ui; cursor: pointer; }
      button[disabled] { opacity: 0.5; cursor: not-allowed; }
      #thread { margin-top: 1rem; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; height: 18rem; overflow-y: auto; background: #fafafa; }
      #thread li { list-style: none; padding: 0.25rem 0; border-bottom: 1px solid #eee; }
      #thread li:last-child { border-bottom: none; }
      .meta { color: #666; font-size: 12px; }
      #history { margin-top: 0.5rem; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; max-height: 10rem; overflow-y: auto; background: #fafafa; display: none; font-size: 13px; }
      #log { margin-top: 1rem; padding: 1rem; background: #111; color: #0f0; font: 13px ui-monospace, monospace; min-height: 4rem; white-space: pre-wrap; border-radius: 4px; }
      .row { display: flex; gap: 0.5rem; align-items: stretch; }
      .row input { flex: 1; }
    </style>
  </head>
  <body>
    <h1>SignalWire SDK messaging & chat demo</h1>

    <label for="token">Subscriber Access Token</label>
    <input id="token" type="password" placeholder="Paste your SAT here" />
    <button id="connect">Connect</button>

    <label for="address">Conversation</label>
    <select id="address" disabled>
      <option>— connect first —</option>
    </select>
    <button id="loadOlder" disabled>Load older</button>
    <button id="toggleHistory" disabled>Show call history</button>

    <ul id="thread"></ul>
    <div id="history"></div>

    <div class="row" style="margin-top: 0.75rem;">
      <input id="text" placeholder="Type a message…" disabled />
      <button id="send" disabled>Send</button>
    </div>

    <pre id="log"></pre>

    <script type="module">
      import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev?bundle-deps";
      import { firstValueFrom, filter } from "https://esm.sh/rxjs@7.8.2?bundle-deps";

      const $ = (id) => document.getElementById(id);
      const log = (msg) => ($("log").textContent += msg + "\n");

      let client, currentAddress, threadSub, valuesSub, hasMoreSub, historySub, currentCollection;

      $("connect").addEventListener("click", async () => {
        const token = $("token").value.trim();
        if (!token) return log("Paste a token first.");
        $("connect").disabled = true;
        log("Connecting…");

        client = new SignalWire(new StaticCredentialProvider({ token }));
        try {
          await firstValueFrom(client.ready$.pipe(filter((r) => r)));
        } catch (err) {
          log("Failed: " + (err.message || err));
          $("connect").disabled = false;
          return;
        }

        // `directory.addresses` starts empty — subscribing to `addresses$`
        // and calling `loadMore()` triggers the initial fetch and keeps
        // the UI in sync as pages stream in.
        const directory = await firstValueFrom(client.directory$);
        // The directory includes the current user's own address —
        // filter it out, since you can't open a conversation with yourself
        // (the server rejects join with a 422).
        const selfAddressId = client.user?.addresses?.[0]?.id;
        const select = $("address");
        let addresses = [];
        let pickedFirst = false;

        directory.addresses$.subscribe((next) => {
          addresses = next.filter((a) => a.id !== selfAddressId);
          select.innerHTML = "";
          if (!addresses.length) {
            const opt = document.createElement("option");
            opt.textContent = "— no addresses in directory —";
            select.appendChild(opt);
            return;
          }
          for (const a of addresses) {
            const opt = document.createElement("option");
            opt.value = a.id;
            opt.textContent = a.name + " (" + a.type + ")";
            select.appendChild(opt);
          }
          select.disabled = false;
          if (!pickedFirst) {
            pickedFirst = true;
            log("Connected. Pick a conversation above.");
            selectAddress(addresses[0]);
          }
        });

        select.addEventListener("change", () => {
          const a = addresses.find((x) => x.id === select.value);
          if (a) selectAddress(a);
        });

        directory.loadMore();
      });

      function selectAddress(address) {
        currentAddress = address;
        $("thread").innerHTML = "";
        $("history").innerHTML = "";
        $("history").style.display = "none";
        $("toggleHistory").textContent = "Show call history";
        $("text").disabled = false;
        $("send").disabled = false;
        $("toggleHistory").disabled = false;

        threadSub?.unsubscribe();
        valuesSub?.unsubscribe();
        hasMoreSub?.unsubscribe();
        historySub?.unsubscribe();

        log("Loading conversation with " + address.name + "…");

        threadSub = address.textMessages$.subscribe((collection) => {
          if (!collection) return;
          currentCollection = collection;

          valuesSub?.unsubscribe();
          valuesSub = collection.values$.subscribe((messages) => {
            $("thread").innerHTML = "";
            for (const m of messages) {
              const li = document.createElement("li");
              const time = new Date(m.created).toLocaleTimeString();
              li.innerHTML =
                "<div>" + escapeHtml(m.text) + "</div>" +
                "<div class='meta'>" + time + "</div>";
              $("thread").appendChild(li);
            }
            $("thread").scrollTop = $("thread").scrollHeight;
          });

          hasMoreSub?.unsubscribe();
          hasMoreSub = collection.hasMore$.subscribe((hasMore) => {
            $("loadOlder").disabled = !hasMore;
          });
        });
      }

      $("loadOlder").addEventListener("click", () => {
        if (!currentCollection) return;
        log("Loading older messages…");
        currentCollection.loadMore();
      });

      $("send").addEventListener("click", async () => {
        const text = $("text").value.trim();
        if (!text || !currentAddress) return;
        $("send").disabled = true;
        try {
          await currentAddress.sendText(text);
          $("text").value = "";
        } catch (err) {
          log("Send failed: " + (err.message || err));
        } finally {
          $("send").disabled = false;
        }
      });

      $("text").addEventListener("keydown", (e) => {
        if (e.key === "Enter") $("send").click();
      });

      $("toggleHistory").addEventListener("click", () => {
        if (!currentAddress) return;
        const panel = $("history");
        if (panel.style.display === "block") {
          panel.style.display = "none";
          $("toggleHistory").textContent = "Show call history";
          historySub?.unsubscribe();
          return;
        }
        panel.style.display = "block";
        $("toggleHistory").textContent = "Hide call history";
        historySub = currentAddress.history$.subscribe((collection) => {
          collection?.values$.subscribe((entries) => {
            panel.innerHTML = "";
            if (!entries.length) {
              panel.textContent = "(no call history)";
              return;
            }
            for (const e of entries) {
              const div = document.createElement("div");
              div.textContent =
                e.kind + " · " + e.status + " · " +
                new Date(e.started).toLocaleString();
              panel.appendChild(div);
            }
          });
        });
      });

      function escapeHtml(s) {
        return s.replace(/[&<>"']/g, (c) => ({
          "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
        })[c]);
      }
    </script>
  </body>
</html>
```

## Reference

* [`Address.sendText()`] — send a chat message
* [`Address.textMessages$`] / [`Address.textMessage`] — chat thread collection
* [`Address.history$`] / [`Address.history`] — call history for the same conversation
* [`TextMessage`] — the message shape
* [`AddressHistory`] — the call-log entry shape
* [`Call.address`] — the active call's address, for in-call chat

[`Address.sendText()`]: /docs/browser-sdk/v4/reference/address/send-text

[`Address.textMessages$`]: /docs/browser-sdk/v4/reference/address

[`Address.textMessage`]: /docs/browser-sdk/v4/reference/address/text-message

[`Address.history$`]: /docs/browser-sdk/v4/reference/address

[`Address.history`]: /docs/browser-sdk/v4/reference/address/history

[`TextMessage`]: /docs/browser-sdk/v4/reference/interfaces/text-message

[`AddressHistory`]: /docs/browser-sdk/v4/reference/interfaces/address-history

[`Call.address`]: /docs/browser-sdk/v4/reference/webrtc-call/address$