For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
Log inSign up
Support
GuidesReferenceClick-to-Call
GuidesReferenceClick-to-Call
  • Getting Started
    • Overview
    • Authentication
    • RxJS Primer
    • Migrate from v3
  • Web Components
    • Overview
    • Click-to-Call
    • Theming
    • Customization
  • Build Voice & Video Apps
    • Overview
    • Outbound Calls
    • Inbound Calls
    • Device Management
    • Screen Sharing
    • Call Controls
    • Layouts
    • Messaging & Chat
  • Manage Resources
    • Overview
    • Users
    • Address Book
    • Client Preferences
    • Capabilities
  • Deploy
    • Overview
    • Framework Integration
    • SSR & Next.js
    • Troubleshooting
LogoLogoSignalWire Docs
Log inSign up
Support
On this page
  • Sending a message
  • Reading the conversation
  • Paging older messages
  • In-call chat
  • Call history
  • Group chat in a room
  • Realtime delivery without a call
  • Try it: a tiny chat client
  • Reference
Build Voice & Video Apps

Messaging & Chat

|View as Markdown|Open in Claude|
Was this page helpful?
Edit this page
Previous

Overview

Next
Built with

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

1import { firstValueFrom } from "rxjs";
2
3const directory = await firstValueFrom(client.directory$);
4const id = await directory.findAddressIdByURI("/private/alice");
5const address = directory.get(id);
6
7await 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 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.

1address.textMessages$.subscribe((collection) => {
2 if (!collection) return;
3
4 collection.values$.subscribe((messages) => {
5 chatList.innerHTML = "";
6 for (const m of messages) {
7 const li = document.createElement("li");
8 li.textContent = `${m.text} — ${new Date(m.created).toLocaleTimeString()}`;
9 chatList.appendChild(li);
10 }
11 });
12});

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():

1collection.hasMore$.subscribe((hasMore) => {
2 if (!hasMore) return;
3 chatList.onscroll = () => {
4 if (chatList.scrollTop < 50) collection.loadMore();
5 };
6});

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:

1const sendButton = document.querySelector("#send-chat");
2const input = document.querySelector("#chat-input");
3
4sendButton.onclick = async () => {
5 const text = input.value.trim();
6 if (!text || !call.address) return;
7 await call.address.sendText(text);
8 input.value = "";
9};
10
11call.address?.textMessages$.subscribe((collection) => {
12 collection?.values$.subscribe(renderMessages);
13});

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.

1address.history$.subscribe((collection) => {
2 collection?.values$.subscribe((entries) => renderCallLog(entries));
3});

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:

1const call = await client.dial("/public/team-standup", { audio: true });
2
3call.address?.textMessages$.subscribe((collection) => {
4 collection?.values$.subscribe((messages) => renderChat(messages));
5});
6
7sendButton.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:

1directory.addresses$.subscribe((addresses) => {
2 for (const address of addresses) {
3 address.textMessages$.subscribe((collection) => {
4 collection?.values$.subscribe((messages) => {
5 const unread = messages.filter((m) => !isRead(m.id));
6 updateUnreadBadge(address.id, unread.length);
7 });
8 });
9 }
10});
11
12directory.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.ids 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, 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).

messaging-chat-demo.html — full source
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <title>SignalWire SDK messaging & chat demo</title>
6 <style>
7 body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
8 label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
9 input, select { width: 100%; padding: 0.5rem; font: 13px ui-monospace, monospace; box-sizing: border-box; }
10 button { margin: 0.5rem 0.5rem 0 0; padding: 0.5rem 1rem; font: 14px system-ui; cursor: pointer; }
11 button[disabled] { opacity: 0.5; cursor: not-allowed; }
12 #thread { margin-top: 1rem; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; height: 18rem; overflow-y: auto; background: #fafafa; }
13 #thread li { list-style: none; padding: 0.25rem 0; border-bottom: 1px solid #eee; }
14 #thread li:last-child { border-bottom: none; }
15 .meta { color: #666; font-size: 12px; }
16 #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; }
17 #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; }
18 .row { display: flex; gap: 0.5rem; align-items: stretch; }
19 .row input { flex: 1; }
20 </style>
21 </head>
22 <body>
23 <h1>SignalWire SDK messaging & chat demo</h1>
24
25 <label for="token">Subscriber Access Token</label>
26 <input id="token" type="password" placeholder="Paste your SAT here" />
27 <button id="connect">Connect</button>
28
29 <label for="address">Conversation</label>
30 <select id="address" disabled>
31 <option>— connect first —</option>
32 </select>
33 <button id="loadOlder" disabled>Load older</button>
34 <button id="toggleHistory" disabled>Show call history</button>
35
36 <ul id="thread"></ul>
37 <div id="history"></div>
38
39 <div class="row" style="margin-top: 0.75rem;">
40 <input id="text" placeholder="Type a message…" disabled />
41 <button id="send" disabled>Send</button>
42 </div>
43
44 <pre id="log"></pre>
45
46 <script type="module">
47 import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev?bundle-deps";
48 import { firstValueFrom, filter } from "https://esm.sh/rxjs@7.8.2?bundle-deps";
49
50 const $ = (id) => document.getElementById(id);
51 const log = (msg) => ($("log").textContent += msg + "\n");
52
53 let client, currentAddress, threadSub, valuesSub, hasMoreSub, historySub, currentCollection;
54
55 $("connect").addEventListener("click", async () => {
56 const token = $("token").value.trim();
57 if (!token) return log("Paste a token first.");
58 $("connect").disabled = true;
59 log("Connecting…");
60
61 client = new SignalWire(new StaticCredentialProvider({ token }));
62 try {
63 await firstValueFrom(client.ready$.pipe(filter((r) => r)));
64 } catch (err) {
65 log("Failed: " + (err.message || err));
66 $("connect").disabled = false;
67 return;
68 }
69
70 // `directory.addresses` starts empty — subscribing to `addresses$`
71 // and calling `loadMore()` triggers the initial fetch and keeps
72 // the UI in sync as pages stream in.
73 const directory = await firstValueFrom(client.directory$);
74 // The directory includes the current user's own address —
75 // filter it out, since you can't open a conversation with yourself
76 // (the server rejects join with a 422).
77 const selfAddressId = client.user?.addresses?.[0]?.id;
78 const select = $("address");
79 let addresses = [];
80 let pickedFirst = false;
81
82 directory.addresses$.subscribe((next) => {
83 addresses = next.filter((a) => a.id !== selfAddressId);
84 select.innerHTML = "";
85 if (!addresses.length) {
86 const opt = document.createElement("option");
87 opt.textContent = "— no addresses in directory —";
88 select.appendChild(opt);
89 return;
90 }
91 for (const a of addresses) {
92 const opt = document.createElement("option");
93 opt.value = a.id;
94 opt.textContent = a.name + " (" + a.type + ")";
95 select.appendChild(opt);
96 }
97 select.disabled = false;
98 if (!pickedFirst) {
99 pickedFirst = true;
100 log("Connected. Pick a conversation above.");
101 selectAddress(addresses[0]);
102 }
103 });
104
105 select.addEventListener("change", () => {
106 const a = addresses.find((x) => x.id === select.value);
107 if (a) selectAddress(a);
108 });
109
110 directory.loadMore();
111 });
112
113 function selectAddress(address) {
114 currentAddress = address;
115 $("thread").innerHTML = "";
116 $("history").innerHTML = "";
117 $("history").style.display = "none";
118 $("toggleHistory").textContent = "Show call history";
119 $("text").disabled = false;
120 $("send").disabled = false;
121 $("toggleHistory").disabled = false;
122
123 threadSub?.unsubscribe();
124 valuesSub?.unsubscribe();
125 hasMoreSub?.unsubscribe();
126 historySub?.unsubscribe();
127
128 log("Loading conversation with " + address.name + "…");
129
130 threadSub = address.textMessages$.subscribe((collection) => {
131 if (!collection) return;
132 currentCollection = collection;
133
134 valuesSub?.unsubscribe();
135 valuesSub = collection.values$.subscribe((messages) => {
136 $("thread").innerHTML = "";
137 for (const m of messages) {
138 const li = document.createElement("li");
139 const time = new Date(m.created).toLocaleTimeString();
140 li.innerHTML =
141 "<div>" + escapeHtml(m.text) + "</div>" +
142 "<div class='meta'>" + time + "</div>";
143 $("thread").appendChild(li);
144 }
145 $("thread").scrollTop = $("thread").scrollHeight;
146 });
147
148 hasMoreSub?.unsubscribe();
149 hasMoreSub = collection.hasMore$.subscribe((hasMore) => {
150 $("loadOlder").disabled = !hasMore;
151 });
152 });
153 }
154
155 $("loadOlder").addEventListener("click", () => {
156 if (!currentCollection) return;
157 log("Loading older messages…");
158 currentCollection.loadMore();
159 });
160
161 $("send").addEventListener("click", async () => {
162 const text = $("text").value.trim();
163 if (!text || !currentAddress) return;
164 $("send").disabled = true;
165 try {
166 await currentAddress.sendText(text);
167 $("text").value = "";
168 } catch (err) {
169 log("Send failed: " + (err.message || err));
170 } finally {
171 $("send").disabled = false;
172 }
173 });
174
175 $("text").addEventListener("keydown", (e) => {
176 if (e.key === "Enter") $("send").click();
177 });
178
179 $("toggleHistory").addEventListener("click", () => {
180 if (!currentAddress) return;
181 const panel = $("history");
182 if (panel.style.display === "block") {
183 panel.style.display = "none";
184 $("toggleHistory").textContent = "Show call history";
185 historySub?.unsubscribe();
186 return;
187 }
188 panel.style.display = "block";
189 $("toggleHistory").textContent = "Hide call history";
190 historySub = currentAddress.history$.subscribe((collection) => {
191 collection?.values$.subscribe((entries) => {
192 panel.innerHTML = "";
193 if (!entries.length) {
194 panel.textContent = "(no call history)";
195 return;
196 }
197 for (const e of entries) {
198 const div = document.createElement("div");
199 div.textContent =
200 e.kind + " · " + e.status + " · " +
201 new Date(e.started).toLocaleString();
202 panel.appendChild(div);
203 }
204 });
205 });
206 });
207
208 function escapeHtml(s) {
209 return s.replace(/[&<>"']/g, (c) => ({
210 "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
211 })[c]);
212 }
213 </script>
214 </body>
215</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