The claim In a room call the Browser SDK v4 exposes the room as call.address. Text sent to that address reaches everyone in the room’s conversation. The room keeps one thread across sessions, per the Messaging and chat guide . A group chat is not a second service with a second token. It is the room’s own conversation, read and written by the client that already carries the video.
Why it holds Two pieces. A Flask route mints a Subscriber Access Token per user with POST /api/fabric/subscribers/tokens. Each message carries a fromAddress$, the sender’s address, so the token decides who a message is from. The page dials the room with audio and video, subscribes to call.address$, reads textMessages$ and posts with sendText().
How it works Server (python/app.py):
client.fabric.tokens.create_subscriber_token(reference=user_id, display_name="Dana") Browser (typescript/index.ts):
const call = await client.dial("/public/team-standup", { audio: true, video: true });
call.address$.subscribe((address) => {
address?.textMessages$.subscribe((thread) => {
thread?.values$.subscribe(render); // the room's thread
thread?.hasMore$.subscribe(...); // older pages, via loadMore()
});
send.onclick = () => address?.sendText(input.value);
}); textMessages$ emits a collection: values$ is the messages, hasMore$ says whether older pages exist and loadMore() fetches one. The guide’s example uses one Subscriber Access Token per person. It notes that one token in two tabs logs stale-event errors, so the server mints one per reference.
Limitations The Browser SDK this uses is a release candidate, 4.0.0-rc.2. sendText() resolves when the platform accepts the message; there is no delivered or read signal, so that state is yours to keep if you need it. The guide documents the thread with Subscriber Access Tokens. Whether a Guest token can post to it is not documented, so this recipe does not claim it. A private message between two participants goes to that participant’s own address, which the page does not show. The verifier proves the token request and the page’s use of the documented members; what the thread does on a live call is live behaviour.
What to change first Subscribe to each message’s fromAddress$ and render its displayName beside the text, so the log shows who typed. Then give the token an expire_at a few minutes out and let the page refresh it, which is call-from-a-browser’s job.
Python TypeScript
python/app.py typescript/index.ts Copy
"""Add a group chat to a video room: the server half.
The chat is the room's own conversation, reached through the room's Fabric
address, so the browser needs a token that says who is typing. A Subscriber
Access Token does that: one per user, minted here with the project API token
the page never sees. The browser (typescript/) dials the room with audio and
video and then reads and writes the room's text thread through the same call.
Written against signalwire-sdk 3.0.1 (RestClient.fabric.tokens) and Flask.
"""
import os
from dotenv import load_dotenv
from flask import Flask , jsonify , request
from signalwire.rest import RestClient
# the SDK does not read .env for you
load_dotenv ()
app = Flask ( __name__ )
client = RestClient ()
ROOM = os . getenv ( "ROOM_NAME" , "team-standup" )
def token_for ( user_reference , display_name = None ):
"""One Subscriber Access Token per signed-in user.
`reference` is the string SignalWire uses to identify the user. Each message
in the thread carries the sender's address, so two people need two tokens,
and the guide notes that one token in two tabs logs stale-event errors.
`display_name` is what the other participants see.
"""
body = { "reference" : user_reference }
if display_name :
body [ "display_name" ] = display_name
return client . fabric . tokens . create_subscriber_token ( ** body )
@app . post ( "/token" )
def token ():
"""The browser asks for its token; the project API token stays here."""
# In a real app, `user` is the authenticated user's id from your session.
body = request . json or {}
user = body . get ( "user" )
if not user :
return jsonify ( error = "user is required" ), 400
t = token_for ( user , body . get ( "display_name" ))
room = body . get ( "room" , ROOM )
return jsonify ( token = t [ "token" ], destination = f "/public/ { room } " )
if __name__ == "__main__" :
app . run ( port = int ( os . getenv ( "PORT" , "8080" ))) // Add a group chat to a video room, from the browser with Browser SDK v4.
// The page asks our server for a Subscriber Access Token (python/app.py:
// POST /token) and dials the room with audio and video. In a room call,
// call.address is the room's address: sendText() posts to the room's one
// thread, and textMessages$ is that thread, with paging for older messages.
import { SignalWire , StaticCredentialProvider } from "@signalwire/js" ;
const params = new URLSearchParams ( location . search );
const ROOM = params . get ( "room" ) ?? "team-standup" ;
// in a real app the server reads the user from its session; the query string
// is how this page is opened twice as two people
const USER = params . get ( "user" ) ?? "dana@example.test" ;
const NAME = params . get ( "name" ) ?? "Dana" ;
// the fields this page renders from a message in the thread
type ChatLine = { id : string ; text : string };
function isChatLine ( m : unknown ) : m is ChatLine {
const x = m as Partial < ChatLine > | null ;
return !! x && typeof x . id === "string" && typeof x . text === "string" ;
}
function render ( list : HTMLElement , messages : unknown []) : void {
list . replaceChildren ();
for ( const m of messages . filter ( isChatLine )) {
const li = document . createElement ( "li" );
li . textContent = m . text ;
list . appendChild ( li );
}
}
async function join ( root : HTMLElement ) : Promise < void > {
const res = await fetch ( "/token" , {
method : "POST" ,
headers : { "content-type" : "application/json" },
body : JSON.stringify ({ user : USER , display_name : NAME , room : ROOM }),
});
const { token , destination } = ( await res . json ()) as {
token : string ;
destination : string ;
};
const client = new SignalWire ( new StaticCredentialProvider ({ token }));
await client . connect ();
// destination is "/public/<room>"; audio + video makes this a video join
const call = await client . dial ( destination , { audio : true , video : true });
const video = document . createElement ( "video" );
video . autoplay = true ;
video . playsInline = true ;
root . appendChild ( video );
call . remoteStream$ . subscribe (( stream : MediaStream | undefined ) => {
if ( stream ) video . srcObject = stream ;
});
const list = document . getElementById ( "chat-log" ) as HTMLElement ;
const input = document . getElementById ( "chat-input" ) as HTMLInputElement ;
const send = document . getElementById ( "chat-send" ) as HTMLButtonElement ;
// The room's address arrives once the call is up. It is the same address
// for everyone in the room, so its thread is the room's group chat.
call . address$ . subscribe (( address ) => {
if ( ! address ) return ;
address . textMessages$ . subscribe (( thread ) => {
if ( ! thread ) return ;
thread . values$ . subscribe (( messages : unknown []) => render ( list , messages ));
// older pages exist while hasMore$ is true; fetch one when the reader
// scrolls to the top of the log
thread . hasMore$ . subscribe (( more : boolean ) => {
list . dataset . more = String ( more );
});
list . onscroll = () => {
if ( list . scrollTop === 0 && list . dataset . more === "true" ) thread . loadMore ();
};
});
send . onclick = async () => {
const text = input . value . trim ();
if ( ! text ) return ;
await address . sendText ( text ); // resolves when the platform accepts it
input . value = "" ;
};
});
document . getElementById ( "leave" ) ? . addEventListener ( "click" , () => {
void call . hangup ();
});
}
join ( document . getElementById ( "room" ) ! ). catch (( err ) =>
console . error ( "join failed" , err ),
); Run it cd python && pip install -r requirements.txt
cp ../.env.example .env # space, project, token, ROOM_NAME
python app.py
cd ../typescript && npm ci && npm start # Vite serves index.html and proxies /token to :8080 typescript/index.html carries the video slot and the chat controls, and vite.config.ts forwards /token to the Flask app, so the page and the server share one origin. Open the page twice with different ?user= and ?name= values to see two people in one thread. The room has to exist first; create-a-video-room-and-join-from-the-browser creates it.
Verify it python verify.py # from the recipe folder, not python/ With the HTTP layer recorded, the verifier asserts:
the route makes one POST to /api/fabric/subscribers/tokens with reference and display_name, and every field sent is documented the response carries the minted token and the room’s address, never the project API token a request naming no user is a 400 and sends nothing the page dials the destination and uses call.address$, textMessages$, values$, hasMore$, loadMore() and sendText() index.html loads the script and carries every element the script looks up, and the dev server forwards /tokenwith typescript/node_modules present, the page type-checks against the installed Browser SDK