diff --git a/.gitignore b/.gitignore
index 5e60302d3..13d3fa750 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ dist
.serena
.claude
.vscode
+.docs-review
.env
.env.*
!.env.example
diff --git a/fern/apis/signalwire-rest/openapi.yaml b/fern/apis/signalwire-rest/openapi.yaml
index fad774522..d5300170b 100644
--- a/fern/apis/signalwire-rest/openapi.yaml
+++ b/fern/apis/signalwire-rest/openapi.yaml
@@ -40907,6 +40907,37 @@ components:
description: Set or update the subscriber's password. Omit this field or pass an empty string if you don't want to update the password.
examples:
- password123
+ fingerprint:
+ type: string
+ minLength: 43
+ maxLength: 43
+ pattern: ^[A-Za-z0-9_-]+$
+ description: |-
+ Binds the token to a specific device or browser session, letting the
+ holder refresh it without going through your backend. The [Browser SDK](/docs/browser-sdk/v4)
+ generates this value automatically when starting a session — forward it
+ to your backend when requesting a token, so tie the token to that client.
+
+ Without `fingerprint`, your backend can still refresh the token using
+ the companion [`refresh_token`](/docs/apis/rest/subscribers/tokens/refresh-subscriber-token)
+ returned in this response.
+ examples:
+ - Vg1h7IDV3AR6kTpCkZPHOVs32B81DX1naHiHbYoKXgY
+ scope:
+ type: string
+ enum:
+ - sat:refresh
+ description: |-
+ Grants the token's holder permission to refresh it directly from the
+ Browser SDK client. Pair with `fingerprint` to bind the token to a
+ device.
+
+ Without this scope, your backend can still refresh the token using the
+ companion [`refresh_token`](/docs/apis/rest/subscribers/tokens/refresh-subscriber-token).
+ If `sat:refresh` is set without `fingerprint`, the token's lifetime is
+ limited to 60 seconds.
+ examples:
+ - sat:refresh
first_name:
type: string
description: Set or update the first name of the subscriber.
diff --git a/fern/docs.yml b/fern/docs.yml
index 57172c6d8..d628ee582 100644
--- a/fern/docs.yml
+++ b/fern/docs.yml
@@ -67,6 +67,10 @@ products:
icon: fa-regular fa-browser
subtitle: Build voice, video and chat applications for the browser
versions:
+ - display-name: v4
+ path: products/browser-sdk/versions/v4.yml
+ availability: beta
+ slug: v4
- display-name: v3
path: products/browser-sdk/versions/latest.yml
availability: stable
@@ -170,7 +174,6 @@ css:
- brand-overrides.css
- styles.css
-
redirects:
- source: /docs/agents-sdk
destination: /docs/server-sdks
@@ -180,6 +183,14 @@ redirects:
destination: /docs/server-sdks
- source: /docs/server-sdk/:slug*
destination: /docs/server-sdks
+ - source: /docs/browser-sdk/js
+ destination: /docs/browser-sdk/v3/js
+ - source: /docs/browser-sdk/js/:slug*
+ destination: /docs/browser-sdk/v3/js/:slug*
+ - source: /docs/browser-sdk/click-to-call
+ destination: /docs/browser-sdk/v3/click-to-call
+ - source: /docs/browser-sdk/click-to-call/:slug*
+ destination: /docs/browser-sdk/v3/click-to-call/:slug*
# SWML methods reorganized into calling/ and messaging/ subsections.
# The old methods overview lived at /docs/swml/reference, which was
diff --git a/fern/fern.config.json b/fern/fern.config.json
index 82cd17f93..0efa328fb 100644
--- a/fern/fern.config.json
+++ b/fern/fern.config.json
@@ -1,4 +1,4 @@
{
"organization": "signalwire",
- "version": "5.23.3"
+ "version": "5.26.4"
}
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_live-streaming.mdx.draft b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_live-streaming.mdx.draft
new file mode 100644
index 000000000..94365ab2c
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_live-streaming.mdx.draft
@@ -0,0 +1,138 @@
+---
+title: "Live Streaming"
+slug: /guides/live-streaming
+sidebar-title: "Live Streaming"
+position: 7
+max-toc-depth: 3
+---
+
+Live streaming on the SignalWire platform pushes a room's mixed feed
+to one or more RTMP destinations (YouTube Live, Twitch, a custom
+ingest server). It's a server-side feature — like
+[recording](/docs/browser-sdk/v4/guides/recording), the SDK's role is to observe
+streaming state on the active call.
+
+
+**Client-side `startStreaming()` is not yet implemented in v4.** The
+SDK ships an `async startStreaming()` stub on `Call` that throws
+`UnimplementedError`. Trigger streams server-side (REST or SWML); use
+`streaming$` to reflect status in your UI.
+
+
+## What you can do today
+
+- **Observe** whether streaming is active: `call.streaming$`.
+- **Render a "LIVE" indicator** driven by that observable.
+- **Start / stop streams** via:
+ - The REST API.
+ - A SWML script on the Resource.
+ - The SignalWire Dashboard.
+
+## Observing streaming state
+
+`streaming$` is a boolean BehaviorSubject — `true` whenever any
+stream is active on the call's session, `false` otherwise.
+
+```js
+call.streaming$.subscribe((isStreaming) => {
+ liveBadge.classList.toggle("visible", isStreaming);
+ liveBadge.textContent = isStreaming ? "● LIVE" : "";
+});
+```
+
+The badge updates instantly when streaming starts or stops, regardless
+of which client started it.
+
+## Starting a stream: server-side
+
+### From the REST API
+
+Issue a request against the call's session ID with the RTMP target:
+
+```bash
+curl -X POST "https://yourspace.signalwire.com/api/video/room_sessions/{id}/streams" \
+ -u "PROJECT_ID:API_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "rtmps://a.rtmps.youtube.com/live2/STREAM_KEY"
+ }'
+```
+
+The full set of stream endpoints (create, list, stop, etc.) is in
+the [REST API reference](/docs/apis).
+
+### From SWML
+
+A `stream` verb in the room's SWML automatically starts a stream when
+the session begins:
+
+```yaml
+sections:
+ main:
+ - stream:
+ url: rtmps://a.rtmps.youtube.com/live2/STREAM_KEY
+```
+
+### From the Dashboard
+
+For ad-hoc stream targets, configure the Resource's streaming
+settings and toggle on join.
+
+## Multiple destinations
+
+The session can run multiple streams concurrently — useful for
+simulcasting to YouTube and Twitch at the same time. Each is created
+through a separate REST call (or a separate `stream` verb in SWML).
+The `streaming$` observable reports a single boolean: `true` if any
+of them are active.
+
+If you need to enumerate the individual stream targets, read them
+from `call.signalingEvent$` payloads (the room session state
+includes the current `streams` list):
+
+```js
+call.signalingEvent$.subscribe((event) => {
+ if (event.event_type === "call.updated") {
+ console.log("streams:", event.params?.room_session?.streams);
+ }
+});
+```
+
+## Capability flag
+
+`call.self.capabilities` does not include a streaming-specific flag
+in v4 — streaming is a session-level operation governed by
+server-side policy. Anyone in the room sees `streaming$` flip, but
+only authorized callers (typically your backend) can start or stop
+streams via the REST API.
+
+## Roadmap
+
+[`Call.startStreaming()`] and `Call.stopStreaming()` are planned.
+Until they land, the SDK contract is:
+
+- **Read**: `streaming$` / `streaming` (works today).
+- **Write**: server-side only (works today, via REST / SWML /
+ Dashboard).
+
+For per-call recording, see [Recording](/docs/browser-sdk/v4/guides/recording). The
+two features behave similarly today — observe-only on the SDK,
+write-only on the platform.
+
+## Viewer-side: interactive live streams
+
+If your app is *consuming* a stream (rather than producing one), the
+playback flow is plain HTTP — point an HLS player at the URL the
+platform publishes, no SDK involved. The Browser SDK is only relevant
+when the viewer needs to *participate* in the room (chat, raise hand,
+join as a video participant). For that flow, treat them as a normal
+inbound or outbound call against the Resource Address; the platform
+mixes their media into the same session that's being streamed.
+
+## Reference
+
+- [`Call.streaming$`] — reactive streaming state
+- [`Call.startStreaming()`] — server-side mutator (not yet implemented in v4)
+
+[`Call.streaming$`]: /docs/browser-sdk/v4/reference/webrtc-call/streaming$
+[`Call.startStreaming()`]: /docs/browser-sdk/v4/reference/webrtc-call/start-streaming
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_recording.mdx.draft b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_recording.mdx.draft
new file mode 100644
index 000000000..ebe069bf6
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_recording.mdx.draft
@@ -0,0 +1,143 @@
+---
+title: "Recording"
+slug: /guides/recording
+sidebar-title: "Recording"
+position: 6
+max-toc-depth: 3
+---
+
+Recording on the SignalWire platform is a server-side capability —
+the media mix is recorded in the cloud, not in the browser. The
+Browser SDK's role today is to **observe** recording state on the
+active call. It surfaces a `recording$` boolean observable; the
+recording itself is started, stopped, and downloaded through the
+platform (Dashboard, REST API, or SWML).
+
+
+**Client-side `startRecording()` is not yet implemented in v4.** The
+SDK ships an `async startRecording()` stub on `Call` that throws
+`UnimplementedError`. Until it lands, trigger recording through one
+of the server-side paths below and use the observables to reflect
+status in your UI.
+
+
+## What you can do today
+
+- **Observe** whether a recording is active: `call.recording$`.
+- **Render a "REC" badge** in your UI driven by that observable.
+- **Trigger recording** server-side via:
+ - Configuring the Resource (room, SWML script) to auto-record on
+ join.
+ - The SignalWire REST API.
+ - A SWML script that runs on the call leg.
+
+## Observing recording state
+
+`recording$` emits a boolean — `true` whenever any recording is
+active on the call's session, `false` otherwise. It's a
+BehaviorSubject, so late subscribers get the current state
+synchronously.
+
+```js
+call.recording$.subscribe((isRecording) => {
+ recordingBadge.classList.toggle("visible", isRecording);
+ recordingBadge.textContent = isRecording ? "● REC" : "";
+});
+```
+
+That's all most apps need — a visible indicator so participants know
+the session is being recorded.
+
+## Pulling more detail
+
+The full recording state (file IDs, current duration, format, paused
+status, etc.) is sent by the server inside `call.joined` and
+`call.updated` events. The SDK exposes the resulting state via
+`call.signalingEvent$` if you need to inspect the raw payload:
+
+```js
+call.signalingEvent$.subscribe((event) => {
+ if (event.event_type === "call.updated") {
+ // event.params.room_session.recordings has the list
+ console.log("recordings:", event.params?.room_session?.recordings);
+ }
+});
+```
+
+This is escape-hatch territory — most apps shouldn't need it. When
+the `start/stop/pause` mutator methods land on the SDK, you'll get a
+cleaner API for the same data.
+
+## Starting a recording: server-side options
+
+### From a SWML script on the Resource
+
+Use the `record` verb in the room's SWML to begin recording as soon
+as the call connects:
+
+```yaml
+sections:
+ main:
+ - record:
+ stereo: true
+ format: mp4
+```
+
+### From the REST API
+
+Issue a request against the call's session ID. The platform
+identifies the active room session and starts recording.
+
+```bash
+curl -X POST "https://yourspace.signalwire.com/api/video/room_sessions/{id}/recordings" \
+ -u "PROJECT_ID:API_TOKEN" \
+ -H "Content-Type: application/json"
+```
+
+Refer to the [REST API reference](/docs/apis) for the full set of
+recording endpoints (start, stop, list, download).
+
+### From the Dashboard
+
+For ad-hoc recording on a specific Resource, toggle "Record on join"
+in the Resource configuration. Useful for support / sales workflows
+where every call should be archived.
+
+## Capability flag
+
+`call.self.capabilities` does **not** currently include a `record`
+flag — recording isn't a per-participant capability in v4. Anyone in
+a room may know about a recording in progress (via the `recording$`
+observable), but starting / stopping is governed entirely by
+server-side policy.
+
+## What about stopping?
+
+When recording is started server-side, the same channel stops it. The
+SDK will see `recording$` flip to `false` and your "REC" badge will
+clear. Don't try to call `call.startRecording()` / no equivalent
+client-side stop is reachable today.
+
+## Roadmap
+
+[`Call.startRecording()`] / `Call.stopRecording()` / pause/resume are
+planned. Until they ship, the contract is:
+
+- **Read**: `recording$` / `recording` (works today).
+- **Write**: server-side only (works today, via REST / SWML /
+ Dashboard).
+
+When the SDK methods become available, this guide will grow examples
+for starting and stopping inline. Watch the [changelog](/docs/browser-sdk/v4/changelog)
+or the JS reference for the introduction of the methods.
+
+For livestreaming an active call (which behaves similarly), see
+[Live Streaming](/docs/browser-sdk/v4/guides/live-streaming).
+
+## Reference
+
+- [`Call.recording$`] — reactive recording state
+- [`Call.startRecording()`] — server-side mutator (not yet implemented in v4)
+
+[`Call.recording$`]: /docs/browser-sdk/v4/reference/webrtc-call/recording$
+[`Call.startRecording()`]: /docs/browser-sdk/v4/reference/webrtc-call/start-recording
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx
new file mode 100644
index 000000000..c22a909d2
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx
@@ -0,0 +1,260 @@
+---
+title: "Call Controls"
+slug: /guides/call-controls
+sidebar-title: "Call Controls"
+position: 8
+max-toc-depth: 3
+---
+
+Every control on a call follows the same shape: **call a mutator,
+listen on the matching `$` observable for state.** Don't track local
+state in a `let isMuted` variable — the server is the source of truth
+and can flip it (a moderator can mute you, the room can lock itself,
+the platform can disconnect). The observable is the only honest
+answer to "are we muted right now."
+
+Once you internalize that pattern, every control on the SDK is the
+same code. This page teaches the pattern; the [reference] has the
+full surface.
+
+## The pattern
+
+```js
+call.self$.subscribe((self) => {
+ if (!self) return;
+
+ // Trigger
+ muteBtn.onclick = () => self.toggleMute();
+
+ // Reflect — fires once with the current state, then on every change
+ self.audioMuted$.subscribe((muted) => {
+ muteBtn.classList.toggle("muted", muted);
+ muteBtn.textContent = muted ? "Unmute" : "Mute";
+ });
+});
+```
+
+That snippet is the canonical shape for *every* button on a call UI.
+Swap `toggleMute` / `audioMuted$` for whichever pair you need:
+[`toggleMuteVideo`] / [`videoMuted$`], [`toggleDeaf`] / [`deaf$`],
+[`toggleHandraise`] / [`handraised$`], and so on.
+
+A few things make this pattern work cleanly:
+
+- **Toggles are idempotent.** Calling [`toggleMute`] while a network
+ round-trip is in flight is safe — the SDK serializes.
+- **`$` observables emit on subscribe.** You get the current value
+ immediately, no "wait for an event" dance.
+- **The mutator only triggers the change.** The observable is what
+ closes the loop. If a moderator mutes you, your `toggleMute` button
+ click was never sent, but `audioMuted$` still emits `true` — and
+ your UI updates with no extra code.
+
+## Where each control lives
+
+Three objects own the controls:
+
+- **`Call`** — session-level: [hangup][`Call.hangup()`],
+ [send DTMF][`Call.sendDigits()`], [lock][`Call.toggleLock()`],
+ [hold][`Call.toggleHold()`], [transfer][`Call.transfer()`],
+ layout (see [Layouts](/docs/browser-sdk/v4/guides/layouts)).
+- **`SelfParticipant`** (`call.self`) — your own state: mute, deaf,
+ hand raise, [screen share](/docs/browser-sdk/v4/guides/screen-sharing),
+ audio processing, your volume.
+- **`Participant`** (entries in `call.participants$`) — moderation
+ actions on *other* members. Gated by capabilities — see below.
+
+If you can't find a control, it's because it doesn't live where you
+expected. The split exists because the server-side authorization
+model is different for each layer: ending a call needs `end`
+capability, kicking someone needs `member.remove`, muting yourself is
+unconditional.
+
+## Two non-obvious distinctions
+
+### Mute vs. deaf
+
+[Mute][`audioMuted$`] silences what *you* send. [Deaf][`deaf$`]
+silences what *you* hear. They're independent — you can be deaf
+without being muted (you keep talking, but you can't hear responses).
+Useful when the user steps away briefly without leaving the room.
+
+### Mute vs. hold vs. push-to-talk
+
+Three ways to stop transmitting audio, and they're not interchangeable:
+
+| Action | What it does | Latency | Use for |
+| ---------------------------- | -------------------------------------------------- | -------------------------------- | ----------------------------- |
+| [`toggleMute`][`audioMuted$`] | Disables the audio track server-side | Round-trip | Standard mute button |
+| [`toggleHold`][`Call.toggleHold()`] | Pauses media transmission for the whole call | Round-trip | "Be right back" / call park |
+| Push-to-talk (local pipeline) | Sets local mic gain to 0 — track stays alive | Instant (no round-trip) | Walkie-talkie UIs |
+
+For instant talk/silence transitions (e.g. holding spacebar), use
+push-to-talk — mute would feel laggy because the round-trip is visible
+to the user:
+
+```js
+call.enablePushToTalk();
+document.addEventListener("keydown", (e) => {
+ if (e.code === "Space") call.setPushToTalkActive(true);
+});
+document.addEventListener("keyup", (e) => {
+ if (e.code === "Space") call.setPushToTalkActive(false);
+});
+```
+
+The local audio pipeline also gives you [`localAudioLevel$`] for a
+real-time meter and [`localSpeaking$`] for VAD-based speaking
+detection — both are observables of the local mic, computed
+client-side, fast enough for ~30fps UI updates.
+
+## DTMF, timing matters
+
+[`sendDigits`][`Call.sendDigits()`] only succeeds once `status$` is
+`'connected'`. Sending before media is negotiated will fail or be
+dropped:
+
+```js
+import { filter, take } from "rxjs";
+
+call.status$
+ .pipe(filter((s) => s === "connected"), take(1))
+ .subscribe(async () => {
+ await call.sendDigits("1234#");
+ });
+```
+
+For interactive dialpads (digits sent as the user presses), wire
+the button click directly — by that point the call is connected.
+
+## Moderation — check the capability first
+
+Methods on other participants exist (`participant.mute()`,
+`participant.remove()`, `participant.setPosition()`), but calling them
+without the corresponding capability throws server-side. Drive the UI
+off [`SelfCapabilities.member$`]:
+
+```js
+call.self?.capabilities.member$.subscribe((member) => {
+ kickButton.hidden = !member.remove;
+ muteOthersButton.disabled = !member.muteAudio.on;
+});
+```
+
+The flag is the server's authoritative answer. If it's false, hide
+the button — don't show a button that will error out. The
+[Capabilities](/docs/browser-sdk/v4/guides/capabilities) guide
+covers the full model.
+
+## Putting it together
+
+A minimal but realistic control bar — every button uses the same
+mutator+observable pattern as the canonical snippet at the top:
+
+```js
+call.self$.subscribe((self) => {
+ if (!self) return;
+
+ // Triggers
+ muteBtn.onclick = () => self.toggleMute();
+ videoBtn.onclick = () => self.toggleMuteVideo();
+ deafBtn.onclick = () => self.toggleDeaf();
+ handBtn.onclick = () => self.toggleHandraise();
+ hangupBtn.onclick = () => call.hangup();
+
+ // Reflections
+ self.audioMuted$.subscribe((m) => muteBtn.classList.toggle("muted", m));
+ self.videoMuted$.subscribe((m) => videoBtn.classList.toggle("muted", m));
+ self.deaf$.subscribe((d) => deafBtn.classList.toggle("active", d));
+ self.handraised$.subscribe((h) => handBtn.classList.toggle("active", h));
+});
+```
+
+That's the muscle. Everything else (volume sliders, audio processing
+toggles, the screen share button, moderation actions) is the same
+shape with different names — and every name is on the [reference].
+
+## Reference
+
+[reference]: /docs/browser-sdk/v4/reference
+
+**Self mute / audio state**
+
+- [`Participant.toggleMute()`] / [`mute()`] / [`unmute()`] · [`audioMuted$`]
+- [`Participant.toggleMuteVideo()`] / [`muteVideo()`] / [`unmuteVideo()`] · [`videoMuted$`]
+- [`Participant.toggleDeaf()`] · [`deaf$`]
+- [`Participant.toggleHandraise()`] · [`handraised$`] · [`Call.raiseHandPriority$`]
+
+**Audio processing**
+
+- [`Participant.toggleEchoCancellation()`] · [`echoCancellation$`]
+- [`Participant.toggleAudioInputAutoGain()`] · [`autoGain$`]
+- [`Participant.toggleNoiseSuppression()`] · [`noiseSuppression$`]
+- [`SelfParticipant.enableStudioAudio()`] / [`disableStudioAudio()`] · [`studioAudio$`]
+
+**Volumes (server-mixed)**
+
+- [`Participant.setAudioInputVolume()`] · [`inputVolume$`]
+- [`Participant.setAudioOutputVolume()`] · [`outputVolume$`]
+- [`Participant.setAudioInputSensitivity()`] · [`inputSensitivity$`]
+
+**Call-level**
+
+- [`Call.hangup()`], [`Call.sendDigits()`], [`Call.toggleLock()`] · [`locked$`], [`Call.toggleHold()`], [`Call.transfer()`]
+- [`Call.setLocalMicrophoneGain()`], [`Call.localAudioLevel$`], [`Call.localSpeaking$`], [`Call.enablePushToTalk()`], [`Call.setPushToTalkActive()`]
+
+**Moderation**
+
+- [`Participant.remove()`], [`Participant.end()`], [`Participant.setPosition()`]
+- Gated by [`SelfCapabilities`] · see [Capabilities](/docs/browser-sdk/v4/guides/capabilities)
+
+[`Participant.toggleMute()`]: /docs/browser-sdk/v4/reference/participant/toggle-mute
+[`toggleMute`]: /docs/browser-sdk/v4/reference/participant/toggle-mute
+[`mute()`]: /docs/browser-sdk/v4/reference/participant/mute
+[`unmute()`]: /docs/browser-sdk/v4/reference/participant/unmute
+[`audioMuted$`]: /docs/browser-sdk/v4/reference/participant/audio-muted$
+[`Participant.toggleMuteVideo()`]: /docs/browser-sdk/v4/reference/participant/toggle-mute-video
+[`toggleMuteVideo`]: /docs/browser-sdk/v4/reference/participant/toggle-mute-video
+[`muteVideo()`]: /docs/browser-sdk/v4/reference/participant/mute-video
+[`unmuteVideo()`]: /docs/browser-sdk/v4/reference/participant/unmute-video
+[`videoMuted$`]: /docs/browser-sdk/v4/reference/participant/video-muted$
+[`Participant.toggleDeaf()`]: /docs/browser-sdk/v4/reference/participant/toggle-deaf
+[`toggleDeaf`]: /docs/browser-sdk/v4/reference/participant/toggle-deaf
+[`deaf$`]: /docs/browser-sdk/v4/reference/participant/deaf$
+[`Participant.toggleHandraise()`]: /docs/browser-sdk/v4/reference/participant/toggle-handraise
+[`toggleHandraise`]: /docs/browser-sdk/v4/reference/participant/toggle-handraise
+[`handraised$`]: /docs/browser-sdk/v4/reference/participant/handraised$
+[`Call.raiseHandPriority$`]: /docs/browser-sdk/v4/reference/webrtc-call/raise-hand-priority$
+[`Participant.toggleEchoCancellation()`]: /docs/browser-sdk/v4/reference/participant/toggle-echo-cancellation
+[`echoCancellation$`]: /docs/browser-sdk/v4/reference/participant/echo-cancellation$
+[`Participant.toggleAudioInputAutoGain()`]: /docs/browser-sdk/v4/reference/participant/toggle-audio-input-auto-gain
+[`autoGain$`]: /docs/browser-sdk/v4/reference/participant/auto-gain$
+[`Participant.toggleNoiseSuppression()`]: /docs/browser-sdk/v4/reference/participant/toggle-noise-suppression
+[`noiseSuppression$`]: /docs/browser-sdk/v4/reference/participant/noise-suppression$
+[`SelfParticipant.enableStudioAudio()`]: /docs/browser-sdk/v4/reference/self-participant/enable-studio-audio
+[`disableStudioAudio()`]: /docs/browser-sdk/v4/reference/self-participant/disable-studio-audio
+[`studioAudio$`]: /docs/browser-sdk/v4/reference/self-participant/studio-audio$
+[`Participant.setAudioInputVolume()`]: /docs/browser-sdk/v4/reference/participant/set-audio-input-volume
+[`inputVolume$`]: /docs/browser-sdk/v4/reference/participant/input-volume$
+[`Participant.setAudioOutputVolume()`]: /docs/browser-sdk/v4/reference/participant/set-audio-output-volume
+[`outputVolume$`]: /docs/browser-sdk/v4/reference/participant/output-volume$
+[`Participant.setAudioInputSensitivity()`]: /docs/browser-sdk/v4/reference/participant/set-audio-input-sensitivity
+[`inputSensitivity$`]: /docs/browser-sdk/v4/reference/participant/input-sensitivity$
+[`Call.hangup()`]: /docs/browser-sdk/v4/reference/webrtc-call/hangup
+[`Call.sendDigits()`]: /docs/browser-sdk/v4/reference/webrtc-call/send-digits
+[`Call.toggleLock()`]: /docs/browser-sdk/v4/reference/webrtc-call/toggle-lock
+[`locked$`]: /docs/browser-sdk/v4/reference/webrtc-call/locked$
+[`Call.toggleHold()`]: /docs/browser-sdk/v4/reference/webrtc-call/toggle-hold
+[`Call.transfer()`]: /docs/browser-sdk/v4/reference/webrtc-call/transfer
+[`Call.setLocalMicrophoneGain()`]: /docs/browser-sdk/v4/reference/webrtc-call/set-local-microphone-gain
+[`Call.localAudioLevel$`]: /docs/browser-sdk/v4/reference/webrtc-call/local-audio-level$
+[`localAudioLevel$`]: /docs/browser-sdk/v4/reference/webrtc-call/local-audio-level$
+[`Call.localSpeaking$`]: /docs/browser-sdk/v4/reference/webrtc-call/local-speaking$
+[`localSpeaking$`]: /docs/browser-sdk/v4/reference/webrtc-call/local-speaking$
+[`Call.enablePushToTalk()`]: /docs/browser-sdk/v4/reference/webrtc-call/enable-push-to-talk
+[`Call.setPushToTalkActive()`]: /docs/browser-sdk/v4/reference/webrtc-call/set-push-to-talk-active
+[`Participant.remove()`]: /docs/browser-sdk/v4/reference/participant/remove
+[`Participant.end()`]: /docs/browser-sdk/v4/reference/participant/end
+[`Participant.setPosition()`]: /docs/browser-sdk/v4/reference/participant/set-position
+[`SelfCapabilities`]: /docs/browser-sdk/v4/reference/self-capabilities
+[`SelfCapabilities.member$`]: /docs/browser-sdk/v4/reference/self-capabilities/member$
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx
new file mode 100644
index 000000000..b9a6aa799
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx
@@ -0,0 +1,466 @@
+---
+title: "Device Management"
+subtitle: "Pick the mic, camera, and speaker — before a call and on the fly"
+slug: /guides/device-management
+sidebar-title: "Device Management"
+position: 4
+max-toc-depth: 3
+---
+
+To let users pick which microphone, camera, and speaker a call uses, subscribe to the device lists, render a picker, and apply the user's choice — either as a preference for the next call or as a live swap during one. The same APIs cover hot-plug events, so a new headset shows up in the list as soon as it's connected.
+
+```ts Browser
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
+
+// 1. Prompt for permission so devices come back with real labels.
+await client.requestMediaPermissions({ audio: true, video: true });
+
+// 2. Render the lists; they re-emit when devices are plugged or unplugged.
+client.audioInputDevices$.subscribe((mics) => renderMicOptions(mics));
+client.videoInputDevices$.subscribe((cams) => renderCamOptions(cams));
+client.audioOutputDevices$.subscribe((speakers) => renderSpeakerOptions(speakers));
+
+// 3. Apply the user's choice. Before a call, set the preference on the client;
+// during a call, switch the live track on `call.self`.
+micSelect.onchange = () => {
+ const mic = client.audioInputDevices.find((d) => d.deviceId === micSelect.value);
+ if (activeCall?.self) activeCall.self.selectAudioInputDevice(mic);
+ else client.selectAudioInputDevice(mic);
+};
+```
+
+The sections below cover requesting permission, reading the device lists, applying the user's pick before a call and again mid-call, routing remote audio to the chosen speaker, and reacting when a device disappears.
+
+
+**Before you start.** Devices live behind the browser's permission gate. `getUserMedia` only runs on secure origins (HTTPS or `localhost`), and device labels are empty strings until the user has granted access at least once. Plan the UX so picking a device comes *after* the prompt — not the other way around.
+
+
+## The three device kinds
+
+Each kind comes with its own pair of observables: one for the list of available devices, and one for the current selection. Subscribe to both whenever you render a picker so the `` reflects what's actually in use — including changes that happen without an explicit user pick, such as a recovery after the active device was unplugged.
+
+
+
+ [`audioInputDevices$`](/docs/browser-sdk/v4/reference/signalwire/audio-input-devices$) lists every mic; [`selectedAudioInputDevice$`](/docs/browser-sdk/v4/reference/signalwire/selected-audio-input-device$) tracks the current pick.
+
+
+ [`videoInputDevices$`](/docs/browser-sdk/v4/reference/signalwire/video-input-devices$) lists every camera; [`selectedVideoInputDevice$`](/docs/browser-sdk/v4/reference/signalwire/selected-video-input-device$) tracks the current pick.
+
+
+ [`audioOutputDevices$`](/docs/browser-sdk/v4/reference/signalwire/audio-output-devices$) lists every speaker; [`selectedAudioOutputDevice$`](/docs/browser-sdk/v4/reference/signalwire/selected-audio-output-device$) tracks the current pick.
+
+
+
+When you only need the current snapshot — for example, populating a dropdown once on click — read the non-`$` accessor instead: `client.audioInputDevices`, `client.videoInputDevices`, `client.audioOutputDevices`. Use the observable when the UI should keep up with hot-plugs and SDK-driven switches.
+
+## Prompt for permission
+
+Each list populates from `navigator.mediaDevices.enumerateDevices()`. Until the user grants permission, that call returns devices with empty `label` strings, which makes a picker UI useless. Call [`requestMediaPermissions()`](/docs/browser-sdk/v4/reference/signalwire/request-media-permissions) once at startup to drive the prompt and re-enumerate with labels filled in:
+
+```ts Browser
+const result = await client.requestMediaPermissions({ audio: true, video: true });
+
+if (!result.audio || !result.video) {
+ showPermissionBanner("Allow microphone and camera in your browser to continue.");
+}
+```
+
+The returned [`PermissionResult`](/docs/browser-sdk/v4/reference/interfaces/permission-result) reports which scopes were granted and which device the browser handed back for each kind. Those devices become the initial selection unless you've already set a preference. The browser only shows the prompt the first time per origin; later calls resolve immediately with the existing grant.
+
+If the user denies the prompt, `enumerateDevices()` still returns the device list, but with empty labels. Show a "permission needed" banner instead of an empty dropdown, and link to the browser's lock-icon controls so they can grant it later.
+
+## Read the device lists
+
+Each list is an observable of `MediaDeviceInfo[]`. Subscribe once at startup — every subscription receives the current list immediately and then again whenever the OS reports a change. Pair the list with the matching `selected…$` observable so a `` reflects the right value when the SDK switches devices on its own:
+
+```ts Browser
+let mics = [];
+let selectedMic = null;
+
+function renderMics() {
+ micSelect.innerHTML = "";
+ for (const mic of mics) {
+ const option = new Option(mic.label || `Microphone ${mic.deviceId.slice(0, 6)}`, mic.deviceId);
+ option.selected = mic.deviceId === selectedMic?.deviceId;
+ micSelect.append(option);
+ }
+}
+
+client.audioInputDevices$.subscribe((list) => {
+ mics = list;
+ renderMics();
+});
+
+client.selectedAudioInputDevice$.subscribe((device) => {
+ selectedMic = device;
+ renderMics();
+});
+```
+
+The same pattern fits `videoInputDevices$` and `audioOutputDevices$` — only the property names change. Caching the latest of each list in module-scope state keeps the rendering pure and avoids pulling in observable combinators.
+
+## Apply the user's choice
+
+There are two scopes for "use this device." Match the scope to the moment the user picks:
+
+
+
+ Set a preference on the client. The selection sticks for every future [`dial()`](/docs/browser-sdk/v4/reference/signalwire/dial) and [`answer()`](/docs/browser-sdk/v4/reference/interfaces/call), so the next outbound call is captured from the right mic and camera without any extra wiring. Wire the picker's `change` event to look up the chosen `MediaDeviceInfo` from the live list and pass it to [`selectAudioInputDevice()`](/docs/browser-sdk/v4/reference/signalwire/select-audio-input-device) or [`selectVideoInputDevice()`](/docs/browser-sdk/v4/reference/signalwire/select-video-input-device):
+
+ ```ts Browser
+ // populated from client.audioInputDevices$
+ micSelect.onchange = () => {
+ const mic = client.audioInputDevices.find(
+ (d) => d.deviceId === micSelect.value,
+ );
+ // null clears the preference and falls back to the system default.
+ client.selectAudioInputDevice(mic ?? null);
+ };
+
+ camSelect.onchange = () => {
+ const camera = client.videoInputDevices.find(
+ (d) => d.deviceId === camSelect.value,
+ );
+ client.selectVideoInputDevice(camera ?? null);
+ };
+
+ // Subsequent dials use the preference automatically.
+ const call = await client.dial("/private/alice", { audio: true, video: true });
+ ```
+
+ The next `dial()` captures from the chosen mic and camera with no extra arguments. To change the default back later, look up a new device the same way, or pass `null` to clear the preference.
+
+
+ Replace the live track on [`call.self`](/docs/browser-sdk/v4/reference/self-participant). The user hears or sees the new device immediately, without renegotiation, and the remote side doesn't notice the switch. The shape is the same picker handler — only the target changes from `client` to `activeCall.self`:
+
+ ```ts Browser
+ // While a call is in progress, swap the live track instead of setting
+ // a preference for the next call.
+ micSelect.onchange = () => {
+ const mic = client.audioInputDevices.find(
+ (d) => d.deviceId === micSelect.value,
+ );
+ if (!mic) return;
+ // `savePreference: true` also stores the pick at the client level so
+ // the next session starts with the same device selected.
+ activeCall.self.selectAudioInputDevice(mic, { savePreference: true });
+ };
+
+ camSelect.onchange = () => {
+ const camera = client.videoInputDevices.find(
+ (d) => d.deviceId === camSelect.value,
+ );
+ if (!camera) return;
+ activeCall.self.selectVideoInputDevice(camera, { savePreference: true });
+ };
+ ```
+
+ See [`call.self.selectAudioInputDevice()`](/docs/browser-sdk/v4/reference/self-participant/select-audio-input-device) and [`call.self.selectVideoInputDevice()`](/docs/browser-sdk/v4/reference/self-participant/select-video-input-device) for the full options object — `constraints` and `savePreference` round out the supported keys.
+
+
+
+In practice, a single picker handler covers both scopes — fall through to the client-level preference when there's no active call, replace the live track when there is:
+
+```ts Browser
+let activeCall = null;
+
+function pickMicrophone(): void {
+ const mic = client.audioInputDevices.find(
+ (d) => d.deviceId === micSelect.value,
+ );
+ if (!mic) return;
+
+ if (activeCall?.self) {
+ // Live swap; mid-call only.
+ activeCall.self.selectAudioInputDevice(mic, { savePreference: true });
+ } else {
+ // Preference for the next dial()/answer().
+ client.selectAudioInputDevice(mic);
+ }
+}
+
+micSelect.onchange = pickMicrophone;
+
+// Keep `activeCall` in sync with whatever call the user is on right now.
+client.dial("/private/alice").then((call) => {
+ activeCall = call;
+ call.status$.subscribe((s) => {
+ if (s === "destroyed") activeCall = null;
+ });
+});
+```
+
+Speakers follow a slightly different path — see the next section.
+
+## Route remote audio to the chosen speaker
+
+Selecting a speaker takes an extra step the other devices don't — most browsers route audio output through the `` or `` element the remote stream is attached to, and switching the sink is an element-level call. [`applySelectedAudioOutputDevice()`](/docs/browser-sdk/v4/reference/signalwire/apply-selected-audio-output-device) wraps `HTMLMediaElement.setSinkId()` so you don't have to:
+
+```ts Browser
+client.selectAudioOutputDevice(speaker);
+const applied = await client.applySelectedAudioOutputDevice(remoteVideo);
+if (!applied) {
+ // Browser doesn't support setSinkId — fall through to the system default.
+}
+```
+
+The method returns `true` when the sink was changed and `false` when no speaker is selected or the browser doesn't support `setSinkId`. Call it once after binding the remote stream to the element, and again any time the user picks a new speaker.
+
+
+**Firefox doesn't ship `setSinkId` yet.** `applySelectedAudioOutputDevice()` returns `false` on Firefox and audio plays through the system default speaker. Check the return value and surface a "speaker selection unavailable" notice rather than silently swallowing the choice — see [the MDN compatibility table](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility).
+
+
+## React to device changes
+
+Hot-plug events are already covered by the list observables — adding a USB headset re-emits `audioInputDevices$` with the new entry. What you usually want on top of that is a notification when the SDK *automatically switches* a device because the previous one was unplugged. Subscribe to [`deviceRecovered$`](/docs/browser-sdk/v4/reference/signalwire/device-recovered$):
+
+```ts Browser
+client.deviceRecovered$.subscribe((event) => {
+ toast(`${event.kind} switched to ${event.newDevice?.label ?? "system default"}`);
+});
+```
+
+The [`DeviceRecoveryEvent`](/docs/browser-sdk/v4/reference/interfaces/device-recovery-event) carries everything you need to compose that notification:
+
+| Field | What it tells you |
+| ---------------- | ---------------------------------------------------------------------------------------- |
+| `kind` | Which kind switched: `audioinput`, `videoinput`, or `audiooutput` |
+| `previousDevice` | The device that was active before the swap (may be `null` if it was unplugged) |
+| `newDevice` | The device the SDK switched to (may be `null` if it fell back to the system default) |
+| `reason` | Why: `device_disconnected`, `device_reconnected`, `fallback_to_default`, and a few more |
+
+Reach for it when the user should know "we lost your AirPods and put you on the built-in mic" rather than discovering it mid-sentence.
+
+Device monitoring is on by default. Use [`disableDeviceMonitoring()`](/docs/browser-sdk/v4/reference/signalwire/disable-device-monitoring) and [`enableDeviceMonitoring()`](/docs/browser-sdk/v4/reference/signalwire/enable-device-monitoring) to pause and resume it — handy on mobile when the page goes into the background and you don't want hot-plug events firing while the user can't see the UI.
+
+## Try it: enumerate and switch devices
+
+The fastest way to see the device APIs end-to-end is a single page that wires the three observable lists to three `` elements, dials a test destination on demand, and switches the mic, camera, or speaker on the live call from the same picker. The handler routes preference changes through `client.*` before the call and live-track swaps through `activeCall.self.*` while the call is up — so the same dropdowns exercise both branches of the [Apply the user's choice](#apply-the-users-choice) section.
+
+
+
+### Issue a Subscriber Access Token
+
+Create a SAT for your project — the [Authentication guide](/docs/browser-sdk/v4/guides/authentication) covers the production version of this flow; the [Create Subscriber Token](/docs/apis/rest/subscribers/tokens/create-subscriber-token) reference will send the request for you.
+
+
+
+### Open the demo and request permission
+
+Save the page below as `devices-demo.html` and open it over HTTPS (or `localhost`). Paste the SAT and click **Connect** — the log reports the WebSocket coming up. Click **Request permission** to drive the browser's prompt; the three dropdowns populate with labelled devices.
+
+Pick a different mic, camera, or speaker before dialing. The log records each pick as `preference: …` — that's the `client.select*` branch in action. Plug or unplug a USB headset to see `deviceRecovered$` fire.
+
+
+```html
+
+
+
+
+ SignalWire SDK device-management demo
+
+
+
+ SignalWire SDK device-management demo
+
+ Subscriber Access Token
+
+
+ Connect
+ Request permission
+
+
+
+ Microphone
+ (none)
+
+
+ Camera
+ (none)
+
+
+ Speaker
+ (none)
+
+
+
+ Destination
+
+
+ Dial
+ Hang up
+
+
+
+
+
+
+
+
+
+
+
+```
+
+
+### Switch a device mid-call
+
+Enter a destination in the **Destination** field (a `/public/`, `/private/`, PSTN number, or SIP URI the token can reach — see [Outbound Calls](/docs/browser-sdk/v4/guides/outbound-calls#pick-a-destination) for the destination shapes) and click **Dial**. The local and remote tiles populate once the call connects.
+
+With the call connected, change the **Microphone** or **Camera** dropdown again. The log now records the change as `live: …` — the picker is routing through `activeCall.self.selectAudioInputDevice()` instead of the client-level preference, and the remote side hears or sees the new device immediately without renegotiation. Picking a new **Speaker** triggers `applySelectedAudioOutputDevice()` on the remote `` element so the sink swaps too.
+
+Click **Hang up** and try the dropdowns again — the log goes back to `preference: …` because `activeCall` is `null`. That's the combined handler from the [Apply the user's choice](#apply-the-users-choice) section, end to end.
+
+
+
+## Next steps
+
+
+
+ Black video, missing audio, denied permissions.
+
+
+ Every device-related property and method on the client.
+
+
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx
new file mode 100644
index 000000000..202a72eec
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx
@@ -0,0 +1,425 @@
+---
+title: "Inbound Calls"
+slug: /guides/inbound-calls
+sidebar-title: "Inbound Calls"
+position: 3
+max-toc-depth: 3
+---
+
+To receive calls in a web app, bring a signed-in user online, show a ringing UI when someone calls them, and let them accept or decline. The result is a receiver you can call from any phone, SIP endpoint, or another browser tab.
+
+```ts Browser
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+import { filter, take } from "rxjs";
+
+// Constructing the client authenticates and registers the subscriber
+// automatically. `await register()` here gives a sync point before we
+// subscribe — see the Authentication guide for the credential lifecycle.
+const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
+await client.register();
+
+// `showIncomingCall` / `hideIncomingCall` are your own UI helpers; `localVideo`
+// and `remoteVideo` are references to your `` elements. The SDK doesn't
+// ship a UI — render the ringing state however fits your app.
+client.session.incomingCalls$.subscribe((calls) => {
+ const ringing = calls.find((c) => c.status === "ringing");
+ if (!ringing) return;
+
+ // Use fromName when it's a real display name; otherwise fall back to
+ // from. SignalWire sends "_undef_" as a placeholder when the
+ // originating leg didn't supply a name.
+ const callerName =
+ ringing.fromName && ringing.fromName !== "_undef_"
+ ? ringing.fromName
+ : ringing.from;
+
+ showIncomingCall({
+ from: callerName,
+ onAccept: () => ringing.answer({ audio: true, video: true }),
+ onDecline: () => ringing.reject(),
+ });
+
+ // Tear the UI down when the call leaves the "ringing" state.
+ ringing.status$
+ .pipe(filter((s) => s !== "ringing"), take(1))
+ .subscribe(() => hideIncomingCall());
+
+ // Attach media once the call connects (these only emit after accept).
+ ringing.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
+ ringing.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
+});
+```
+
+
+**Before you start.** Inbound calls require a [Subscriber Access Token (SAT)](/docs/browser-sdk/v4/guides/authentication) issued for a specific [subscriber](/docs/platform/subscribers). Embed tokens and guest tokens are outbound-only and can't receive calls.
+
+
+## Listen for incoming calls
+
+Subscribe to [`client.session.incomingCalls$`](/docs/browser-sdk/v4/reference/interfaces/session-state). The stream emits the **current list** of inbound calls every time it changes, not one event per call — filter by `status === "ringing"` to find calls that still need a decision.
+
+```ts Browser
+client.session.incomingCalls$.subscribe((calls) => {
+ const ringing = calls.find((c) => c.status === "ringing");
+ if (ringing) showIncomingCall(ringing); // your UI helper for the ringing state
+});
+```
+
+Each entry is a [`Call`](/docs/browser-sdk/v4/reference/interfaces/call) with `direction: "inbound"`. Display the caller from these properties:
+
+| Property | What it is |
+| ----------- | ------------------------------------------------------------------- |
+| `from` | The caller's address (e.g. `/private/alice`) |
+| `fromName` | Display name, if the caller supplied one |
+| `to` | The address that was dialed (useful when one subscriber has aliases)|
+| `direction` | Always `"inbound"` here |
+
+The SDK hands you the raw list — it doesn't queue, dedupe, or pick a call for you. Two simultaneous callers land in the same emission, and calls stay in the array through every status transition (only dropping out when destroyed), so the `status === "ringing"` filter is what tells you which entries still need a decision.
+
+## Accept or decline
+
+A ringing call ends one of three ways: the user accepts, the user declines, or the caller gives up. Use [`answer()`](/docs/browser-sdk/v4/reference/interfaces/call) to accept and [`reject()`](/docs/browser-sdk/v4/reference/interfaces/call) to decline; subscribe to [`status$`](/docs/browser-sdk/v4/reference/interfaces/call) to detect any of the three so the ringing UI tears down from a single place.
+
+**Accept the call.** `answer()` takes a [`MediaOptions`](/docs/browser-sdk/v4/reference/interfaces/media-options) object that controls which tracks the user sends back — `audio` defaults to `true`, `video` defaults to `false`:
+
+
+
+ ```ts Browser {9}
+ import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+ const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
+ await client.register();
+
+ client.session.incomingCalls$.subscribe((calls) => {
+ const ringing = calls.find((c) => c.status === "ringing");
+ if (!ringing) return;
+ ringing.answer({ audio: true, video: true });
+ });
+ ```
+ Standard video call. Both tracks acquired from the selected mic and camera.
+
+
+ ```ts Browser {9}
+ import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+ const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
+ await client.register();
+
+ client.session.incomingCalls$.subscribe((calls) => {
+ const ringing = calls.find((c) => c.status === "ringing");
+ if (!ringing) return;
+ ringing.answer(); // defaults: audio: true, video: false
+ });
+ ```
+ Phone-style call. No camera permission prompt.
+
+
+ ```ts Browser {9}
+ import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+ const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
+ await client.register();
+
+ client.session.incomingCalls$.subscribe((calls) => {
+ const ringing = calls.find((c) => c.status === "ringing");
+ if (!ringing) return;
+ ringing.answer({ audio: false, video: true });
+ });
+ ```
+ Useful for receive-only kiosks, or when the user wants to join on camera with their mic off.
+
+
+
+**Decline the call.** `reject()` declines before any media negotiates — the caller sees a normal decline; the session never picks up:
+
+```ts Browser {9}
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
+await client.register();
+
+client.session.incomingCalls$.subscribe((calls) => {
+ const ringing = calls.find((c) => c.status === "ringing");
+ if (!ringing) return;
+ ringing.reject();
+});
+```
+
+**Dismiss the ringing UI.** Subscribe to the call's `status$` and dismiss on the first emission that isn't `"ringing"`. That single handler covers all three outcomes — accepted, declined, or caller-gave-up:
+
+```ts Browser {11-13}
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+import { filter, take } from "rxjs";
+
+const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
+await client.register();
+
+client.session.incomingCalls$.subscribe((calls) => {
+ const ringing = calls.find((c) => c.status === "ringing");
+ if (!ringing) return;
+
+ ringing.status$
+ .pipe(filter((s) => s !== "ringing"), take(1))
+ .subscribe(() => hideIncomingCall()); // your UI helper to dismiss the ringing UI
+});
+```
+
+After ringing, the call walks `connecting` → `connected` → `disconnected`. React to `connected` for the in-call UI, and `disconnected` / `destroyed` for the final cleanup.
+
+## Attach the streams
+
+Once the call is connected, attach the local and remote media to `` elements. The shape is identical to an outbound call — bind the [`localStream$`](/docs/browser-sdk/v4/reference/webrtc-call/local-stream$) and [`remoteStream$`](/docs/browser-sdk/v4/reference/webrtc-call/remote-stream$) observables to each element's `srcObject`:
+
+```ts Browser
+ringing.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
+ringing.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
+```
+
+```html
+
+
+```
+
+The local element needs `muted` so the user doesn't echo their own voice; the remote element must not be `muted` or no one is heard. Both need `playsinline` for mobile Safari.
+
+## End the call
+
+Call [`hangup()`](/docs/browser-sdk/v4/reference/webrtc-call/hangup) when the user clicks the hang-up button or navigates away. The call transitions through `disconnecting` → `disconnected` → `destroyed`; any subscriptions on the call complete naturally.
+
+```ts Browser
+hangupButton.onclick = () => ringing.hangup();
+```
+
+If the user closes the tab without calling `hangup()`, the SDK still tears the call down when the page unloads. Calling `hangup()` explicitly gives you a clean point to dismiss the in-call UI before the connection drops.
+
+To leave the page but keep the call alive on the platform — a transfer-and-disappear flow — use [`transfer()`](/docs/browser-sdk/v4/reference/webrtc-call/transfer) instead.
+
+## Try it: receive a call
+
+The fastest way to verify inbound calls end-to-end is to issue a SAT, load the demo as your subscriber, then place the call yourself with a server-side dial that runs inline SWML. The demo surfaces a ringing UI you can accept with **Answer**, then the SWML script plays a public test MP4 into the call — you see real audio and video on the receiving side without needing a second browser or a phone.
+
+
+
+### Issue a Subscriber Access Token
+
+Pick a `reference` for the user the call will arrive on — usually an email, but any stable ID works. If a subscriber with that `reference` doesn't exist yet, this endpoint creates one (set `first_name`, `last_name`, or `password` in the same body if you want); otherwise it returns a fresh token for the existing subscriber.
+
+The response `token` is the SAT you'll plug into the demo. For the production version of this flow — where your backend issues SATs to clients — see the [Authentication guide](/docs/browser-sdk/v4/guides/authentication).
+
+
+
+### Open the demo and come online
+
+Save the page below as `inbound-demo.html` and open it over HTTPS (or `localhost`). Paste the SAT and click **Come online**.
+
+Once registered, the log prints the subscriber's dialable `/private/` address(es) — copy one for the next step. (You can also grab it from the [Dashboard Resources page](https://my.signalwire.com/?page=resources) if you prefer the UI.)
+
+Leave the page open — when the call arrives, the **Caller** line populates and the **Answer** / **Decline** buttons enable. After you accept, **Hang up** enables so you can end the call.
+
+
+```html
+
+
+
+
+ SignalWire SDK inbound demo
+
+
+
+ SignalWire SDK inbound demo
+
+ Subscriber Access Token
+
+ Come online
+
+ Caller: —
+ Answer
+ Decline
+ Hang up
+
+
+
+
+
+
+
+
+
+
+
+```
+
+
+### Place a test call
+
+There are several ways to dial the subscriber — another browser tab signed in as a different user, a SIP softphone configured against the subscriber's SIP endpoint, a PSTN call from a phone, or a server-side dial via the [Calling REST API](/docs/apis/rest). This guide uses the **Calling API** so you can verify the flow from a single terminal. The body below carries inline SWML that plays a public test MP4 — click **Answer** in the demo when the call rings and the platform plays that file into the call so the remote `` tile shows real audio and video.
+
+
+
+Set `from` to any phone number or SIP credential on your project — server-side dials originate from those. Set `to` to the address the demo log printed in step 2 (e.g. `/private/john-doe`). Paste the body below into the request above:
+
+```json Request body example {4,5}
+{
+ "command": "dial",
+ "params": {
+ "from": "",
+ "to": "/private/",
+ "swml": {
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "play": { "url": "https://www.w3schools.com/html/mov_bbb.mp4" } }
+ ]
+ }
+ }
+ }
+}
+```
+
+The demo logs the ringing call and enables the **Answer** / **Decline** buttons. Click **Answer** to accept (the demo answers with `audio: true, video: true`) — the streams attach, the local tile shows your webcam preview, and the remote tile shows whatever the SWML leg sends back. The SWML script hangs up automatically when playback finishes, or click **Hang up** to end the call from your side.
+
+
+
+## Next steps
+
+
+
+ Dial subscribers, rooms, or PSTN destinations with `client.dial()`.
+
+
+ Choose the mic, camera, and speaker.
+
+
+ Every property and method on a `Call`.
+
+
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/layouts.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/layouts.mdx
new file mode 100644
index 000000000..710c57f0d
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/layouts.mdx
@@ -0,0 +1,147 @@
+---
+title: "Layouts & Participant Views"
+slug: /guides/layouts
+sidebar-title: "Layouts"
+position: 9
+max-toc-depth: 3
+---
+
+For multi-party video rooms, the SignalWire platform composes every
+participant's camera into a single **mixed video stream** — that's
+what `call.remoteStream$` emits. The layout is the rule for *how*
+the server composites that stream: grid, presenter + thumbnails,
+picture-in-picture, and so on. Each room has a list of layouts the
+server allows; clients pick one, optionally pin who goes in which
+slot, and read back where everyone ended up.
+
+The mental model that matters: **you don't compose the video, the
+server does.** Your job is to (a) tell it which composition to use,
+and (b) draw overlays (name tags, mute icons, click targets) on top
+of the resulting stream using percentage-based layer coordinates.
+
+## Pick a layout
+
+Wire your picker to [`layouts$`] (available options),
+[`layout$`] (current selection), and [`setLayout()`] (mutator). Same
+pattern as every other control on a call:
+
+```js
+call.layouts$.subscribe((names) => {
+ layoutPicker.innerHTML = names
+ .map((n) => `${n} `)
+ .join("");
+});
+
+call.layout$.subscribe((current) => {
+ layoutPicker.value = current ?? "";
+});
+
+layoutPicker.onchange = () => call.setLayout(layoutPicker.value, {});
+```
+
+The available layout names are server-defined — they depend on the
+room's configuration. `setLayout` rejects with `InvalidParams` if you
+pass a name that isn't in `layouts$`, so either bind from the picker
+options (as above) or validate up front.
+
+The empty `{}` second argument means "let the server place
+participants automatically." To pin specific members into specific
+slots:
+
+```js
+await call.setLayout("presenter", {
+ [presenterId]: "reserved-0", // big slot
+ [guestId]: "reserved-1", // sidebar
+});
+```
+
+Slot names (`reserved-0`, `reserved-1`, `auto`, `standard-0`, …) are
+defined per-layout — see [`VideoPosition`]. Members not in the map
+are auto-placed. The local user can request their own position too
+with [`call.self.setPosition()`][`Participant.setPosition()`], gated
+by `capabilities.self.position`.
+
+## Render the layout
+
+Attach the mixed video to a single `` element and you're done
+for the composition itself:
+
+```html
+
+```
+
+```js
+call.remoteStream$.subscribe((s) => roomVideo.srcObject = s);
+```
+
+That stream *already* contains every participant arranged by the
+current layout. You don't render per-participant `` tags
+in a room — that's the server's job.
+
+## Draw overlays
+
+What you *do* render is overlays on top of that single video — name
+tags, mute icons, click hotspots, "speaking" borders. That's where
+[`layoutLayers$`] comes in: it emits per-participant boxes with
+**percentage** coordinates (0–100) relative to the room canvas, so
+your overlays scale with the video element regardless of resolution.
+
+```js
+call.layoutLayers$.subscribe((layers) => {
+ for (const layer of layers) {
+ overlay(layer.member_id).style.cssText = `
+ left: ${layer.x}%;
+ top: ${layer.y}%;
+ width: ${layer.width}%;
+ height: ${layer.height}%;
+ `;
+ }
+});
+```
+
+See [`LayoutLayer`] for the full layer shape (z-index, visibility,
+reservation slot, etc.).
+
+If you only need *one* member's box at a time, every `Participant`
+has its own [`position$`][`Participant.position$`] that narrows
+`layoutLayers$` to just that member — easier to wire per-tile than
+re-scanning the full list on every emission. This is the standard
+"render an overlay per participant card" pattern; see
+[Multi-party rooms](/docs/browser-sdk/v4/guides/multi-party).
+
+## When the layout re-shuffles
+
+[`layout$`] and [`layoutLayers$`] re-emit whenever the server
+re-composites: someone calls `setLayout`, a member joins/leaves and
+auto-layout reflows, the server promotes a raised hand. Keep your
+subscriptions live and the overlays follow. There's no manual
+refresh.
+
+## Capability gating
+
+| Action | Capability |
+| ------------------------------- | --------------------------------------- |
+| Pick a different layout | [`SelfCapabilities.setLayout$`] |
+| Set your own position | `capabilities.self.position` |
+| Set another member's position | `capabilities.member.position` |
+
+Hide the picker / position controls when the capability is false.
+See [Capabilities](/docs/browser-sdk/v4/guides/capabilities).
+
+## Reference
+
+- [`layouts$`] · [`layout$`] · [`layoutLayers$`] — what to subscribe to
+- [`setLayout()`] — switch the composition / pin slots
+- [`Participant.position$`] · [`Participant.setPosition()`] — per-member position
+- [`LayoutLayer`] · [`VideoPosition`] — data shapes
+- [`SelfCapabilities.setLayout$`] — capability gate
+
+[`layouts$`]: /docs/browser-sdk/v4/reference/webrtc-call/layouts$
+[`layout$`]: /docs/browser-sdk/v4/reference/webrtc-call/layout$
+[`layoutLayers$`]: /docs/browser-sdk/v4/reference/webrtc-call/layout-layers$
+[`setLayout()`]: /docs/browser-sdk/v4/reference/webrtc-call/set-layout
+[`Participant.position$`]: /docs/browser-sdk/v4/reference/participant/position$
+[`Participant.setPosition()`]: /docs/browser-sdk/v4/reference/participant/set-position
+[`LayoutLayer`]: /docs/browser-sdk/v4/reference/interfaces/layout-layer
+[`VideoPosition`]: /docs/browser-sdk/v4/reference/type-aliases/video-position
+[`SelfCapabilities.setLayout$`]: /docs/browser-sdk/v4/reference/self-capabilities/set-layout$
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/messaging-chat.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/messaging-chat.mdx
new file mode 100644
index 000000000..8520fef1c
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/messaging-chat.mdx
@@ -0,0 +1,181 @@
+---
+title: "Messaging & Chat"
+slug: /guides/messaging-chat
+sidebar-title: "Messaging & Chat"
+position: 11
+max-toc-depth: 3
+---
+
+Text messaging in the Browser SDK lives on the `Address` entity, not
+on the `Call`. Every Address — whether a 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
+Subscriber. 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 address = directory.addresses.find((a) => a.name === "/private/alice");
+
+await address.sendText("Heading over to the call now");
+```
+
+`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.
+
+## 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
+for (const address of directory.addresses) {
+ address.textMessages$.subscribe((collection) => {
+ collection?.values$.subscribe((messages) => {
+ const unread = messages.filter((m) => !isRead(m.id));
+ updateUnreadBadge(address.id, unread.length);
+ });
+ });
+}
+```
+
+The platform pushes new messages over the same WebSocket the SDK
+uses for signaling — no polling required.
+
+## 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$
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/multi-party.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/multi-party.mdx
new file mode 100644
index 000000000..d876df862
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/multi-party.mdx
@@ -0,0 +1,180 @@
+---
+title: "Multi-Party Rooms"
+slug: /guides/multi-party
+sidebar-title: "Multi-Party"
+position: 10
+max-toc-depth: 3
+---
+
+A "room" in SignalWire is a call with more than two members. The
+`Call` API is identical to a 1:1 call — `dial()` into a room address,
+get back a [`Call`], observe `remoteStream$` for the mixed feed
+(the server composites every participant's camera into one stream,
+laid out by [Layouts](/docs/browser-sdk/v4/guides/layouts)). What
+changes is the *count* of things you have to track: now there are
+many participants, and you need a way to render them, listen to
+their state, and act on them as a moderator.
+
+## Joining
+
+A room is just a Resource Address with `type === 'room'`:
+
+```js
+const call = await client.dial("/public/team-standup", {
+ audio: true,
+ video: true,
+});
+```
+
+Nothing room-specific in the dial itself. The room-ness shows up in
+the observables.
+
+## The participants list
+
+[`participants$`] is the source of truth for who's in the room. It
+re-emits on every join, leave, and update. The structural trick:
+**don't re-render the whole list on every emission** — diff it,
+because each new emission may include the same `Participant`
+instances and you want to keep their subscriptions intact.
+
+```js
+const tiles = new Map();
+
+call.participants$.subscribe((participants) => {
+ // Tear down tiles for members who left
+ for (const [id, el] of tiles) {
+ if (!participants.find((p) => p.id === id)) {
+ el.remove();
+ tiles.delete(id);
+ }
+ }
+
+ // Mount tiles for new members, leave existing ones alone
+ for (const p of participants) {
+ if (!tiles.has(p.id)) {
+ const el = renderTile(p);
+ bindParticipantObservables(p, el); // subscribe ONCE per id
+ tiles.set(p.id, el);
+ participantsList.appendChild(el);
+ }
+ }
+});
+```
+
+The list includes self. Filter on `p.id === call.self?.id` if you
+want to treat self specially (e.g. label "You" instead of the name).
+
+## Per-participant state
+
+Each entry is a [`Participant`] — the same class as `call.self`,
+minus the device/screen-share extras. Bind to its observables
+exactly the way you would for self:
+
+```js
+function bindParticipantObservables(p, tileEl) {
+ p.name$.subscribe((n) => tileEl.querySelector(".name").textContent = n ?? "");
+ p.audioMuted$.subscribe((m) => tileEl.classList.toggle("muted", m));
+ p.videoMuted$.subscribe((m) => tileEl.classList.toggle("video-off", m));
+ p.isTalking$.subscribe((t) => tileEl.classList.toggle("speaking", t));
+ p.handraised$.subscribe((h) => tileEl.classList.toggle("hand-up", h));
+}
+```
+
+The full list of per-member observables is on the [`Participant`]
+reference page. The shape is consistent: whatever state exists has a
+`$` observable and a sync getter.
+
+## Active speaker
+
+[`isTalking$`] flips while server-side VAD detects voice on a
+member's track. The standard pattern: highlight whoever is currently
+talking, optionally pin them in the layout.
+
+```js
+call.participants$.subscribe((participants) => {
+ for (const p of participants) {
+ p.isTalking$.subscribe((talking) => {
+ tile(p.id).classList.toggle("speaking", talking);
+ });
+ }
+});
+```
+
+Combine with [`layoutLayers$`] if you want to overlay a "speaking"
+border on top of the mixed video — `layoutLayers$` tells you where
+each member is rendered inside that stream.
+
+## Moderation
+
+You can mute, kick, reposition, or end the call on behalf of others
+— but **every moderation action is server-gated**. The capability
+stream tells you what's allowed:
+
+```js
+call.self?.capabilities.member$.subscribe((member) => {
+ kickButton.disabled = !member.remove;
+ muteOthersButton.disabled = !member.muteAudio.on;
+});
+
+// Then, on click — note these are methods on the OTHER participant,
+// not on self.
+kickButton.onclick = () => participant.remove();
+muteOthersButton.onclick = () => participant.mute();
+```
+
+Calling [`participant.remove()`][`Participant.remove()`] /
+[`participant.mute()`][`Participant.mute()`] etc. without the
+matching capability throws server-side. Always gate the UI off the
+[`member$`][`SelfCapabilities.member$`] stream — don't show a button
+the server will reject. The
+[Capabilities](/docs/browser-sdk/v4/guides/capabilities) guide is the
+full reference for that model.
+
+Room-level actions are the same shape:
+[`toggleLock`][`Call.toggleLock()`] (with [`locked$`] for the
+reflection) keeps new joiners out; [`self.end()`][`Participant.end()`]
+ends the call for *everyone*, gated by
+[`SelfCapabilities.end$`]. The mutator+observable pattern is the
+same as everywhere else — see
+[Call Controls](/docs/browser-sdk/v4/guides/call-controls#the-pattern).
+
+## Lobby / waiting room
+
+There's no first-class "lobby" primitive in v4. Two ways to fake one:
+
+- **Two Resources.** Guests dial a `lobby` Resource configured with
+ a SWML script that holds them (e.g. hold music). A moderator app,
+ watching `participants$` on the lobby room, calls
+ [`call.transfer({ destination: "/private/main-room" })`][`Call.transfer()`]
+ to graduate each guest individually.
+- **Lock the main room.** Have everyone dial the main room directly;
+ toggle the lock open / closed to admit batches. Cheaper, but guests
+ hear "room locked" until you flip it.
+
+The two-resource approach scales better for one-by-one admission
+(e.g. an interview format) and gives you per-guest control. Lock is
+fine for "we've started, no more arrivals."
+
+## Reference
+
+- [`Call.participants$`] · [`Call.self$`] — the lists
+- [`Participant`] — per-member API (full observable + method surface)
+- [`SelfCapabilities.member$`] · [`SelfCapabilities.end$`] · [`SelfCapabilities.lock$`] — moderation gates
+- [`Call.toggleLock()`] · [`locked$`] · [`Call.transfer()`] — room-level actions
+
+[`Call`]: /docs/browser-sdk/v4/reference/webrtc-call
+[`Call.participants$`]: /docs/browser-sdk/v4/reference/webrtc-call/participants$
+[`participants$`]: /docs/browser-sdk/v4/reference/webrtc-call/participants$
+[`Call.self$`]: /docs/browser-sdk/v4/reference/webrtc-call/self$
+[`Participant`]: /docs/browser-sdk/v4/reference/participant
+[`Participant.mute()`]: /docs/browser-sdk/v4/reference/participant/mute
+[`Participant.remove()`]: /docs/browser-sdk/v4/reference/participant/remove
+[`Participant.end()`]: /docs/browser-sdk/v4/reference/participant/end
+[`isTalking$`]: /docs/browser-sdk/v4/reference/participant/is-talking$
+[`layoutLayers$`]: /docs/browser-sdk/v4/reference/webrtc-call/layout-layers$
+[`SelfCapabilities.member$`]: /docs/browser-sdk/v4/reference/self-capabilities/member$
+[`SelfCapabilities.end$`]: /docs/browser-sdk/v4/reference/self-capabilities/end$
+[`SelfCapabilities.lock$`]: /docs/browser-sdk/v4/reference/self-capabilities/lock$
+[`Call.toggleLock()`]: /docs/browser-sdk/v4/reference/webrtc-call/toggle-lock
+[`locked$`]: /docs/browser-sdk/v4/reference/webrtc-call/locked$
+[`Call.transfer()`]: /docs/browser-sdk/v4/reference/webrtc-call/transfer
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/outbound-calls.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/outbound-calls.mdx
new file mode 100644
index 000000000..68ba9dddc
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/outbound-calls.mdx
@@ -0,0 +1,260 @@
+---
+title: "Outbound Calls"
+slug: /guides/outbound-calls
+sidebar-title: "Outbound Calls"
+position: 2
+max-toc-depth: 3
+---
+
+To place a call from a web app, hand the SDK a destination, choose whether to send audio, video, or both, and attach the resulting media streams to the page. The same [`client.dial()`](/docs/browser-sdk/v4/reference/signalwire/dial) call works for joining a room, calling another [subscriber](/docs/platform/subscribers), or reaching a phone number — only the destination string changes.
+
+Here's the shape of an outbound call end-to-end:
+
+```ts Browser
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
+const call = await client.dial("/public/test-room", { audio: true, video: true });
+
+// Attach the media to the page.
+call.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
+call.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
+
+// End the call when the user clicks hang up.
+hangupButton.onclick = () => call.hangup();
+```
+
+
+**Before you start.** You'll need a [Subscriber Access Token](/docs/browser-sdk/v4/guides/authentication) your backend issued for the user, a destination the token is allowed to reach, and an HTTPS page (browsers only grant mic and camera access over a secure origin — `localhost` is the development exception).
+
+
+## Pick a destination
+
+The first argument to `dial()` is a URI string identifying what the call should reach. Four shapes cover the common cases:
+
+
+
+ `/public/` — a [resource](/docs/platform/resources) like a room, IVR, or app anyone in the project can dial.
+
+
+ `/private/` — a registered user in your project that you can call directly.
+
+
+ `+15551234567` — a PSTN number. The token must be allowed to dial PSTN.
+
+
+ `sip:alice@example.com` — a SIP destination reachable from your space.
+
+
+
+```ts Browser
+const call = await client.dial("/public/test-room");
+```
+
+If you're iterating addresses from the directory, an [`Address`](/docs/browser-sdk/v4/reference/address) object exposes [`defaultChannel`](/docs/browser-sdk/v4/reference/address/default-channel) — a ready-to-dial URI for that address — so you don't have to assemble the string yourself.
+
+## Choose audio, video, or both
+
+`dial()` takes a [`DialOptions`](/docs/browser-sdk/v4/reference/interfaces/dial-options) object, which extends [`MediaOptions`](/docs/browser-sdk/v4/reference/interfaces/media-options). The four common shapes:
+
+
+
+ ```ts
+ const call = await client.dial(destination, { audio: true, video: true });
+ ```
+ Standard video call. Both tracks captured from the selected mic and camera.
+
+
+ ```ts
+ const call = await client.dial(destination);
+ // equivalent: { audio: true, video: false }
+ ```
+ Phone-style call. No camera permission prompt.
+
+
+ ```ts
+ const call = await client.dial(destination, { audio: false, video: true });
+ ```
+ Useful for a kiosk that joins on camera with the mic off, or for receive-only viewers.
+
+
+ ```ts
+ const call = await client.dial(destination, {
+ audio: false,
+ video: false,
+ receiveAudio: true,
+ receiveVideo: true,
+ });
+ ```
+ Joins without sending any media. The remote tracks still arrive on `remoteStream$`.
+
+
+
+The destination URI can also carry a `?channel=audio` or `?channel=video` hint that sets the matching media defaults — useful when the destination URI is what determines the call shape. Explicit options passed to `dial()` always win.
+
+For pinned device constraints, codec preference, your own `MediaStream`, or custom invite metadata, see `DialOptions`. For mic, camera, or speaker selection that persists across every call, use the device management APIs instead of constraining each `dial()` individually.
+
+## Attach the streams
+
+A [`Call`](/docs/browser-sdk/v4/reference/interfaces/call) exposes two media observables: [`localStream$`](/docs/browser-sdk/v4/reference/webrtc-call/local-stream$) (what the user is sending) and [`remoteStream$`](/docs/browser-sdk/v4/reference/webrtc-call/remote-stream$) (what the user receives). Both emit a `MediaStream` once the track is ready — bind each one to a `` element's `srcObject`:
+
+```ts Browser
+// These subscriptions complete on their own when the call ends.
+call.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
+call.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
+```
+
+```html
+
+
+```
+
+The local element needs `muted` so the user doesn't echo their own voice; the remote element must not be `muted` or no one is heard. Both need `playsinline` for mobile Safari. Audio-only calls use the same `remoteStream$` — keep the `` element and the browser plays the audio track through it.
+
+## End the call
+
+Call [`hangup()`](/docs/browser-sdk/v4/reference/webrtc-call/hangup) when the user clicks the hang-up button or navigates away. The call transitions through `disconnecting` → `disconnected` → `destroyed`; any subscriptions on the call complete naturally.
+
+```ts Browser
+hangupButton.onclick = () => call.hangup();
+```
+
+If the user closes the tab without calling `hangup()`, the SDK still tears the call down when the page unloads. Calling `hangup()` explicitly gives you a clean point to dismiss the in-call UI before the connection drops.
+
+To leave the page but keep the call alive on the platform — a transfer-and-disappear flow — use [`transfer()`](/docs/browser-sdk/v4/reference/webrtc-call/transfer) instead.
+
+## Try it: dial a destination
+
+Create a SAT against your project — the [Authentication guide](/docs/browser-sdk/v4/guides/authentication) covers it end-to-end; the [Create Subscriber Token](/docs/apis/rest/subscribers/tokens/create-subscriber-token) reference will send the request for you.
+
+
+
+Copy the returned `token`, save the page below as `outbound-demo.html`, and open it over HTTPS (or `localhost`). Paste the SAT and a destination, toggle the **Send audio** / **Send video** checkboxes to match the call shape you want, click **Dial**, and watch the log — it records every status the call moves through. The checkboxes map directly onto the `audio` and `video` keys of `dial()`'s [`DialOptions`](/docs/browser-sdk/v4/reference/interfaces/dial-options).
+
+
+```html
+
+
+
+
+ SignalWire SDK outbound call demo
+
+
+
+ SignalWire SDK outbound call demo
+
+ Subscriber Access Token
+
+
+ Destination
+
+
+
+ Media
+ Send audio
+ Send video
+
+
+ Dial
+ Hang up
+
+
+
+
+
+
+
+
+
+
+
+```
+
+
+You should see `Status: connected` once the destination picks up. If `dial()` rejects with `CallCreateError`, the token's scope doesn't reach the destination — re-check `allowed_addresses` or the token's project.
+
+## Next steps
+
+
+
+ Receive calls in a signed-in subscriber session.
+
+
+ Choose the mic, camera, and speaker the call should use.
+
+
+ Every option you can pass to `client.dial()`.
+
+
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/overview.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/overview.mdx
new file mode 100644
index 000000000..f3830bc86
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/overview.mdx
@@ -0,0 +1,109 @@
+---
+title: "Overview"
+slug: /guides/build-voice-video
+sidebar-title: "Overview"
+position: 1
+max-toc-depth: 3
+---
+
+The Browser SDK gives you a [`Call`] object for every active conversation
+— inbound or outbound, audio-only or video, 1-on-1 or room. Once you
+have one, every aspect of the call (status, media streams,
+participants, layout, recording state, network quality) is reachable
+through observables and a small set of imperative methods.
+
+This section is the practical guide to building with that object.
+Pages are roughly ordered from "first call" to "advanced call-center
+features," but each is self-contained.
+
+## Reading order
+
+If you're starting from zero:
+
+1. **[Outbound Calls](/docs/browser-sdk/v4/guides/outbound-calls)** — the simplest
+ thing that works: `client.dial()`, attach streams, listen for
+ status, hang up.
+2. **[Call Controls](/docs/browser-sdk/v4/guides/call-controls)** — the muscle of any
+ call UI: mute, deaf, hand raise, hangup, DTMF.
+3. **[Device Management](/docs/browser-sdk/v4/guides/device-management)** — pick a
+ microphone and camera; reactively rebind when the user plugs in a
+ headset mid-call.
+4. **[Inbound Calls](/docs/browser-sdk/v4/guides/inbound-calls)** — `register()` and
+ subscribe to `session.incomingCalls$`; answer or reject.
+
+When you need more:
+
+- **[Screen Sharing](/docs/browser-sdk/v4/guides/screen-sharing)** —
+ `self.startScreenShare()` and the matching `screenShareStatus$`.
+- **[Multi-party](/docs/browser-sdk/v4/guides/multi-party)** — `participants$`,
+ per-participant observables, moderation actions.
+- **[Layouts](/docs/browser-sdk/v4/guides/layouts)** — `setLayout()`,
+ `layoutLayers$`, picking room layouts at runtime.
+- **[Messaging & Chat](/docs/browser-sdk/v4/guides/messaging-chat)** — in-call text
+ messages via `address.sendText` and `textMessages$`.
+
+Every page in this section assumes you've read the
+[RxJS Primer](/docs/browser-sdk/v4/guides/rxjs-primer) — the SDK is
+observables all the way down. See the [`Call`] reference for the full
+property and method list; the guides here teach *how to use it*, not
+which fields exist.
+
+## Two patterns the SDK relies on
+
+These show up in every example below — calling them out once here so
+they don't surprise you:
+
+### 1. Subscribe to the participant, not the call, for member state
+
+`call.audioMuted` doesn't exist. Mute / deaf / hand raise / video
+state all live on the [`SelfParticipant`]:
+
+```js
+call.self$.subscribe((self) => {
+ if (!self) return;
+ self.audioMuted$.subscribe((muted) => updateMuteButton(muted));
+});
+```
+
+You wait for `self$` to emit (it's `null` until the local member
+joins) and then bind to the participant's own observables.
+
+### 2. BehaviorSubjects emit synchronously on subscribe
+
+Most observables on [`Call`] and [`Participant`] are BehaviorSubjects:
+late subscribers receive the current value immediately. You don't
+need to remember "did I subscribe before the call connected" — you'll
+get the cached state on first emission.
+
+```js
+// This works even if you subscribe well after the call is connected:
+call.status$.subscribe((status) => console.log(status));
+// → logs the current status immediately, then any future changes.
+```
+
+## A real reference app
+
+Everything in this section is faithful to the kitchen-sink demo in
+`signalwire-typescript-web/playground/kitchen-sink-demo` — a vanilla
+TypeScript app that exercises every public API. When in doubt about
+how a feature fits together with the rest of the SDK, check
+`playground/kitchen-sink-demo/src/main.ts` in the SDK repo for the
+exact wiring.
+
+## Reference
+
+- [`SignalWire`] — top-level client
+- [`Call`] (interface) / [`WebRTCCall`] (concrete) — the active call
+- [`Participant`] / [`SelfParticipant`] — members
+- [`Address`] — directory entries
+- [`SelfCapabilities`] — per-call permission flags
+- [`SessionState`] — `client.session` surface (incl. inbound calls)
+
+[`SignalWire`]: /docs/browser-sdk/v4/reference/signalwire
+[`Call`]: /docs/browser-sdk/v4/reference/interfaces/call
+[`WebRTCCall`]: /docs/browser-sdk/v4/reference/webrtc-call
+[`Participant`]: /docs/browser-sdk/v4/reference/participant
+[`SelfParticipant`]: /docs/browser-sdk/v4/reference/self-participant
+[`Address`]: /docs/browser-sdk/v4/reference/address
+[`SelfCapabilities`]: /docs/browser-sdk/v4/reference/self-capabilities
+[`SessionState`]: /docs/browser-sdk/v4/reference/interfaces/session-state
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/screen-sharing.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/screen-sharing.mdx
new file mode 100644
index 000000000..938bd1abb
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/screen-sharing.mdx
@@ -0,0 +1,122 @@
+---
+title: "Screen Sharing"
+slug: /guides/screen-sharing
+sidebar-title: "Screen Sharing"
+position: 5
+max-toc-depth: 3
+---
+
+Screen sharing is a method on the `SelfParticipant`. Call
+`startScreenShare()` to add a screen-share track to the active call;
+call `stopScreenShare()` to remove it. The SDK calls
+`getDisplayMedia()` under the hood, negotiates the additional track,
+and pushes status changes through an observable.
+
+There's no separate "screen share call" — the share is added to the
+*existing* call alongside the camera, as a second video stream.
+
+## Start / stop
+
+```js
+call.self$.subscribe(async (self) => {
+ if (!self) return;
+
+ shareButton.onclick = async () => {
+ try {
+ if (self.screenShareStatus === "started") {
+ await self.stopScreenShare();
+ } else {
+ await self.startScreenShare();
+ }
+ } catch (err) {
+ // User cancelled the picker, or permission denied
+ console.error(err);
+ }
+ };
+});
+```
+
+`startScreenShare()` triggers the browser's screen-picker. If the
+user cancels, the promise rejects — handle it gracefully (no error
+toast for a deliberate cancel).
+
+## Observing share state
+
+`screenShareStatus$` is the reactive form. Use it instead of polling
+[`screenShareStatus`] so your UI reflects auto-stop (user clicked
+"Stop sharing" in the browser bar, OS revoked permission, etc.):
+
+```ts
+type ScreenShareStatus = 'idle' | 'starting' | 'started' | 'stopping';
+```
+
+```js
+self.screenShareStatus$.subscribe((status) => {
+ shareButton.classList.toggle("active", status === "started");
+ shareButton.disabled = status === "starting" || status === "stopping";
+});
+```
+
+## How the share appears to other participants
+
+The screen-share track is delivered as a separate participant entry
+in `call.participants$` — usually with a name like `Screen` or the
+sharer's name suffixed with `(Screen)`. The local participant
+doesn't need to render their own share to see it; the SDK doesn't
+mirror the local capture into `remoteStream$`.
+
+To detect which participants are screen shares specifically, check
+the participant's metadata or `type`. In the kitchen-sink demo, the
+share state is read from `self.screenShareStatus` directly:
+
+```js
+const isSharing = call.self?.screenShareStatus === "started";
+```
+
+## Audio with the share
+
+`getDisplayMedia()` can capture system / tab audio on some platforms
+(Chromium-based browsers on macOS / Windows). The SDK forwards
+whatever the browser provides — there's no separate "share audio"
+toggle. If the user's browser doesn't support display audio, only
+the video is shared.
+
+In practice:
+- Chrome / Edge on macOS or Windows: works for tab audio, may work
+ for system audio depending on OS version.
+- Firefox: video only.
+- Safari: video only (and screen sharing requires explicit
+ user-initiated permission per session).
+
+## Browser quirks
+
+- **User gesture required.** `startScreenShare()` must originate from
+ a click or keyboard event — calling it on a timer or after an
+ async chain that didn't start from a gesture will fail.
+- **iOS Safari.** Tab screen sharing isn't supported. iOS has its own
+ system-wide screen-broadcast flow which is outside the browser's
+ reach.
+- **Multiple displays.** The picker lists each display separately;
+ selecting "Entire screen" on a multi-monitor setup shares only the
+ picked display.
+
+## Stopping from outside the page
+
+The user can stop sharing from the browser's "Stop sharing" toolbar.
+When they do, the underlying track ends and the SDK transitions
+`screenShareStatus$` to `'idle'` automatically — no action required
+on your side. Subscribe to `screenShareStatus$` and your UI will
+update without polling.
+
+## Reference
+
+- [`SelfParticipant.startScreenShare()`] — open picker, add the share track
+- [`SelfParticipant.stopScreenShare()`] — remove the share track
+- [`SelfParticipant.screenShareStatus$`] / [`screenShareStatus`] — reactive status (`'idle' | 'starting' | 'started' | 'stopping'`)
+- [`SelfCapabilities.screenshare$`] — capability gate
+
+[`SelfParticipant.startScreenShare()`]: /docs/browser-sdk/v4/reference/self-participant/start-screen-share
+[`SelfParticipant.stopScreenShare()`]: /docs/browser-sdk/v4/reference/self-participant/stop-screen-share
+[`SelfParticipant.screenShareStatus$`]: /docs/browser-sdk/v4/reference/self-participant/screen-share-status$
+[`screenShareStatus`]: /docs/browser-sdk/v4/reference/self-participant/screen-share-status$
+[`SelfCapabilities.screenshare$`]: /docs/browser-sdk/v4/reference/self-capabilities/screenshare$
diff --git a/fern/products/browser-sdk/pages/v4/guides/deploy/framework-integration.mdx b/fern/products/browser-sdk/pages/v4/guides/deploy/framework-integration.mdx
new file mode 100644
index 000000000..45ebf3128
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/deploy/framework-integration.mdx
@@ -0,0 +1,283 @@
+---
+title: "Framework Integration"
+slug: /guides/framework-integration
+sidebar-title: "Framework Integration"
+position: 2
+max-toc-depth: 3
+---
+
+The Browser SDK is framework-agnostic — every public surface is either
+a plain class (`SignalWire`, `StaticCredentialProvider`), a method
+returning a Promise, or an RxJS observable. Integration with React,
+Vue, Svelte, or Angular comes down to two questions:
+
+1. **Lifecycle** — when to construct the `SignalWire` client, and when
+ to disconnect it.
+2. **State** — how to fold an observable into the framework's
+ reactivity system so your UI re-renders when call state changes.
+
+The patterns below cover both for each major framework. If you're using
+the [web components](/docs/browser-sdk/v4/guides/web-components) instead of the JS
+SDK directly, you only need to worry about lifecycle — the components
+manage their own state through context.
+
+## React
+
+### One client per app
+
+Construct the client once at app start, share it through context, and
+disconnect it on unmount. Do **not** put `new SignalWire(...)` inside
+a render — it would re-run on every state change.
+
+```tsx
+// src/signalwire-context.tsx
+import { createContext, useContext, useEffect, useState } from "react";
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const Ctx = createContext(null);
+
+export function SignalWireProvider({
+ token,
+ children,
+}: {
+ token: string;
+ children: React.ReactNode;
+}) {
+ const [client, setClient] = useState(null);
+
+ useEffect(() => {
+ const c = new SignalWire(new StaticCredentialProvider({ token }));
+ setClient(c);
+ return () => {
+ c.disconnect();
+ };
+ }, [token]);
+
+ return {children} ;
+}
+
+export const useSignalWire = () => useContext(Ctx);
+```
+
+### Subscribing to observables
+
+Wrap an observable in a hook that subscribes on mount and unsubscribes
+on unmount. The SDK uses BehaviorSubjects, so the current value emits
+synchronously on subscribe.
+
+```ts
+// src/hooks/use-observable.ts
+import { useEffect, useState } from "react";
+import type { Observable } from "rxjs";
+
+export function useObservable(obs: Observable | undefined, initial: T) {
+ const [value, setValue] = useState(initial);
+ useEffect(() => {
+ if (!obs) return;
+ const sub = obs.subscribe(setValue);
+ return () => sub.unsubscribe();
+ }, [obs]);
+ return value;
+}
+```
+
+```tsx
+function CallStatus({ call }: { call: WebRTCCall }) {
+ const status = useObservable(call.status$, "idle");
+ return {status} ;
+}
+```
+
+### Strict Mode and double mounting
+
+React 18 Strict Mode mounts components twice in development to surface
+unsafe lifecycle effects. The pattern above is safe because the
+cleanup function disconnects the client — but if you `await
+client.connect()` outside `useEffect`, you'll get a duplicate
+connection. Always put side effects inside `useEffect`.
+
+### Typed refs for web components
+
+If you're using `@signalwire/web-components`, the package ships a JSX
+type declaration so `useRef` is fully typed:
+
+```json
+// tsconfig.json
+{
+ "compilerOptions": {
+ "types": ["@signalwire/web-components/react"]
+ }
+}
+```
+
+```tsx
+import type { SwCallWidget } from "@signalwire/web-components";
+
+function Dialer() {
+ const widget = useRef(null);
+ return (
+ <>
+
+ widget.current?.dial()}>Dial
+ >
+ );
+}
+```
+
+## Vue 3
+
+### One client via `provide` / `inject`
+
+```ts
+// src/composables/use-signalwire.ts
+import { inject, onUnmounted, provide } from "vue";
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const KEY = Symbol("signalwire");
+
+export function provideSignalWire(token: string) {
+ const client = new SignalWire(new StaticCredentialProvider({ token }));
+ provide(KEY, client);
+ onUnmounted(() => client.disconnect());
+ return client;
+}
+
+export function useSignalWire() {
+ const c = inject(KEY, null);
+ if (!c) throw new Error("SignalWire client not provided");
+ return c;
+}
+```
+
+### Observables → reactive refs
+
+```ts
+// src/composables/use-observable.ts
+import { onUnmounted, ref, type Ref } from "vue";
+import type { Observable } from "rxjs";
+
+export function useObservable(obs: Observable, initial: T): Ref {
+ const r = ref(initial) as Ref;
+ const sub = obs.subscribe((v) => (r.value = v));
+ onUnmounted(() => sub.unsubscribe());
+ return r;
+}
+```
+
+```vue
+
+
+
+ {{ status }}
+
+```
+
+## Svelte 5
+
+Svelte's `readable` store maps cleanly onto an RxJS observable — both
+are just "subscribe and you get values."
+
+```ts
+// src/lib/observable-store.ts
+import { readable, type Readable } from "svelte/store";
+import type { Observable } from "rxjs";
+
+export function fromObservable(obs: Observable, initial: T): Readable {
+ return readable(initial, (set) => {
+ const sub = obs.subscribe(set);
+ return () => sub.unsubscribe();
+ });
+}
+```
+
+```svelte
+
+
+{$status}
+```
+
+For the client itself, construct it in `+layout.svelte`'s `onMount`
+and `onDestroy`, or in a `+page.svelte` if it's page-scoped — never at
+module top level (it would run during SSR).
+
+## Angular
+
+Angular and the SDK both use RxJS, so the SDK's streams plug into the
+template's `| async` pipe directly. Wrap the client in an injectable
+service:
+
+```ts
+// src/app/signalwire.service.ts
+import { Injectable, OnDestroy } from "@angular/core";
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+@Injectable({ providedIn: "root" })
+export class SignalWireService implements OnDestroy {
+ client = new SignalWire(
+ new StaticCredentialProvider({ token: this.bootstrapToken() })
+ );
+
+ ngOnDestroy() {
+ this.client.disconnect();
+ }
+
+ private bootstrapToken(): string {
+ /* read from APP_INITIALIZER, secure cookie, etc. */
+ return "";
+ }
+}
+```
+
+```ts
+@Component({
+ selector: "app-call-status",
+ template: `{{ call.status$ | async }} `,
+})
+export class CallStatusComponent {
+ @Input() call!: WebRTCCall;
+}
+```
+
+## Patterns that apply everywhere
+
+### Subscribe immediately
+
+The SDK uses BehaviorSubjects throughout. They emit their current
+value synchronously on subscribe — but only if you actually subscribe.
+A common bug is awaiting `client.ready$.pipe(filter(Boolean), take(1))`
+*after* the client has already become ready, then waiting forever.
+Subscribe early; you'll get the cached value.
+
+### Always unsubscribe
+
+Memory leaks in long-lived sessions are almost always missing
+unsubscribes. Use the framework's lifecycle hook (`useEffect` cleanup,
+`onUnmounted`, `onDestroy`, the `async` pipe) and resist the urge to
+"just keep it simple." See the [RxJS Primer](/docs/browser-sdk/v4/guides/rxjs-primer)
+for the patterns the SDK relies on.
+
+### One client per session, not one per component
+
+Constructing a `SignalWire` opens a WebSocket. Sharing the client
+through context (React), provide/inject (Vue), `getContext` (Svelte),
+or a singleton service (Angular) avoids "why am I seeing two
+connections in the network panel" debugging.
+
+### Disconnect on unmount
+
+The SDK doesn't tear down its WebSocket when the host page is hot-
+reloaded — your cleanup hook has to call `client.disconnect()`. This
+matters most in dev mode (Vite, Next.js fast refresh) where unmount /
+mount cycles are frequent.
diff --git a/fern/products/browser-sdk/pages/v4/guides/deploy/overview.mdx b/fern/products/browser-sdk/pages/v4/guides/deploy/overview.mdx
new file mode 100644
index 000000000..7546b9ffc
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/deploy/overview.mdx
@@ -0,0 +1,70 @@
+---
+title: "Overview"
+slug: /guides/deploy
+sidebar-title: "Overview"
+position: 1
+max-toc-depth: 3
+---
+
+The Browser SDK runs entirely in the browser — there's no server-side
+runtime to deploy. What you *do* deploy is the host application that
+loads it, plus the small backend surface that mints tokens for your
+users. This section covers the four things that consistently come up
+between "it works on my laptop" and "it works in production":
+
+
+
+ Idiomatic patterns for wrapping the SDK in React, Vue, Svelte, and
+ Angular — managing client lifetimes, subscribing observables into
+ component state, and avoiding double-mount pitfalls in dev mode.
+
+
+ The SDK is browser-only. Cover dynamic imports, `"use client"`
+ boundaries, and route handlers that mint tokens without leaking
+ your project credentials.
+
+
+ HTTPS, microphone permission UX, Content Security Policy, token
+ TTL & refresh, error reporting, and pre-launch verification.
+
+
+ Symptom → cause → fix for the issues that show up most often:
+ expired tokens, black video, autoplay-blocked audio, ICE
+ failures, and browser quirks.
+
+
+
+## What ships where
+
+A Browser SDK app is split between two runtimes:
+
+| Lives in the browser | Lives on your backend |
+| -------------------------------------------------- | ------------------------------------------------ |
+| `@signalwire/js` (or `@signalwire/web-components`) | Endpoint that mints SATs / embed tokens |
+| Your UI | Your auth / user system |
+| WebRTC peer connection | (Optionally) webhook handlers for incoming calls |
+
+**The SDK never sees your SignalWire API credentials.** Project ID and
+auth token live exclusively on the backend; the browser only ever
+holds a short-lived JWT it received from your token endpoint. This is
+the most important invariant to preserve across every framework,
+deployment target, and CDN setup discussed in this section.
+
+## A minimal production topology
+
+```
+┌─────────────────────────┐ 1. fetch("/api/sw-token") ┌──────────────────────────┐
+│ Browser │ ─────────────────────────────► │ Your backend │
+│ • @signalwire/js │ │ • POST /api/sw-token │
+│ • Your UI │ ◄───────────────────────────── │ • mints SAT via REST │
+│ │ 2. { token, expiry_at } │ • uses PROJECT creds │
+│ │ │ (env var, secret) │
+│ │ └──────────────────────────┘
+│ │ 3. WebSocket → SignalWire ──►┌──────────────────────────┐
+│ │ │ SignalWire │
+└─────────────────────────┘ └──────────────────────────┘
+```
+
+Everything else (CDN choice, framework, SSR strategy) is a variation
+on this shape. The remaining pages in this section walk through the
+practical details of each layer.
diff --git a/fern/products/browser-sdk/pages/v4/guides/deploy/production.mdx b/fern/products/browser-sdk/pages/v4/guides/deploy/production.mdx
new file mode 100644
index 000000000..cee914844
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/deploy/production.mdx
@@ -0,0 +1,195 @@
+---
+title: "Production Checklist"
+slug: /guides/production
+sidebar-title: "Production"
+position: 4
+max-toc-depth: 3
+---
+
+Eight things to verify before flipping a Browser SDK app to
+production. None are exotic — they're all the items that show up in
+post-launch incident reviews when a step gets skipped.
+
+## 1. HTTPS everywhere
+
+`getUserMedia`, `RTCPeerConnection`, and the SDK's WebSocket all
+require a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).
+The browser will silently refuse to grant microphone or camera access
+on plain HTTP — even on a LAN. The only exempt origin is `localhost`.
+
+- ✅ Production domain served over HTTPS
+- ✅ HSTS header on the app domain (e.g. `Strict-Transport-Security: max-age=63072000`)
+- ✅ No mixed-content warnings in DevTools
+
+## 2. Microphone & camera permission UX
+
+The browser permission prompt is non-negotiable, but its *context* is
+yours to design. A cold prompt — "Allow microphone?" the instant the
+page loads — drops grant rates substantially. Two patterns that work:
+
+- **Just-in-time**: only call `getUserMedia` (or `client.dial()` with
+ media) after the user has explicitly clicked "Start call." This is
+ what `` does by default.
+- **Pre-explain**: render a small in-app modal that explains why the
+ permission is needed, with a "Continue" button that triggers the
+ browser prompt.
+
+Always handle denial:
+
+```js
+try {
+ const call = await client.dial(destination, { audio: true, video: true });
+} catch (err) {
+ if (err.name === "NotAllowedError") {
+ showPermissionDeniedHelp(); // explain how to reset permissions
+ } else if (err.name === "NotFoundError") {
+ showNoDeviceHelp(); // no microphone or camera at all
+ } else {
+ showGenericFailure(err);
+ }
+}
+```
+
+## 3. Content Security Policy
+
+A CSP that supports the SDK needs to allow the SignalWire WebSocket,
+your token endpoint, WebRTC media (which isn't gated by CSP — but
+related fetches are), and — if you load the web-components embed
+bundle — the CDN you fetch it from.
+
+```http
+Content-Security-Policy:
+ default-src 'self';
+ connect-src 'self' https://*.signalwire.com wss://*.signalwire.com;
+ script-src 'self' https://unpkg.com;
+ style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
+ font-src 'self' https://fonts.gstatic.com;
+ media-src 'self' blob:;
+ img-src 'self' data: blob:;
+ worker-src 'self' blob:;
+ frame-src 'self';
+```
+
+| Directive | Why |
+| ------------- | ------------------------------------------------------------------- |
+| `connect-src` | WebSocket to SignalWire (`wss://`) and your `/api/sw-token` endpoint. |
+| `script-src` | Add `https://unpkg.com` (or your CDN host) if loading the embed bundle by script tag; omit otherwise. |
+| `style-src` | The web components inject `theme.css` and Google Fonts inline. Drop `'unsafe-inline'` and the `fonts.googleapis.com` host if you use `disable-auto-fonts` + `disable-auto-theme`. |
+| `font-src` | Brand fonts from Google. Drop if self-hosted or `disable-auto-fonts` is set. |
+| `media-src` | `blob:` is required for `MediaStream` track URLs. |
+| `worker-src` | `blob:` is required for some browsers' WebRTC stack workers. |
+
+Test the policy with the browser console open — CSP violations log
+loudly.
+
+## 4. Token TTL and refresh
+
+The SDK refreshes credentials automatically *if* your provider exposes
+a `refresh()` method. A static token will expire and the next
+`dial()` will fail with `InvalidCredentialsError`.
+
+```ts
+class RefreshingCredentialProvider {
+ async authenticate() {
+ const r = await fetch("/api/sw-token", { method: "POST" });
+ return await r.json(); // { token, expiry_at }
+ }
+ async refresh() {
+ return this.authenticate();
+ }
+}
+```
+
+Recommended TTLs:
+
+| Use case | TTL |
+| ------------------------- | ------------ |
+| Interactive user session | 1–4 hours |
+| Click-to-call / embed | 15–60 min |
+| Long-running kiosk / bot | 1–8 hours, with `refresh()` mandatory |
+
+Short TTLs limit the blast radius if a token is leaked. The refresh
+flow is transparent to the user.
+
+See [Authentication](/docs/browser-sdk/v4/guides/authentication) for the full
+credential-provider contract.
+
+## 5. Error reporting
+
+Wire `client.errors$` and `call.errors$` into your error tracker
+(Sentry, Datadog, Bugsnag, …) so connection failures and ICE
+disconnects show up in your usual dashboards.
+
+```ts
+client.errors$.subscribe((err) => {
+ Sentry.captureException(err, { tags: { source: "signalwire-client" } });
+});
+
+call.errors$.subscribe((err) => {
+ Sentry.captureException(err, {
+ tags: { source: "signalwire-call", call_id: call.id },
+ });
+});
+```
+
+Track the connection state too — `isConnected$` going false is often
+the first signal of a network problem on the user's side. A short
+"reconnecting…" banner improves perceived reliability significantly.
+
+## 6. Network expectations
+
+WebRTC media is UDP-by-default. Corporate networks frequently block
+UDP outbound, in which case the SDK falls back to TURN-over-TCP via
+SignalWire's relay infrastructure. Two things to verify:
+
+- Open the **Network** panel during a real call — confirm the
+ WebSocket stays connected.
+- Open the **WebRTC internals** (`chrome://webrtc-internals/`) tab —
+ confirm `iceConnectionState` reaches `connected` and stays there.
+
+If you have customers on strict networks, document a fallback ("call
+in on PSTN at +1…") in your support UI. The SDK can't open ports that
+the network has closed.
+
+## 7. Observability
+
+Beyond error tracking, track call quality. The peer connection's
+`getStats()` returns RTP-level metrics that map onto real
+mean-opinion-score deterioration:
+
+```ts
+const stats = await call.rtcPeerConnection.getStats();
+const inbound = [...stats.values()].find(
+ (s) => s.type === "inbound-rtp" && s.kind === "audio"
+);
+if (inbound) {
+ metrics.gauge("call.packets_lost", inbound.packetsLost);
+ metrics.gauge("call.jitter", inbound.jitter);
+}
+```
+
+Sample at the end of each call (or every 30s for long calls) and feed
+into your existing dashboards.
+
+## 8. Pre-launch verification
+
+Run through this list on the actual production URL — not staging,
+not localhost — with real devices:
+
+- [ ] Anonymous visitor can place a click-to-call (if applicable).
+- [ ] Authenticated user can place an outbound call.
+- [ ] Authenticated user receives a registered inbound call.
+- [ ] Permission prompt fires only after user gesture.
+- [ ] Denying the permission shows a graceful help screen.
+- [ ] Token refresh fires before expiry (force-test by setting a 60s TTL).
+- [ ] CSP allows the WebSocket, fonts, and embed bundle (no violations in console).
+- [ ] HTTPS + HSTS confirmed; no mixed content.
+- [ ] Camera-off / muted UI matches actual track state after toggle.
+- [ ] Hangup from both ends cleans up the peer connection (no orphan WebSockets in `chrome://webrtc-internals`).
+- [ ] Error tracker receives a forced failure (e.g. dial an invalid destination).
+- [ ] Mobile Safari: video plays after user gesture and `playsinline` set.
+- [ ] Firefox: audio routes correctly even though `setSinkId` is no-op.
+- [ ] Slow-network simulation (DevTools throttling): SDK reconnects after disconnect.
+
+For per-symptom debugging once the app is live, see
+[Troubleshooting](/docs/browser-sdk/v4/guides/troubleshooting).
diff --git a/fern/products/browser-sdk/pages/v4/guides/deploy/ssr-nextjs.mdx b/fern/products/browser-sdk/pages/v4/guides/deploy/ssr-nextjs.mdx
new file mode 100644
index 000000000..425635384
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/deploy/ssr-nextjs.mdx
@@ -0,0 +1,284 @@
+---
+title: "SSR & Next.js"
+slug: /guides/ssr
+sidebar-title: "SSR & Next.js"
+position: 3
+max-toc-depth: 3
+---
+
+`@signalwire/js` and `@signalwire/web-components` are **browser-only**.
+They depend on `WebSocket`, `RTCPeerConnection`, `navigator.mediaDevices`,
+and `customElements` — APIs that don't exist in Node.js. Importing
+either package at the top of a server-rendered module will crash the
+server during build or during SSR.
+
+The same constraints apply to every server-rendered framework: Next.js,
+Nuxt, SvelteKit, Remix, Astro, Gatsby. The fix is the same in each:
+load the SDK only on the client, and mint tokens on the server.
+
+## The rule
+
+> The SDK runs in the browser. Tokens are minted on the server.
+> Nothing else crosses that line.
+
+That's it. Every concrete pattern below is a way to enforce that
+boundary in a particular framework.
+
+## Next.js (App Router)
+
+### Client components
+
+Wrap any code that touches the SDK in a `"use client"` component. The
+import won't execute on the server.
+
+```tsx
+// app/_components/dialer.tsx
+"use client";
+
+import { useEffect, useState } from "react";
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+export function Dialer({ token }: { token: string }) {
+ const [client, setClient] = useState(null);
+
+ useEffect(() => {
+ const c = new SignalWire(new StaticCredentialProvider({ token }));
+ setClient(c);
+ return () => c.disconnect();
+ }, [token]);
+
+ // ...
+}
+```
+
+A `"use client"` file's transitive imports are fine — they're bundled
+for the browser, not Node. You don't need `dynamic()` for ordinary
+SDK use.
+
+### When `dynamic()` is needed
+
+`next/dynamic` with `ssr: false` is only required when an import has
+side effects that run at *module evaluation time* (custom element
+registration, top-level `new SignalWire(...)`, etc.). The web
+components package registers `customElements.define` on import — so in
+the App Router, import the components from inside an effect or use
+`next/dynamic`:
+
+```tsx
+// app/_components/widget-mount.tsx
+"use client";
+
+import dynamic from "next/dynamic";
+import { useEffect } from "react";
+
+const RegisterWebComponents = dynamic(
+ () => import("@signalwire/web-components").then(() => () => null),
+ { ssr: false }
+);
+
+export function Widget({ token }: { token: string }) {
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+
+For the **embed bundle** (`signalwire-web-components-embed.iife.js`)
+loaded via `
+```
+
+```ts
+// src/routes/api/sw-token/+server.ts
+import { json, type RequestHandler } from "@sveltejs/kit";
+import { env } from "$env/dynamic/private";
+
+export const POST: RequestHandler = async ({ locals }) => {
+ // mint a SAT, return { token, expiry_at }
+ return json({ token, expiry_at });
+};
+```
+
+## Hydration caveats
+
+- **Don't render call state from the server.** Anything driven by an
+ observable belongs in a `useEffect` / `onMount` / `` —
+ not in the initial server render. Otherwise hydration will mismatch
+ (the server rendered `"idle"`, the client mounts with `"connected"`).
+- **Don't read `window` in module scope.** Even inside a `"use client"`
+ file, the module body runs once during client hydration. Wrap any
+ `window.` / `document.` access in an effect.
+- **`` `srcObject` won't survive serialization.** Bind it from
+ an effect after the stream observable emits — never from a prop on
+ the first render.
+
+## Environment variables
+
+| Variable | Lives in | Why |
+| ------------------------- | --------------- | --------------------------------------------------- |
+| `SIGNALWIRE_PROJECT_ID` | Server only | API credential. Never expose to the browser. |
+| `SIGNALWIRE_TOKEN` | Server only | API credential. Never expose to the browser. |
+| `SIGNALWIRE_SPACE` | Server only | Hostname for REST minting. |
+| `NEXT_PUBLIC_SW_HOST` | Public (client) | Optional. The WebSocket host the SDK connects to. |
+
+In Next.js, `NEXT_PUBLIC_*` is the only prefix that gets inlined into
+the client bundle. In Nuxt, use `runtimeConfig.public`. In SvelteKit,
+use `$env/dynamic/public`. **Project ID and auth token must stay on
+the server side of that boundary**, always.
+
+See [Authentication](/docs/browser-sdk/v4/guides/authentication) for the full token
+flow and [Production](/docs/browser-sdk/v4/guides/production) for hardening
+recommendations.
diff --git a/fern/products/browser-sdk/pages/v4/guides/deploy/troubleshooting.mdx b/fern/products/browser-sdk/pages/v4/guides/deploy/troubleshooting.mdx
new file mode 100644
index 000000000..8592929fa
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/deploy/troubleshooting.mdx
@@ -0,0 +1,301 @@
+---
+title: "Troubleshooting & FAQ"
+slug: /guides/troubleshooting
+sidebar-title: "Troubleshooting"
+position: 4
+max-toc-depth: 3
+---
+
+Common issues and solutions when working with the SignalWire Browser SDK.
+
+## Connection issues
+
+### `InvalidCredentialsError` on connect
+
+**Cause:** Token is expired, malformed, or generated for a different environment.
+
+**Fix:**
+
+1. Generate a fresh token from your backend
+2. Verify the token is for the correct SignalWire space
+3. Check that you're copying the full token string
+
+```js
+import { jwtDecode } from "jwt-decode";
+
+const decoded = jwtDecode(token);
+const expiresAt = new Date(decoded.exp * 1000);
+console.log("Expired?", expiresAt < new Date());
+```
+
+### Connection fails silently
+
+**Cause:** Not subscribing to error streams.
+
+```js
+client.errors$.subscribe((error) => console.error("Client error:", error));
+```
+
+### `NotConnectedError` when calling `dial()`
+
+**Cause:** Trying to make a call before the client is ready.
+
+```js
+import { filter, take } from "rxjs";
+
+client.ready$.pipe(filter(Boolean), take(1)).subscribe(async () => {
+ const call = await client.dial(destination);
+});
+```
+
+### WebSocket disconnects frequently
+
+**Causes:** Unstable network, corporate firewall/proxy blocking WebSocket, server-side timeout.
+
+The SDK reconnects automatically — subscribe to `isConnected$` to track state:
+
+```js
+client.isConnected$.subscribe((connected) => {
+ connected ? hideReconnectingBanner() : showReconnectingBanner();
+});
+```
+
+## Video / Audio issues
+
+### Video is black
+
+**Causes:** Camera permissions denied, camera in use by another app, wrong camera selected, hardware issue.
+
+```js
+const permission = await navigator.permissions.query({ name: "camera" });
+console.log("Camera permission:", permission.state);
+
+client.videoInputDevices$.subscribe((devices) => {
+ console.log("Available cameras:", devices);
+});
+```
+
+### No remote audio
+
+**Causes:** Speaker/headphone issue, audio output not selected, remote muted, autoplay blocked.
+
+```html
+
+
+
+
+
+```
+
+Browsers block autoplay with audio until the user interacts with the page.
+
+### Remote can't hear me
+
+```js
+call.self$.subscribe((self) => console.log("Audio muted:", self?.audioMuted));
+
+client.selectedAudioInputDevice$.subscribe((device) => {
+ console.log("Microphone:", device?.label);
+});
+```
+
+### Echo or feedback
+
+Use headphones, or enable echo cancellation:
+
+```js
+call.self$.subscribe(async (self) => {
+ if (self && !self.echoCancellation) await self.toggleEchoCancellation();
+});
+```
+
+### `Permission denied` for camera/microphone
+
+**HTTPS is required.** `getUserMedia` only works on secure contexts (HTTPS or localhost).
+
+```js
+try {
+ await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
+} catch (e) {
+ if (e.name === "NotAllowedError") {
+ alert("Click the lock icon in your address bar to reset permissions.");
+ }
+}
+```
+
+## Call issues
+
+### Call stays in `trying` state
+
+**Causes:** Invalid destination, destination not reachable, network/firewall blocking.
+
+```js
+call.errors$.subscribe((error) => console.error("Call error:", error));
+```
+
+### Call connects but no media flows
+
+**Cause:** ICE connection failed (firewall blocking UDP) or TURN server unreachable.
+
+```js
+const pc = call.rtcPeerConnection;
+console.log("ICE state:", pc?.iceConnectionState);
+pc?.addEventListener("iceconnectionstatechange", () => {
+ console.log("ICE state changed:", pc.iceConnectionState);
+});
+```
+
+### Can't receive inbound calls
+
+**Causes:** Not registered, using embed token (no inbound), subscriber not configured.
+
+```js
+client.ready$.subscribe(async (ready) => {
+ if (ready) await client.register();
+});
+```
+
+### DTMF tones not working
+
+Send digits only after the call is connected:
+
+```js
+import { filter, take } from "rxjs";
+
+call.status$
+ .pipe(filter((s) => s === "connected"), take(1))
+ .subscribe(async () => await call.sendDigits("123#"));
+```
+
+## UI issues
+
+### Video element shows nothing
+
+```js
+call.remoteStream$.subscribe((stream) => {
+ const video = document.getElementById("remoteVideo");
+ if (stream && video) {
+ video.srcObject = stream;
+ video.play().catch((e) => console.error("Play failed:", e));
+ }
+});
+```
+
+### UI doesn't update when state changes
+
+Subscribe immediately after getting the object — BehaviorSubjects emit current state on subscribe.
+
+### Memory leak / page slows down
+
+Always unsubscribe. See [RxJS Primer → Cleanup](/docs/browser-sdk/v4/guides/rxjs-primer#cleanup).
+
+## Browser-specific issues
+
+### Safari: video doesn't play
+
+Safari has strict autoplay policies. Add `playsinline` and handle the play promise:
+
+```html
+
+```
+
+```js
+call.remoteStream$.subscribe(async (stream) => {
+ const video = document.getElementById("remote");
+ video.srcObject = stream;
+ try {
+ await video.play();
+ } catch {
+ showPlayButton(() => video.play());
+ }
+});
+```
+
+### Firefox: no audio output selection
+
+Firefox doesn't fully support `setSinkId`. Audio plays through default output.
+
+### Mobile: camera switches unexpectedly
+
+Device rotation or app switching can reset the camera.
+
+```js
+client.videoInputDevices$.subscribe((devices) => {
+ const preferred = devices.find((d) => d.label.includes("front"));
+ if (preferred) call.self?.selectVideoInputDevice(preferred);
+});
+```
+
+## Debugging
+
+### Verbose logging
+
+The SDK logs at debug level. Filter the browser console by `signalwire`.
+
+### Inspect WebSocket traffic
+
+DevTools → Network → "WS" filter → click the connection → Messages tab.
+
+### Get call statistics
+
+```js
+const stats = await call.rtcPeerConnection.getStats();
+stats.forEach((report) => {
+ if (report.type === "inbound-rtp" && report.kind === "video") {
+ console.log("Packets received:", report.packetsReceived);
+ console.log("Packets lost:", report.packetsLost);
+ }
+});
+```
+
+### Test without real media
+
+Chrome flag: `--use-fake-device-for-media-stream`. Or generate a canvas stream:
+
+```js
+const canvas = document.createElement("canvas");
+canvas.getContext("2d").fillRect(0, 0, 640, 480);
+const fakeStream = canvas.captureStream(30);
+```
+
+## FAQ
+
+### Do I need HTTPS?
+
+Yes for production. WebRTC's `getUserMedia` requires a secure context. Localhost is exempt for development.
+
+### What browsers are supported?
+
+Modern Chrome, Firefox, Safari, and Edge. No IE11.
+
+### Can I use this in Node.js?
+
+No — the SDK is browser-only. Use the SignalWire REST APIs or server-side SDKs.
+
+### How do I implement a mute button?
+
+```js
+muteButton.onclick = () => call.self?.toggleMute();
+```
+
+`call.self` is `null` until the local participant joins — always check.
+
+### How do I get call duration?
+
+```js
+let startTime;
+call.status$.subscribe((status) => {
+ if (status === "connected") startTime = Date.now();
+ if (status === "disconnected" && startTime) {
+ console.log("Lasted:", Math.round((Date.now() - startTime) / 1000), "s");
+ }
+});
+```
+
+### Can I record calls?
+
+Recording is controlled server-side through the SignalWire platform. Check `call.capabilities$` for available features.
+
+### Why "Unimplemented" errors?
+
+Some features require specific server capabilities or are still in development. Check `call.capabilities$`.
diff --git a/fern/products/browser-sdk/pages/v4/guides/examples/_live-streaming-broadcast.mdx.draft b/fern/products/browser-sdk/pages/v4/guides/examples/_live-streaming-broadcast.mdx.draft
new file mode 100644
index 000000000..bb1cf7c43
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/examples/_live-streaming-broadcast.mdx.draft
@@ -0,0 +1,11 @@
+---
+title: "Live Streaming Broadcast"
+slug: /guides/examples/live-streaming-broadcast
+sidebar-title: "Live Streaming"
+position: 3
+max-toc-depth: 3
+---
+
+A one-to-many broadcast app: a small panel of hosts goes live, viewers watch via HLS/RTMP fanout, and viewers can be promoted to the stage on demand.
+
+> **TODO:** Stub. Combine the v3 "interactive-live-streaming" and "streaming-to-youtube" samples.
diff --git a/fern/products/browser-sdk/pages/v4/guides/examples/_overview.mdx.draft b/fern/products/browser-sdk/pages/v4/guides/examples/_overview.mdx.draft
new file mode 100644
index 000000000..8135bdb42
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/examples/_overview.mdx.draft
@@ -0,0 +1,11 @@
+---
+title: "Overview"
+slug: /guides/examples
+sidebar-title: "Overview"
+position: 0
+max-toc-depth: 3
+---
+
+Complete, runnable example apps built with the Browser SDK. Each example pairs a working GitHub repo with a walkthrough explaining the architecture, the key SDK calls, and the trade-offs made.
+
+> **TODO:** Stub. Index and short blurb for each example below.
diff --git a/fern/products/browser-sdk/pages/v4/guides/examples/_video-conference-app.mdx.draft b/fern/products/browser-sdk/pages/v4/guides/examples/_video-conference-app.mdx.draft
new file mode 100644
index 000000000..05daa5a8b
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/examples/_video-conference-app.mdx.draft
@@ -0,0 +1,11 @@
+---
+title: "Video Conference App"
+slug: /guides/examples/video-conference-app
+sidebar-title: "Video Conference"
+position: 1
+max-toc-depth: 3
+---
+
+A Zoom-style multi-party video conference: lobby, grid/presenter layouts, mute controls, screen share, and a roster of participants.
+
+> **TODO:** Stub. Carry forward the v3 "zoom-clone" reference app, ported to v4 (`SignalWire` client, observables, web components where useful).
diff --git a/fern/products/browser-sdk/pages/v4/guides/getting-started/authentication.mdx b/fern/products/browser-sdk/pages/v4/guides/getting-started/authentication.mdx
new file mode 100644
index 000000000..464cf6890
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/getting-started/authentication.mdx
@@ -0,0 +1,392 @@
+---
+title: "Authentication"
+subtitle: "Learn how to authenticate with the browser SDK client"
+slug: /guides/authentication
+sidebar-title: "Authentication"
+position: 2
+max-toc-depth: 3
+---
+
+The SDK authenticates with a **Subscriber Access Token (SAT)**, a short-lived credential that identifies which [**Subscriber**](/docs/platform/subscribers) (SignalWire's term for a user in your communication system) the SDK is acting on behalf of. Your backend creates the SAT using your Project API Token, then hands it to the browser, where the SDK uses it to open a WebSocket session with SignalWire.
+
+```ts Browser
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(
+ new StaticCredentialProvider({ token: "" })
+);
+```
+
+The sections below cover which kind of SAT to create, how to deliver it to the browser, and how to keep the session alive past the SAT's expiry.
+
+
+**Before you start.** You need a SignalWire space, a Project ID, and an API token with at least one of the **Voice / Messaging / Fax / Video** scopes. All three are in the [API Credentials section of the dashboard](https://my.signalwire.com/?page=api). The Project API Token is what creates SATs. Keep it server-side only.
+
+
+## Authentication patterns
+
+The SDK supports four authentication patterns, each shaped by who is holding the credential and what they need to do with it.
+
+
+
+ For apps where users sign in with an account. Each user can place and receive calls.
+
+
+ For users without an account who need limited calling, typically to a short list of destinations you allow.
+
+
+ For embedding a "call us" button on a public webpage. Anyone visiting can dial one preset destination.
+
+
+ For giving a specific recipient a way to connect to a call through a shareable invite.
+
+
+
+Each pattern uses a different credential: three **Subscriber Access Token (SAT)** flavors issued for a subscriber, guest, or invitee, and a separate **Embed token** for public widgets. The credential's capabilities determine what the holder can do:
+
+| Pattern | Credential | Inbound calls | Outbound calls | Destinations | Audience |
+|---|---|:---:|:---:|---|---|
+| Authenticated users | **[Subscriber Access Token](/docs/apis/rest/subscribers/tokens/create-subscriber-token)** | ✓ | ✓ | Anywhere the subscriber can reach | One signed-in user |
+| Guest users | **[Guest SAT](/docs/apis/rest/subscribers/tokens/create-subscriber-guest-token)** | ✗ | ✓ | A list of allowed destinations you set (max 10) | One guest user with scoped capabilities |
+| Call invite | **[Invite SAT](/docs/apis/rest/subscribers/tokens/create-subscriber-invite-token)** | ✗ | ✓ | The inviting subscriber's address | One invitee |
+| Public usage | **[Embed token](/docs/apis/rest/embeds/tokens/create-guest-embed-token)** | ✗ | ✓ | Tied to a single resource | Anyone visiting a public page |
+
+Match the credential's reach to the trust level of whoever holds it. If a credential can dial anyone, then anyone who can read it can dial anyone, so use the [delivery model](#how-the-sdk-gets-its-credential) below that keeps the credential out of untrusted hands.
+
+## How the SDK gets its credential
+
+Credentials reach the SDK one of two ways. Embed tokens live in the page itself. Every other variant is created server-side and handed to the browser; the only thing that differs is whether the SDK should keep the session alive past the credential's first expiry.
+
+### Embed tokens (in-page)
+
+Embed tokens are the only credential designed to sit in a public page. They are pinned to one Click-to-Call resource: anyone who reads the page can only dial the resource the embed token was created for. That fixed scope is what makes them safe to expose to every visitor.
+
+Getting an embed token is a two-step setup:
+
+1. Create a [Click-to-Call resource in your SignalWire dashboard](https://my.signalwire.com/?page=click_to_calls). The dashboard issues a **Click-to-Call (C2C) token** (with a `c2c_` prefix) tied to that resource.
+2. Exchange the C2C token for an **embed token** by calling [`POST /api/embeds/tokens`](/docs/apis/rest/embeds/tokens/create-guest-embed-token). The embed token is what the SDK is built to consume for public widgets.
+
+The SDK also accepts a C2C token directly as a shortcut (convenient for testing), but production widgets should pass the exchanged embed token. The examples below use the shortcut form so they can run with only the dashboard value.
+
+**Shortcut for a single call.** [`embeddableCall()`](/docs/browser-sdk/v4/reference/functions/embeddable-call) handles credential exchange, client construction, and dial in one call:
+
+```ts Browser
+import { embeddableCall } from "@signalwire/js";
+
+const call = await embeddableCall({
+ host: "yourspace.signalwire.com",
+ embedToken: "c2c_7acc0e5e968706a032983cd80cdca219",
+ to: "/public/support",
+});
+```
+
+**Full SDK setup for multiple calls or client-level subscriptions.** Pass [`EmbedTokenCredentialProvider`](/docs/browser-sdk/v4/reference/credential-providers/embed-token-credential-provider) to the SDK directly. You keep a long-lived [`SignalWire`](/docs/browser-sdk/v4/reference/signalwire) client that can dial repeatedly and exposes observables you can subscribe to. The provider exchanges the embed token for a Guest SAT and refreshes automatically:
+
+```ts Browser
+import { SignalWire, EmbedTokenCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(
+ new EmbedTokenCredentialProvider(
+ "yourspace.signalwire.com",
+ "c2c_7acc0e5e968706a032983cd80cdca219"
+ )
+);
+
+const call = await client.dial("/public/support");
+```
+
+### Server-fetched SATs
+
+The browser asks for a SAT, your backend creates one using the Project API Token, and the SDK uses it for the session.
+
+```mermaid
+sequenceDiagram
+ participant B as Browser (SDK)
+ participant S as Your Backend
+ participant SW as SignalWire
+
+ B->>S: Request SAT (authenticated user session)
+ Note over S: Project API Token never leaves the server
+ S->>SW: POST /api/fabric/subscribers/tokens (Project API Token)
+ SW-->>S: { token, refresh_token }
+ S-->>B: { token, expiresAt }
+ B->>SW: Open WebSocket (token)
+ SW-->>B: Session ready
+```
+
+The browser never talks to SignalWire directly here. Creating any SAT requires the Project API Token, which can issue a SAT for *any* user in your project. That is why it stays server-side. The hop through your backend is what enforces "this browser session can only get the SAT it's authorized for."
+
+Three SAT variants come through this path:
+
+- **Subscriber Access Token** ([`POST /api/fabric/subscribers/tokens`](/docs/apis/rest/subscribers/tokens/create-subscriber-token)): full subscriber identity for a signed-in user; can receive inbound calls. Also called a default-scope SAT when you need to distinguish it from the variants below.
+- **Guest SAT** ([`POST /api/fabric/guests/tokens`](/docs/apis/rest/subscribers/tokens/create-subscriber-guest-token)): outbound-only, pinned to up to 10 `allowed_addresses`.
+- **Invite SAT** ([`POST /api/fabric/subscriber/invites`](/docs/apis/rest/subscribers/tokens/create-subscriber-invite-token)): outbound-only, pinned to one address; created client-side by a signed-in subscriber and delivered out-of-band (URL, email, QR code) to one recipient.
+
+Once the SAT is in the browser, the next decision is **whether the session needs to outlive a single SAT**. For one-shot sessions (typical for Guest and Invite SATs), use [`StaticCredentialProvider`](/docs/browser-sdk/v4/reference/credential-providers/static-credential-provider). The SDK uses the fetched SAT until it expires, then the session ends. For sessions that must outlive a single SAT, pick a refresh strategy below.
+
+## Refreshing SATs
+
+SATs are short-lived (two hours by default), which limits the damage if one ever leaks. When a SAT expires, the SDK's WebSocket session ends with it unless a fresh SAT is supplied first. **Refreshing** is the process of swapping in a fresh SAT before the current one expires, so the session continues uninterrupted: the user stays connected, ongoing calls aren't dropped, and they don't need to re-authenticate.
+
+There are two ways to refresh a SAT, depending on where the rotation logic should live.
+
+### Server-side refresh
+
+The backend rotates the SAT. Your [`CredentialProvider`](/docs/browser-sdk/v4/reference/interfaces/credential-provider) exposes a [`refresh()`](/docs/browser-sdk/v4/reference/interfaces/credential-provider) method that fetches a fresh SAT from your backend; the SDK calls it shortly before the current SAT's `expiry_at`. Every rotation roundtrips through your backend.
+
+```ts Browser
+import { SignalWire } from "@signalwire/js";
+import type { CredentialProvider } from "@signalwire/js";
+
+class BackendSAT implements CredentialProvider {
+ async authenticate() {
+ const r = await fetch("/api/signalwire-token", {
+ method: "POST",
+ // `credentials: "include"` tells fetch to send the browser's cookies with
+ // the request, so your backend reads its own session cookie and knows
+ // which signed-in user is asking for a token.
+ credentials: "include",
+ });
+ const { token, expiresAt } = await r.json();
+ // expiry_at is a Date.now()-style millisecond timestamp.
+ return { token, expiry_at: expiresAt };
+ }
+
+ refresh() {
+ return this.authenticate();
+ }
+}
+
+const client = new SignalWire(new BackendSAT());
+```
+
+
+Inside `/api/signalwire-token`, your backend produces the fresh SAT one of two ways:
+
+
+
+ Every SAT comes back with a companion `refresh_token`. Your backend stores it and swaps it for a new SAT/refresh-token pair via [`POST /api/fabric/subscribers/tokens/refresh`](/docs/apis/rest/subscribers/tokens/refresh-subscriber-token). This keeps the session going without re-checking the user's app session on every rollover.
+
+ ```js Server (Node.js)
+ app.post("/api/signalwire-token", async (req, res) => {
+ // Look up the refresh_token you stored when this user's subscriber was created.
+ const stored = await getRefreshTokenForUser(req.user.id);
+
+ // Swap that refresh_token for a new SAT + new refresh_token pair.
+ const r = await fetch(`https://${SPACE}/api/fabric/subscribers/tokens/refresh`, {
+ method: "POST",
+ headers: { Authorization: BASIC_AUTH, "Content-Type": "application/json" },
+ body: JSON.stringify({ refresh_token: stored }),
+ });
+ const { token, refresh_token } = await r.json();
+
+ // Save the rotated refresh_token so the next call can swap it too.
+ await saveRefreshTokenForUser(req.user.id, refresh_token);
+
+ // expiresAt assumes the 2h default; if you set `expire_at` when you created the SAT, compute from that.
+ res.json({ token, expiresAt: Date.now() + 2 * 60 * 60 * 1000 });
+ });
+ ```
+
+ The new access token carries the standard SAT lifetime; the new refresh token outlives it by five minutes so the swap has slack. Store refresh tokens encrypted, server-side only.
+
+
+ If you'd rather not store refresh tokens, re-authenticate the user on every rollover (usually via their app session cookie) and create a brand-new SAT. Stateless on your side, but it hits your user-auth path every time a SAT expires.
+
+ ```js Server (Node.js)
+ app.post("/api/signalwire-token", requireUserAuth, async (req, res) => {
+ // `requireUserAuth` reads the session cookie and rejects the request if
+ // there's no signed-in app user, then populates `req.user`.
+ const r = await fetch(`https://${SPACE}/api/fabric/subscribers/tokens`, {
+ method: "POST",
+ headers: { Authorization: BASIC_AUTH, "Content-Type": "application/json" },
+ // `reference` is the string SignalWire uses to identify the subscriber.
+ // Pick a stable identifier from your user record (email, UUID) and use
+ // the same one every time so the same subscriber is found across logins.
+ body: JSON.stringify({ reference: req.user.email }),
+ });
+ const { token } = await r.json();
+ // expiresAt assumes the 2h default; if you set `expire_at` when you created the SAT above, compute from that.
+ res.json({ token, expiresAt: Date.now() + 2 * 60 * 60 * 1000 });
+ });
+ ```
+
+
+
+### Client-side refresh
+
+The SDK rotates the SAT directly with SignalWire after it is first issued. Your backend is involved only at startup.
+
+This path binds the SAT to the browser session that requested it. The SDK provides a public **fingerprint** at authentication time; the backend includes that fingerprint plus `scope: "sat:refresh"` on the create request. Refresh calls are then signed against the matching private key the browser holds, so a SAT lifted off the wire can't be rotated from anywhere else.
+
+```ts Browser
+import { SignalWire } from "@signalwire/js";
+import type { CredentialProvider, AuthenticateContext } from "@signalwire/js";
+
+class BackendSAT implements CredentialProvider {
+ async authenticate(context?: AuthenticateContext) {
+ const r = await fetch("/api/signalwire-token", {
+ method: "POST",
+ // `credentials: "include"` tells fetch to send the browser's cookies with
+ // the request, so your backend reads its own session cookie and knows
+ // which signed-in user is asking for a token.
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ // Forward the SDK's fingerprint so the backend can issue a SAT bound to this browser.
+ body: JSON.stringify({ fingerprint: context?.fingerprint }),
+ });
+ const { token, expiresAt } = await r.json();
+ return { token, expiry_at: expiresAt };
+ }
+
+ // No refresh() — rotation happens directly between the SDK and SignalWire after the SAT is first issued.
+}
+
+const client = new SignalWire(new BackendSAT());
+```
+
+On the backend side, forward the fingerprint and request the refresh scope when creating the SAT:
+
+```js Server (Node.js)
+app.post("/api/signalwire-token", requireUserAuth, async (req, res) => {
+ // `requireUserAuth` reads the session cookie and populates `req.user` with
+ // the signed-in app user; `req.body.fingerprint` was forwarded by the SDK.
+ const r = await fetch(`https://${SPACE}/api/fabric/subscribers/tokens`, {
+ method: "POST",
+ headers: { Authorization: BASIC_AUTH, "Content-Type": "application/json" },
+ body: JSON.stringify({
+ reference: req.user.email, // identifies the SignalWire subscriber
+ fingerprint: req.body.fingerprint, // binds the SAT to this browser
+ scope: "sat:refresh", // lets the SDK refresh without your backend
+ }),
+ });
+ const { token } = await r.json();
+ res.json({ token, expiresAt: Date.now() + 2 * 60 * 60 * 1000 });
+});
+```
+
+For the rotation endpoints, refresh events you can subscribe to, and failure modes, see [`CredentialProvider`](/docs/browser-sdk/v4/reference/interfaces/credential-provider).
+
+## Connection lifecycle
+
+Constructing [`SignalWire`](/docs/browser-sdk/v4/reference/signalwire) runs three steps in sequence: authenticate the SAT, open the WebSocket, and register the subscriber as online. Each step runs by default, and each can be deferred with a constructor option in [`SignalWireOptions`](/docs/browser-sdk/v4/reference/interfaces/signalwire-options) so your UI can drive it later.
+
+| Step | Default | Defer with | Run later with |
+| --- | --- | --- | --- |
+| Open the WebSocket | runs | `skipConnection: true` | [`client.connect()`](/docs/browser-sdk/v4/reference/signalwire/connect) |
+| Register as online | runs | `skipRegister: true` | [`client.register()`](/docs/browser-sdk/v4/reference/signalwire/register) |
+| Persist across reloads | off | `persist: true` | (constructor only) |
+
+```ts Browser
+const client = new SignalWire(credentialProvider, {
+ skipConnection: true,
+ skipRegister: true,
+});
+
+await client.connect(); // open the WebSocket when the user opts in
+await client.register(); // come online for inbound calls
+```
+
+## Going online and offline
+
+[`register()`](/docs/browser-sdk/v4/reference/signalwire/register) tells SignalWire the subscriber is online on this session, so inbound calls and presence events route here. It runs automatically when the client constructs unless `skipRegister: true` is set — defer it when the user needs to opt in (microphone prompt, "Go online" toggle, permissions step) before they start receiving calls.
+
+[`unregister()`](/docs/browser-sdk/v4/reference/signalwire/unregister) is the opposite: the subscriber goes offline for inbound calls, but the WebSocket stays open so outbound calls and observable subscriptions keep working. Use it for Do Not Disturb, app-background, or "available / away" toggles.
+
+```ts Browser
+await client.unregister(); // go offline; socket stays open
+await client.register(); // come back online later
+```
+
+Closing the session entirely is a separate step. [`disconnect()`](/docs/browser-sdk/v4/reference/signalwire/disconnect) closes the WebSocket, and [`destroy()`](/docs/browser-sdk/v4/reference/signalwire/destroy) wipes persisted state on explicit logout.
+
+Only credentials issued with subscriber identity can register. Guest SATs, Invite SATs, and embed-derived Guest SATs are outbound-only, so `register()` is a no-op on those clients — inbound calls require a full Subscriber Access Token.
+
+## Try it: create a SAT and connect
+
+Create a Subscriber Access Token (SAT) for your project using the request snippet below. Have your space name and an API token ready, with at least one of the **Voice / Messaging / Fax / Video** scopes. Both come from the API Credentials section of the SignalWire dashboard. Open the [Create Subscriber Token](/docs/apis/rest/subscribers/tokens/create-subscriber-token) reference to send the request with your space and credentials filled in.
+
+
+The Project API Token can issue a SAT for any user in your project. Use a development project, or rotate the API token afterward.
+
+
+
+
+Copy the returned `token`, save the page below as `auth-demo.html`, and open it in a browser. Paste the SAT into the input, click **Authenticate**, and watch the log. It reports whether the SDK was able to open a session with the SAT.
+
+
+```html
+
+
+
+
+ SignalWire SDK auth demo
+
+
+
+ SignalWire SDK auth demo
+
+ Subscriber Access Token
+
+ Authenticate
+
+
+
+
+
+
+```
+
+
+You should see `Authenticated — WebSocket open.` in the log. If you see `Failed: InvalidCredentialsError`, the SAT is expired, malformed, or issued for a different SignalWire space than the SDK is connecting to. Create a fresh one and try again.
+
+## Next steps
+
+
+
+ Receive incoming calls in a signed-in subscriber session.
+
+
+ Dial subscribers, rooms, or PSTN destinations.
+
+
+ Platform concept: who a credential represents and what addresses they can reach.
+
+
diff --git a/fern/products/browser-sdk/pages/v4/guides/getting-started/migrate-from-v3.mdx b/fern/products/browser-sdk/pages/v4/guides/getting-started/migrate-from-v3.mdx
new file mode 100644
index 000000000..70d262f6e
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/getting-started/migrate-from-v3.mdx
@@ -0,0 +1,618 @@
+---
+title: "Migrate from v3"
+slug: /guides/migrate-from-v3
+sidebar-title: "Migrate from v3"
+position: 4
+max-toc-depth: 3
+---
+
+This guide walks through moving an existing v3 (`@signalwire/js@3.x`) integration to v4. v3 was built around `RoomSession` and event emitters for video conferencing. v4 unifies calling and conferencing under a single `Call` API, replaces event emitters with RxJS observables, and introduces a credential-provider auth model with automatic token refresh.
+
+If you are using the older RELAY v2 SDK, see [Migrate from v2](/docs/browser-sdk/v4/guides/migrate-from-v2) instead.
+
+## At a glance
+
+| Concern | v3 | v4 |
+| ------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- |
+| Initialization | `await SignalWire({ host, token })` (async factory) | `new SignalWire(credentialProvider)` (class constructor) |
+| Authentication | Token passed directly | `CredentialProvider` with auto-refresh (`StaticCredentialProvider`, custom) |
+| State | Event emitters — `roomObj.on('event', handler)` | RxJS observables — `call.status$.subscribe(handler)` |
+| Call controls | `roomObj.audioMute()`, `roomObj.videoMute()` | `call.self.toggleMute()`, `call.self.toggleMuteVideo()` |
+| Media rendering | `rootElement` passed to `dial()` — SDK manages the DOM | `` / `` components, or `localStream$`/`remoteStream$` |
+| Devices | `getCameraDevicesWithPermissions()`, `roomObj.updateCamera()` | `client.audioInputDevices$`, `self.selectAudioInputDevice()` |
+| Directory | Paginated API — `client.address.getAddresses({...})` | Observable directory — `client.directory.addresses$`, `loadMore()` |
+| Inbound calls | `client.online({ incomingCallHandlers })` | `client.session.incomingCalls$` (always active after register) |
+| Messaging | `client.conversation.sendMessage()` / `subscribe()` | `callAddress.sendText()` / `callAddress.textMessages$` |
+
+## Feature compatibility
+
+v4 covers the bulk of v3, but some features are still in progress. Check this table before migrating.
+
+| Feature | v4 Status | Alternative |
+| ---------------------- | ------------------ | ---------------------------- |
+| Video rooms & calling | Implemented | — |
+| Participants & events | Implemented | — |
+| Layouts | Implemented | — |
+| Screen sharing | Implemented | — |
+| Mute/unmute | Implemented | — |
+| Device selection | Implemented | — |
+| DTMF | Implemented | — |
+| Hold/unhold | Implemented | — |
+| Recording | Not implemented | Use SWML or the REST API |
+| Streaming (RTMP) | Not implemented | Use the server-side REST API |
+| Playback | Not implemented | Use SWML |
+| Room locking | Not implemented | — |
+| Metadata (`setMeta`) | Not implemented | — |
+| Call transfer | Not implemented | — |
+
+If your application depends on recording, streaming, playback, room locking, metadata, or transfer, wait for these features to land before migrating.
+
+## Migration checklist
+
+- [ ] Update the package and import paths
+- [ ] Replace `await SignalWire({ token })` with `new SignalWire(credentialProvider)`
+- [ ] Remove `rootElement` from `dial()` and attach media streams manually (or use web components)
+- [ ] Drop `node_id` / `userVariables` / `await call.start()` — handled by v4 internally
+- [ ] Convert `RoomSession` methods to `Call` / `call.self` equivalents
+- [ ] Replace `roomObj.on('event', ...)` with `call.eventName$.subscribe(...)`
+- [ ] Update `invite.accept` / `invite.reject` to `call.answer()` / `call.reject()`
+- [ ] Move screen share from the room to `call.self`
+- [ ] Swap `WebRTC.getCameras()` etc. for `client.videoInputDevices$`
+- [ ] Pass full `MediaDeviceInfo` objects (not bare `deviceId`) to device selectors
+- [ ] Replace `client.address.getAddresses()` with `client.directory.addresses$`
+- [ ] Replace `client.conversation` messaging with `callAddress.sendText()` / `textMessages$`
+- [ ] Add explicit cleanup: `call.hangup()`, `client.disconnect()`, `client.destroy()`
+
+## Installation
+
+The package name is unchanged. Upgrade to the v4 major release:
+
+```bash
+npm install @signalwire/js@latest
+```
+
+For the browser build:
+
+```html
+
+```
+
+v4 ships as an ES module. If you bundled v3 as a CDN global, switch to module imports:
+
+```html
+
+
+
+
+
+```
+
+```js
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+```
+
+## Authentication
+
+v3 accepted a room token directly. v4 introduces a **CredentialProvider** that owns the token lifecycle — including scheduled refresh before expiry. Use **Subscriber Access Tokens (SAT)** for authenticated users and **Embed Tokens** for guest access. See [Authentication](/docs/browser-sdk/v4/guides/authentication) for the full reference.
+
+The SDK ships with `StaticCredentialProvider` for pre-obtained tokens (build-time SAT, server-rendered pages). For long-running apps, implement a custom provider that fetches and refreshes a SAT from your backend.
+
+### Client Bound SAT (DPoP)
+
+When the SDK passes an `AuthenticateContext` with a DPoP key `fingerprint`, forward it to your token endpoint to request a **Client Bound SAT** with automatic refresh:
+
+```js
+class UserCredentialProvider {
+ async authenticate(context) {
+ const response = await fetch("/api/subscriber/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fingerprint: context?.fingerprint }),
+ });
+ const { token, expiresAt } = await response.json();
+ return { token, expiry_at: expiresAt };
+ }
+
+ async refresh() {
+ return this.authenticate();
+ }
+}
+```
+
+## Client initialization
+
+v3 was an async factory. v4 is a synchronous constructor; connection happens automatically when you subscribe to the first observable.
+
+**Before (v3):**
+
+```js
+const client = await SignalWire({
+ host,
+ token: "",
+ debug: { logWsTraffic: true },
+});
+```
+
+**After (v4):**
+
+```js
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const credentials = new StaticCredentialProvider({ token: "" });
+const client = new SignalWire(credentials);
+
+client.ready$.subscribe((ready) => {
+ if (ready) console.log("Client connected and authenticated");
+});
+
+client.errors$.subscribe((error) => {
+ console.error("Client error:", error);
+});
+```
+
+### Connection state
+
+v3 had no separate connection observable — the factory was the connect call. v4 exposes connection state reactively:
+
+```js
+client.isConnected$.subscribe((connected) => { /* ... */ });
+client.isRegistered$.subscribe((registered) => { /* ... */ });
+client.ready$.subscribe((ready) => { /* connected + authenticated */ });
+```
+
+## Outbound calls
+
+v3 took an options object with `to`, `rootElement`, optional `nodeId` for routing, and `userVariables`, then required `await call.start()`. v4 takes the destination as the first argument, handles steering internally, and does **not** auto-attach media — you wire streams up yourself.
+
+**Before (v3):**
+
+```js
+const call = await client.dial({
+ to: "/private/user1",
+ rootElement: document.getElementById("container"),
+ nodeId: steeringId,
+ userVariables: { /* ... */ },
+});
+await call.start();
+```
+
+**After (v4):**
+
+```js
+const call = await client.dial("/private/user1", {
+ audio: true,
+ video: true,
+});
+// No rootElement, no nodeId, no start() — routing is internal
+
+call.remoteStream$.subscribe((stream) => {
+ if (stream) document.getElementById("remoteVideo").srcObject = stream;
+});
+
+call.localStream$.subscribe((stream) => {
+ if (stream) document.getElementById("localVideo").srcObject = stream;
+});
+```
+
+### Full call lifecycle
+
+```js
+// v3
+const call = await client.dial({ to, rootElement, nodeId, userVariables });
+await call.start();
+roomObj.on("room.joined", handler);
+roomObj.on("media.connected", handler);
+roomObj.hangup();
+
+// v4
+const call = await client.dial(address, { audio, video });
+call.status$.subscribe(handler); // replaces on('room.joined')
+call.localStream$.subscribe(/* ... */); // replaces rootElement auto-render
+call.remoteStream$.subscribe(/* ... */);
+call.hangup();
+```
+
+## Inbound calls
+
+v3 used `client.online({ incomingCallHandlers })` with callbacks and an explicit `offline()`. v4 exposes incoming calls as an observable that is always active after `register()`. `answer()` and `reject()` are **synchronous** in v4 — no `await` needed.
+
+**Before (v3):**
+
+```js
+await client.online({
+ incomingCallHandlers: {
+ all: (notification) => {
+ window.__invite = notification.invite;
+ },
+ },
+});
+
+const call = await window.__invite.accept({
+ rootElement: document.getElementById("container"),
+});
+await window.__invite.reject();
+await client.offline();
+```
+
+**After (v4):**
+
+```js
+await client.register();
+
+client.session.incomingCalls$.subscribe((calls) => {
+ const ringing = calls.filter((c) => c.status === "ringing");
+ if (ringing.length > 0) showIncomingCallUI(ringing[0]);
+});
+
+function acceptCall(call) {
+ call.answer(); // synchronous
+ call.remoteStream$.subscribe((stream) => {
+ document.getElementById("remoteVideo").srcObject = stream;
+ });
+}
+
+function rejectCall(call) {
+ call.reject(); // synchronous
+}
+```
+
+## RoomSession → Call
+
+v3 distinguished between `CallFabricRoomSession` and `RoomSession`. v4 collapses both into a single `Call`, with self-participant controls moved off the room object onto `call.self`.
+
+| v3 | v4 |
+| -------------------------------- | ------------------------------ |
+| `roomSession.audioMute()` | `call.self.mute()` |
+| `roomSession.audioUnmute()` | `call.self.unmute()` |
+| `roomSession.videoMute()` | `call.self.muteVideo()` |
+| `roomSession.videoUnmute()` | `call.self.unmuteVideo()` |
+| `roomSession.deaf()` | `call.self.toggleDeaf()` |
+| `roomSession.startScreenShare()` | `call.self.startScreenShare()` |
+| `roomSession.stopScreenShare()` | `call.self.stopScreenShare()` |
+| `roomSession.setMicrophoneVolume({ volume })` | `call.self.setAudioInputVolume(value)` |
+| `roomSession.setSpeakerVolume({ volume })` | `call.self.setAudioOutputVolume(value)` |
+
+## Self participant
+
+`call.self` is a full participant object with reactive state.
+
+```js
+const self = call.self;
+
+await self.mute();
+await self.unmute();
+await self.toggleMute();
+
+await self.muteVideo();
+await self.unmuteVideo();
+await self.toggleMuteVideo();
+
+await self.toggleDeaf();
+
+// Sync access
+const isMuted = call.self?.audioMuted;
+const isVideoMuted = call.self?.videoMuted;
+
+// Reactive
+call.self$.subscribe((self) => {
+ if (self) {
+ self.audioMuted$.subscribe((muted) => updateMuteButton(muted));
+ }
+});
+```
+
+## Participants
+
+Event emitters are gone — participants are an observable list. Each participant also exposes individual observables for granular updates.
+
+**Before (v3):**
+
+```js
+roomSession.on("member.joined", (member) => addParticipantToUI(member));
+roomSession.on("member.left", (member) => removeParticipantFromUI(member));
+roomSession.on("member.updated", handler);
+const members = roomSession.members; // flat objects with properties
+```
+
+**After (v4):**
+
+```js
+// Full list (re-emits on every change)
+call.participants$.subscribe((participants) => {
+ renderParticipantList(participants);
+});
+
+// Individual events
+call.memberJoined$.subscribe((event) => addParticipantToUI(event.member));
+call.memberLeft$.subscribe((event) => removeParticipantFromUI(event.member_id));
+
+// Per-participant observables for fine-grained UI updates:
+// participant.name$
+// participant.audioMuted$
+// participant.videoMuted$
+// participant.isTalking$
+// participant.handraised$
+// participant.deaf$
+// participant.visible$
+// participant.position$
+
+const participants = call.participants;
+```
+
+## Screen sharing
+
+Screen sharing moves from the room to `call.self`.
+
+```js
+await call.self.startScreenShare();
+await call.self.stopScreenShare();
+
+call.self$.subscribe((self) => {
+ if (self) {
+ self.screenShareStatus$.subscribe((status) => {
+ console.log("Screen share:", status);
+ });
+ }
+});
+```
+
+## Layouts
+
+```js
+// v3
+roomObj.getLayoutList();
+roomObj.setLayout({ name: layoutName });
+roomObj.on("layout.changed", (event) => console.log(event.layout));
+
+// v4
+call.layouts$.subscribe((layouts) => console.log("Available:", layouts));
+call.layout$.subscribe((layout) => console.log("Current:", layout));
+
+await call.setLayout("grid", {});
+
+await call.setLayout("highlight-1-active-4", {
+ "participant-id": "reserved-1",
+});
+```
+
+## Recording and streaming
+
+Recording and streaming APIs are **not yet implemented** in v4. The observables exist for monitoring server-initiated state, but `startRecording()` and `startStreaming()` will throw. Drive these from SWML or the server-side REST API in the meantime.
+
+```js
+// State observable (for server-initiated recordings)
+call.recording$.subscribe((isRecording) => updateRecordingIndicator(isRecording));
+
+const isRecording = call.recording;
+```
+
+## Device management
+
+The standalone `WebRTC` namespace and `roomObj.updateCamera()`-style methods are removed. Devices live on the client as reactive lists that auto-update when devices are plugged in or removed.
+
+> **Heads up:** v4 device selectors take the full `MediaDeviceInfo` object, not just a `deviceId` string.
+
+**Before (v3):**
+
+```js
+import { WebRTC } from "@signalwire/js";
+
+enumerateDevices();
+getCameraDevicesWithPermissions();
+createDeviceWatcher(); // for change detection
+
+await WebRTC.getCameras();
+await WebRTC.getMicrophones();
+await WebRTC.getSpeakers();
+await WebRTC.checkCameraPermissions();
+
+roomObj.updateMicrophone({ deviceId });
+roomObj.updateCamera({ deviceId });
+```
+
+**After (v4):**
+
+```js
+client.videoInputDevices$.subscribe((cameras) => populateCameraSelect(cameras));
+client.audioInputDevices$.subscribe((mics) => populateMicSelect(mics));
+client.audioOutputDevices$.subscribe((speakers) => populateSpeakerSelect(speakers));
+
+// Pass the full MediaDeviceInfo, not just deviceId
+call.self.selectVideoInputDevice(deviceInfo);
+call.self.selectAudioInputDevice(deviceInfo);
+call.self.selectAudioOutputDevice(deviceInfo);
+
+// Sync access
+const cameras = client.videoInputDevices;
+```
+
+## User info
+
+`Subscriber` is renamed to `User`.
+
+**Before (v3):**
+
+```js
+const info = await client.getSubscriberInfo();
+console.log("Logged in as:", info.name);
+```
+
+**After (v4):**
+
+```js
+const user = client.user;
+
+user.fetched$.subscribe((fetched) => {
+ if (fetched) {
+ console.log("User ID:", user.id);
+ console.log("Display name:", user.displayName);
+ }
+});
+```
+
+## Directory
+
+v3's paginated `client.address.getAddresses()` is replaced by a reactive directory that accumulates entries on `loadMore()`.
+
+**Before (v3):**
+
+```js
+const data = await client.address.getAddresses({
+ type,
+ displayName,
+ pageSize: 10,
+});
+// data.data, data.hasNext, data.hasPrev, data.nextPage(), data.prevPage()
+```
+
+**After (v4):**
+
+```js
+const directory = client.directory;
+
+directory.addresses$.subscribe((addresses) => {
+ // Reactive list — accumulates as loadMore() is called
+ addresses.forEach((addr) => console.log(addr.displayName, addr.type));
+});
+
+directory.hasMore$.subscribe((hasMore) => toggleLoadMoreButton(hasMore));
+directory.loading$.subscribe((loading) => showSpinner(loading));
+
+directory.loadMore(); // fetches and appends the next page
+```
+
+You can dial an address directly:
+
+```js
+const address = client.directory.addresses.find((a) => a.name === "user1");
+const call = await client.dial(address.defaultChannel, { video: true, audio: true });
+
+// URI strings still work
+const call2 = await client.dial("/private/user1");
+```
+
+## Messaging
+
+v3's `client.conversation` API is replaced by per-address messaging on the call.
+
+**Before (v3):**
+
+```js
+client.conversation.sendMessage({ addressId, text });
+client.conversation.subscribe((newMsg) => { /* ... */ });
+client.conversation.getConversationMessages({ addressId, pageSize });
+```
+
+**After (v4):**
+
+```js
+callAddress.sendText(text); // scoped to the call's address
+
+callAddress.textMessages$.subscribe((textMessagesCollection) => {
+ textMessagesCollection.values$.subscribe((messages) => renderMessages(messages));
+ textMessagesCollection.hasMore$.subscribe((hasMore) => {});
+ textMessagesCollection.loadMore();
+});
+```
+
+Messages are scoped to the current call's address — there is no global conversation client in v4.
+
+## Removed namespaces
+
+The standalone `Chat`, `PubSub`, and `WebRTC` clients from v3 are removed. Device APIs move onto the client (see [Device management](#device-management)). Chat/PubSub equivalents are not part of the v4 browser SDK.
+
+## Event-to-observable reference
+
+> When using RxJS operators like `filter`, `map`, or `pipe`, import them from `rxjs`:
+>
+> ```js
+> import { filter, map } from "rxjs";
+> ```
+>
+> See the [RxJS primer](/docs/browser-sdk/v4/guides/rxjs-primer) for a quick orientation.
+
+| v3 Event | v4 Observable |
+| ------------------------- | ------------------------------------------------------ |
+| `member.joined` | `call.memberJoined$` |
+| `member.left` | `call.memberLeft$` |
+| `member.updated` | `call.memberUpdated$` |
+| `member.talking` | `call.memberTalking$` |
+| `layout.changed` | `call.layout$`, `call.layoutLayers$` |
+| `recording.started/ended` | `call.recording$` (state observable) |
+| `playback.started/ended` | Not available in the browser SDK (server-side only) |
+| `room.updated` | `call.meta$`, `call.locked$` |
+| `room.joined` | `call.status$.pipe(filter(s => s === 'connected'))` |
+| `room.left` | `call.status$.pipe(filter(s => s === 'disconnected'))` |
+
+## API quick reference
+
+| v3 | v4 |
+| ---------------------------------- | ------------------------------------------------------------- |
+| `SignalWire({ token })` | `new SignalWire(credentialProvider)` |
+| Ready callback | `client.ready$` (emits `true` when connected + authenticated) |
+| `client.dial({ to, rootElement })` | `client.dial(destination, options)` |
+| `client.online({ handlers })` | `client.register()` + `client.session.incomingCalls$` |
+| `invite.accept()` (async) | `call.answer()` (sync) |
+| `invite.reject()` (async) | `call.reject()` (sync) |
+| `roomSession.audioMute()` | `call.self.mute()` |
+| `roomSession.deaf()` | `call.self.toggleDeaf()` |
+| `roomSession.setMicrophoneVolume({ volume })` | `call.self.setAudioInputVolume(value)` |
+| `roomSession.setLayout(name)` | `call.setLayout(name, positions)` |
+| `roomSession.getLayoutList()` | `call.layouts$` |
+| `roomSession.members` | `call.participants` / `call.participants$` |
+| `roomSession.on('event', fn)` | `call.eventName$.subscribe(fn)` |
+| `client.updateToken(token)` | Handled by credential provider's `refresh()` |
+| `client.address.getAddresses()` | `client.directory.addresses$` + `directory.loadMore()` |
+| `client.conversation.sendMessage()`| `callAddress.sendText()` |
+| `roomSession.leave()` | `call.hangup()` |
+| Disconnect | `client.disconnect()` + `client.destroy()` |
+
+## Cleanup
+
+v4 requires explicit cleanup. End calls with `hangup()`, then disconnect and destroy the client to release all subscriptions.
+
+```js
+await call.hangup();
+
+await client.disconnect(); // closes the WebSocket
+client.destroy(); // releases subscriptions and resources
+
+// Manual subscription cleanup, if needed
+const sub = call.status$.subscribe((status) => console.log(status));
+sub.unsubscribe();
+```
+
+## Web components
+
+v4 ships `@signalwire/web-components`, composable around the new reactive Call API. `` is the root container — nest media, controls, and status components inside, then assign the call.
+
+```html
+
+
+
+
+
+
+
+```
+
+```js
+const call = await client.dial("/public/room");
+
+const callMedia = document.getElementById("call-media");
+callMedia.call = call;
+// Child components receive the call automatically via Lit context
+```
+
+`` renders participant overlays driven by the same context.
+
+## Common migration issues
+
+1. **No video displays.** v4 does not auto-attach to the DOM. Subscribe to `remoteStream$` (and `localStream$`) and assign the stream to a ``'s `srcObject` — or use `` / ``.
+2. **`call.self` is null.** `self` is populated only after joining. Use `call.self$` for reactive access, or optional chaining (`call.self?.audioMuted`) for sync reads.
+3. **Events seem to be missing.** Subscribe to observables before the events fire — and avoid unsubscribing prematurely. `participants$` re-emits the full list on any change, so wire it up early in your component lifecycle.
+4. **`startRecording()` throws.** Recording is not yet implemented in v4. Trigger recording server-side via SWML or the REST API; use `call.recording$` to reflect state in the UI.
+5. **Device selection has no effect.** v4 expects a full `MediaDeviceInfo` object, not a bare `deviceId` string.
+6. **Token expired errors after a while.** v3's `client.updateToken()` is gone. Implement `refresh()` on your credential provider and return `{ token, expiry_at }` — the SDK will refresh on schedule.
diff --git a/fern/products/browser-sdk/pages/v4/guides/getting-started/overview.mdx b/fern/products/browser-sdk/pages/v4/guides/getting-started/overview.mdx
new file mode 100644
index 000000000..046282d96
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/getting-started/overview.mdx
@@ -0,0 +1,188 @@
+---
+title: "Overview"
+slug: /guides/overview
+sidebar-title: "Overview"
+position: 1
+max-toc-depth: 3
+---
+
+The SignalWire Browser SDK puts voice, video, and chat in a browser
+without plugins, downloads, or a media server you have to run. It also integrates with
+powerful AI agents, [SWML](/docs/swml), and all telephony and communication services SignalWire provides.
+
+How would you like to get started?
+
+
+
+ Drive everything yourself with `@signalwire/js` — `client.dial()`,
+ observables, your own UI. Best when you want full control over how
+ the call looks and behaves.
+
+
+ Add `` or `` to a page and you're
+ done — a styled, working call UI with one element. Best for
+ marketing sites, click-to-call buttons, and quick integrations.
+
+
+
+## Prerequisites
+
+If you have Node + npm (or you can drop a `
+```
+
+## Your first call
+
+```html
+
+
+Hang Up
+```
+
+```js
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(
+ new StaticCredentialProvider({ token: "YOUR_SUBSCRIBER_ACCESS_TOKEN" })
+);
+
+let activeCall;
+
+client.ready$.subscribe(async (ready) => {
+ if (!ready) return;
+
+ activeCall = await client.dial("/public/test-room", {
+ audio: true,
+ video: true,
+ });
+
+ activeCall.localStream$.subscribe((s) => {
+ document.getElementById("localVideo").srcObject = s;
+ });
+ activeCall.remoteStream$.subscribe((s) => {
+ document.getElementById("remoteVideo").srcObject = s;
+ });
+ activeCall.status$.subscribe((status) => console.log("status:", status));
+});
+
+document.getElementById("hangup").onclick = () => activeCall?.hangup();
+```
+
+**If it worked**, you'll see your own camera in `localVideo` and a
+black frame in `remoteVideo` — `/public/test-room` is empty until
+someone else joins. Open the same page in a second tab to see the
+remote stream light up. The browser console should log
+`status: connected` once media is flowing.
+
+If your camera light isn't on, check the [Troubleshooting](/docs/browser-sdk/v4/guides/troubleshooting)
+guide — usually permissions, HTTPS, or a denied microphone prompt.
+
+## Trying it without a backend
+
+If you don't want to mint SATs yet — or you're prototyping from a
+static HTML file — use an **embed token** (`c2c_…` / `c2t_…`) from
+the Dashboard's *Embeds* section with the one-call helper:
+
+```js
+import { embeddableCall } from "@signalwire/js";
+
+const call = await embeddableCall({
+ host: "yourspace.signalwire.com",
+ embedToken: "YOUR_EMBED_TOKEN",
+ to: "/public/test-room",
+});
+```
+
+`embeddableCall` builds the client, connects, and dials in a single
+call. Embed tokens are safe to expose in client code (they're scoped
+to a single destination) — see
+[Authentication](/docs/browser-sdk/v4/guides/authentication) for the full token
+model.
+
+## Receiving inbound calls
+
+To accept incoming calls, register the client and watch the inbound
+list:
+
+```js
+await client.register();
+
+client.session.incomingCalls$.subscribe((calls) => {
+ const ringing = calls.find((c) => c.status === "ringing");
+ if (!ringing) return;
+
+ document.getElementById("accept").onclick = () => {
+ ringing.answer({ audio: true, video: true });
+ // Now wire `ringing` to your UI — status$, remoteStream$, etc.
+ // See the Inbound Calls guide for the full pattern.
+ };
+ document.getElementById("reject").onclick = () => ringing.reject();
+});
+```
+
+The full accept-and-wire pattern lives in
+[Inbound Calls](/docs/browser-sdk/v4/guides/inbound-calls).
+
+## Where to go from here
+
+Pick the next page by intent:
+
+| You want to… | Read |
+| --------------------------------------- | ---------------------------------------------------------------------- |
+| Build out the call UI (mute, layout, share screen) | [Build Voice & Video apps](/docs/browser-sdk/v4/guides/build-voice-video) |
+| Drop in a pre-built widget instead | [Web Components](/docs/browser-sdk/v4/guides/web-components) |
+| Understand the observable patterns | [RxJS Primer](/docs/browser-sdk/v4/guides/rxjs-primer) |
+| Mint tokens correctly from your backend | [Authentication](/docs/browser-sdk/v4/guides/authentication) |
+| Ship this to production | [Deploy](/docs/browser-sdk/v4/guides/deploy) |
+| See a complete reference app | `playground/kitchen-sink-demo` in [signalwire-typescript-web](https://github.com/signalwire/signalwire-typescript-web) |
+
+## Reference
+
+- [`SignalWire`] — top-level client
+- [`StaticCredentialProvider`], [`EmbedTokenCredentialProvider`] — credential providers
+- [`SignalWire.dial()`] — place an outbound call
+- [`SignalWire.register()`] / [`session.incomingCalls$`][`SessionState`] — receive calls
+- [`embeddableCall()`] — one-call helper for embed tokens
+
+[`SignalWire`]: /docs/browser-sdk/v4/reference/signalwire
+[`StaticCredentialProvider`]: /docs/browser-sdk/v4/reference/credential-providers/static-credential-provider
+[`EmbedTokenCredentialProvider`]: /docs/browser-sdk/v4/reference/credential-providers/embed-token-credential-provider
+[`SignalWire.dial()`]: /docs/browser-sdk/v4/reference/signalwire/dial
+[`SignalWire.register()`]: /docs/browser-sdk/v4/reference/signalwire/register
+[`SessionState`]: /docs/browser-sdk/v4/reference/interfaces/session-state
+[`embeddableCall()`]: /docs/browser-sdk/v4/reference/functions/embeddable-call
diff --git a/fern/products/browser-sdk/pages/v4/guides/getting-started/rxjs-primer.mdx b/fern/products/browser-sdk/pages/v4/guides/getting-started/rxjs-primer.mdx
new file mode 100644
index 000000000..b679da4b5
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/getting-started/rxjs-primer.mdx
@@ -0,0 +1,138 @@
+---
+title: "RxJS Primer"
+slug: /guides/rxjs-primer
+sidebar-title: "RxJS Primer"
+position: 3
+max-toc-depth: 3
+---
+
+The Browser SDK is highly asynchronous. Devices come and go, participants join and leave, capabilities shift mid-call, call status walks through `new` → `trying` → `ringing` → `connected` → `disconnected`. Rather than firing one-shot events that are hard to manage, the SDK exposes most of this state as **RxJS Observables** — streams you can subscribe to that immediately push the current value plus every future change.
+
+This page will briefly introduce RxJS, but we recommend taking the time to understand it better from their official docs to be productive with the Browser SDK.
+
+## What you need to know
+
+An **Observable** is a value-over-time. It doesn't do anything until you call `.subscribe()` on it. When you do, you get back a **Subscription** — call `.unsubscribe()` to stop listening. Forgetting to do that is the #1 way to leak memory in this SDK.
+
+Most observables you'll touch here behave like a `BehaviorSubject` under the hood: the moment you subscribe, you get the current value synchronously, and then every change after that. So you don't have to "wait for an event" — just subscribe and you're caught up.
+
+To transform a stream, you `.pipe()` it through operators. `filter`, `map`, `take`, `combineLatest`, `switchMap`, `debounceTime` — the usual suspects.
+
+## The `$` suffix
+
+Anywhere you see a property ending in `$`, that's the observable. The same name without the `$` is the current snapshot:
+
+```js
+const status = call.status; // string — what it is right now
+call.status$.subscribe(handler); // stream — current value + every change
+```
+
+Reach for the snapshot when you just need to peek. Reach for the observable when you need to react to changes.
+
+## Subscribing
+
+```js
+const sub = client.audioInputDevices$.subscribe((devices) => {
+ console.log("mics:", devices);
+});
+
+// when you're done
+sub.unsubscribe();
+```
+
+That fires once immediately with the current device list, then again every time the OS reports a change.
+
+## Patterns you'll use a lot
+
+**Wait for something to become true, then move on.** `filter` keeps emissions you care about; `take(1)` ends the subscription after the first one — no manual unsubscribe needed.
+
+```js
+import { filter, take } from "rxjs";
+
+client.ready$
+ .pipe(filter(Boolean), take(1))
+ .subscribe(async () => {
+ const call = await client.dial(destination);
+ });
+```
+
+**React when a value matches.** Same idea, but you keep listening:
+
+```js
+import { filter } from "rxjs";
+
+call.status$.pipe(filter((s) => s === "connected")).subscribe(showCallControls);
+```
+
+**Combine streams.** When you need the latest of several things together:
+
+```js
+import { combineLatest } from "rxjs";
+
+combineLatest([client.audioInputDevices$, client.selectedAudioInputDevice$])
+ .subscribe(([devices, selected]) => {
+ const activeMic = devices.find((d) => d.deviceId === selected?.deviceId);
+ });
+```
+
+**Ignore the initial value.** Useful when you only care about *changes*, not what's true right now:
+
+```js
+import { skip } from "rxjs";
+
+call.status$.pipe(skip(1)).subscribe(showStatusNotification);
+```
+
+**Smooth out chatty streams.** `inputVolume$` fires constantly — `debounceTime` waits for things to settle:
+
+```js
+import { debounceTime } from "rxjs";
+
+participant.inputVolume$.pipe(debounceTime(100)).subscribe(updateVolumeIndicator);
+```
+
+## Cleaning up
+
+Every subscription you open needs to be closed when you're done with it, or it'll keep firing into a stale handler. Two patterns to pick from:
+
+**Collect subscriptions, tear them all down at once:**
+
+```js
+class CallManager {
+ subs = [];
+ start(call) {
+ this.subs.push(
+ call.status$.subscribe(this.onStatus),
+ call.participants$.subscribe(this.onParticipants),
+ );
+ }
+ stop() {
+ this.subs.forEach((s) => s.unsubscribe());
+ this.subs = [];
+ }
+}
+```
+
+**Or fire one signal that completes everything** via `takeUntil`:
+
+```js
+import { Subject, takeUntil } from "rxjs";
+
+const destroy$ = new Subject();
+
+call.status$.pipe(takeUntil(destroy$)).subscribe(updateStatus);
+call.participants$.pipe(takeUntil(destroy$)).subscribe(updateParticipants);
+
+// later
+destroy$.next();
+destroy$.complete();
+```
+
+Both are fine. `takeUntil` scales better when you have lots of pipelines; the array is more obvious for a handful.
+
+## Going further
+
+- [RxJS docs](https://rxjs.dev/) — the canonical reference
+- [Observables guide](https://rxjs.dev/guide/observable)
+- [Operators](https://rxjs.dev/guide/operators) — the full menu
+- [Subjects](https://rxjs.dev/guide/subject) — including `BehaviorSubject`, which is what most SDK observables are under the hood
diff --git a/fern/products/browser-sdk/pages/v4/guides/manage-resources/address-book.mdx b/fern/products/browser-sdk/pages/v4/guides/manage-resources/address-book.mdx
new file mode 100644
index 000000000..036d8abc6
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/manage-resources/address-book.mdx
@@ -0,0 +1,236 @@
+---
+title: "Address Book & Directory"
+slug: /guides/address-book
+sidebar-title: "Address Book"
+position: 3
+max-toc-depth: 3
+---
+
+`client.directory` is the runtime view of every
+[Address](/docs/platform/addresses) the authenticated user can reach
+— other Subscribers, video rooms, AI agents, SWML scripts, anything
+the platform has surfaced into this user's scope. Each entry is an
+[`Address`] instance that you can read identity from, dial, message,
+and inspect for call history.
+
+The directory is paginated, observable, and lazily loaded: subscribe
+to `addresses$` and pages stream in as you call `loadMore()`.
+
+## Getting the directory
+
+```js
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(
+ new StaticCredentialProvider({ token: "YOUR_SAT" })
+);
+
+client.directory$.subscribe((directory) => {
+ if (!directory) return; // not yet connected
+
+ directory.addresses$.subscribe((addresses) => {
+ renderList(addresses);
+ });
+});
+```
+
+`client.directory$` emits once the client is connected; subscribe to
+it instead of reading `client.directory` synchronously to avoid the
+"not yet authenticated" race. The directory itself outlives any
+single `addresses$` subscription — it's the manager that owns the
+state.
+
+## Paging
+
+The first page loads automatically when the directory becomes
+available. Pull the next page with `loadMore()`:
+
+```js
+const directory = await firstValueFrom(client.directory$.pipe(filterNull()));
+
+// Subscribe to the full, growing list
+directory.addresses$.subscribe((addresses) => {
+ console.log(`now have ${addresses.length} addresses`);
+});
+
+// Track whether more pages exist
+directory.hasMore$.subscribe((hasMore) => {
+ loadMoreButton.disabled = !hasMore;
+});
+
+// Track loading state to disable the button mid-fetch
+directory.loading$.subscribe((loading) => {
+ spinner.hidden = !loading;
+});
+
+loadMoreButton.onclick = () => directory.loadMore();
+```
+
+The collection is reactive — when a server-side update lands (e.g. a
+new contact added in the background), new entries appear in the
+existing `addresses$` stream without you re-fetching.
+
+## What an [`Address`] gives you
+
+Identity (name, displayName, type, resourceId), visuals (preview /
+cover URLs), communication channels (audio / video / messaging URIs),
+room state, and the conversation handle (`sendText`, `textMessages$`,
+`history$`). Like everything in the SDK, mutable state is exposed
+twice — as a synchronous getter and as a `$` observable. The full
+shape is on the [`Address`] reference page; this guide covers the
+fields you'll actually drive UI off of.
+
+### Resource type
+
+`Address.type` tells you what kind of Resource is on the other end:
+
+| `type` | What it is |
+| -------------- | ------------------------------------------------------------------------- |
+| `'subscriber'` | Another user. Direct peer-to-peer. |
+| `'room'` | A video room. Multi-party. |
+| `'app'` | A SWML script or AI agent. |
+| `'call'` | A platform call resource (gateway, queue, etc.). |
+
+Use it to drive UI affordances — show a video icon for rooms, a phone
+icon for subscribers, an avatar for AI agents:
+
+```js
+function iconFor(address) {
+ switch (address.type) {
+ case "room": return "video";
+ case "subscriber": return "user";
+ case "app": return "robot";
+ case "call": return "phone";
+ }
+}
+```
+
+### Channels
+
+`address.channels` reports which communication modes the resource
+supports. A video room exposes `{ audio, video, messaging }`; a phone
+address might be `{ audio }` only. The `defaultChannel` getter picks
+the right one for a one-click dial (video for rooms, audio
+otherwise).
+
+```js
+const call = await client.dial(address.defaultChannel ?? address.name, {
+ audio: true,
+ video: address.type === "room",
+});
+```
+
+## Looking up an address by URI
+
+When you know the URI (`/public/support`, `/private/jane`) and need
+the [`Address`] instance — to inspect channels, send a message, or
+hand to `client.dial()` — use `findAddressIdByURI`:
+
+```js
+const id = await directory.findAddressIdByURI("/public/support");
+if (id) {
+ const address = directory.get(id);
+ // address is now usable
+}
+```
+
+`findAddressIdByURI` checks the local cache first, then queries the
+server. `directory.get(id)` is a pure local lookup — call it only
+after the id is known to exist.
+
+For the reactive equivalent, `directory.get$(id)` returns an
+`Observable` that emits whenever the entry's state changes.
+
+## Dialing
+
+`client.dial()` accepts either the URI directly or an [`Address`]
+instance:
+
+```js
+// by URI
+await client.dial("/public/support", { audio: true });
+
+// by Address — equivalent
+const address = directory.get(addressId);
+await client.dial(address, { audio: true });
+```
+
+Passing the [`Address`] lets the SDK pick the right channel
+automatically when one isn't pinned in the URI.
+
+## Messaging and call history
+
+Each address owns its own conversation: `address.sendText()`,
+`address.textMessages$`, and `address.history$`. See
+[Messaging & Chat](/docs/browser-sdk/v4/guides/messaging-chat) for
+the patterns — same pagination shape as the directory, lazy-loaded
+on first subscribe.
+
+## Room state
+
+For room-type addresses, `locked$` reports whether the room is
+currently accepting new joins. Lock state changes mid-call propagate
+through the same observable:
+
+```js
+address.locked$.subscribe((locked) => {
+ joinButton.disabled = locked;
+ joinButton.textContent = locked ? "Room locked" : "Join";
+});
+```
+
+`previewUrl$` and `coverUrl$` carry the room's thumbnail and banner
+images when the platform has them.
+
+## A complete directory UI
+
+```js
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+import { filter, firstValueFrom } from "rxjs";
+
+const client = new SignalWire(
+ new StaticCredentialProvider({ token: "YOUR_SAT" })
+);
+
+const directory = await firstValueFrom(
+ client.directory$.pipe(filter((d) => !!d))
+);
+
+directory.addresses$.subscribe(renderList);
+directory.hasMore$.subscribe((more) => (loadMoreBtn.hidden = !more));
+loadMoreBtn.onclick = () => directory.loadMore();
+
+function renderList(addresses) {
+ list.innerHTML = "";
+ for (const address of addresses) {
+ const li = document.createElement("li");
+ li.textContent = `${address.displayName} (${address.name})`;
+ li.onclick = () =>
+ client.dial(address, {
+ audio: true,
+ video: address.type === "room",
+ });
+ list.appendChild(li);
+ }
+}
+```
+
+This is the same shape `` builds on top of in the web
+components — see the
+[Web Components reference](/docs/browser-sdk/v4/reference/web-component/sw-directory) if
+you'd rather drop in a pre-styled list.
+
+## Reference
+
+- [`SignalWire.directory$`] / [`directory`] — the directory manager
+- [`Directory`] interface — `addresses$`, `loadMore()`, `hasMore$`, `loading$`, `get()`, `get$()`, `findAddressIdByURI()`
+- [`Address`] — the per-entry class (name, displayName, type, channels, sendText, textMessages$, history$, locked$, previewUrl$, coverUrl$)
+- [`SignalWire.dial()`] — accepts an [`Address`] or URI
+- [`ResourceType`] — `'app' | 'call' | 'room' | 'subscriber'`
+
+[`SignalWire.directory$`]: /docs/browser-sdk/v4/reference/signalwire/directory$
+[`directory`]: /docs/browser-sdk/v4/reference/signalwire/directory$
+[`Directory`]: /docs/browser-sdk/v4/reference/interfaces/directory
+[`Address`]: /docs/browser-sdk/v4/reference/address
+[`SignalWire.dial()`]: /docs/browser-sdk/v4/reference/signalwire/dial
+[`ResourceType`]: /docs/browser-sdk/v4/reference/type-aliases/resource-type
diff --git a/fern/products/browser-sdk/pages/v4/guides/manage-resources/capabilities.mdx b/fern/products/browser-sdk/pages/v4/guides/manage-resources/capabilities.mdx
new file mode 100644
index 000000000..7dc9a64a9
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/manage-resources/capabilities.mdx
@@ -0,0 +1,179 @@
+---
+title: "Capabilities"
+slug: /guides/capabilities
+sidebar-title: "Capabilities"
+position: 5
+max-toc-depth: 3
+---
+
+Capabilities are the permissions the server granted *this* participant
+in *this* call. Different across rooms, roles, and token scopes — and
+they're the single source of truth for *what your UI should let the
+user do*. The SDK surfaces them as `call.self.capabilities`, a
+[`SelfCapabilities`] instance with both synchronous getters and
+observable streams.
+
+Use capability flags to decide which UI affordances to render. Don't
+guess from token type or hard-code "agents can lock rooms" — the
+server already knows, and the capability stream reflects what it
+decided for this specific call.
+
+## When capabilities are populated
+
+Once a call reaches `joined`, the server sends a `call.joined` event
+carrying the participant's capability flags. The SDK decodes them
+into a structured [`SelfCapabilities`] object on `call.self`:
+
+```js
+const call = await client.dial("/private/team", { audio: true, video: true });
+
+call.self$.subscribe((self) => {
+ if (!self) return;
+
+ // Synchronous read — current state at this moment
+ console.log(self.capabilities.end); // can end the call?
+ console.log(self.capabilities.self.muteAudio.on); // can mute my own audio?
+ console.log(self.capabilities.member.remove); // can remove others?
+});
+```
+
+[`SelfCapabilities`] exposes both synchronous getters (`end`,
+`screenshare`, `setLayout`, …) and `$`-suffixed observables that
+re-emit when capabilities change. State updates are *full
+replacements* — a new `call.joined` swaps the entire state object,
+not a partial merge.
+
+### Mid-call changes
+
+Capabilities only re-emit when the server sends a fresh `call.joined`
+event. If your application supports role promotions (guest → host,
+attendee → moderator) and you need the UI to react, the server has
+to re-emit `call.joined` for that participant. The SDK supports
+nested `call.joined` events and will update the capability state
+when one arrives — but it won't synthesize updates on its own.
+
+## The shape of [`SelfCapabilities`]
+
+[`SelfCapabilities`] groups flags into three families. Each capability
+is exposed in two forms — an observable (e.g. [`end$`]) for reactive
+bindings and a synchronous getter (e.g. `end`) for snapshot reads.
+
+- **Member-level (self):** what *this* participant can do to themselves
+ — mute audio/video, deaf, raise hand, microphone volume / sensitivity,
+ speaker volume. Access via [`self$`] / [`self`].
+- **Member-level (others):** the same shape, but for moderation against
+ other members — plus `remove` (kick), `position` (move them in the
+ layout), and `meta`. Access via [`member$`] / [`member`].
+- **Call-level:** [`end$`], [`setLayout$`], [`sendDigit$`],
+ [`screenshare$`], [`device$`], plus on/off-split [`lock$`] and
+ [`vmutedHide$`].
+
+For the full member shape, see [`MemberCapabilities`] in the reference.
+Each member flag is either a boolean (the action is allowed or not) or
+an [`OnOffCapability`] — which separates "can turn this on" from "can
+turn this off" because some roles can do one but not both (e.g. a
+moderator who can lock a room while only the host can unlock it).
+
+## Driving UI from capabilities
+
+The pattern: subscribe once to the observable you care about, toggle
+the affordance, let the stream update it forever.
+
+```js
+const self = call.self; // SelfParticipant
+
+self.capabilities.end$.subscribe((canEnd) => {
+ endCallButton.hidden = !canEnd;
+});
+
+self.capabilities.screenshare$.subscribe((canShare) => {
+ shareScreenButton.disabled = !canShare;
+});
+
+self.capabilities.setLayout$.subscribe((canLayout) => {
+ layoutMenu.hidden = !canLayout;
+});
+
+// Self-mute flags split on/off
+self.capabilities.self$.subscribe((self) => {
+ muteAudioButton.disabled = !self.muteAudio.on && !self.muteAudio.off;
+});
+
+// Moderation actions on other members
+self.capabilities.member$.subscribe((member) => {
+ kickButton.hidden = !member.remove;
+ moveButton.hidden = !member.position;
+});
+```
+
+If you're using the [web components](/docs/browser-sdk/v4/guides/web-components),
+`` already does this internally — buttons hide
+themselves when the corresponding capability isn't granted. You only
+need the manual wiring when building a custom UI.
+
+## Reading the full state
+
+`state$` emits the entire [`CallCapabilitiesState`] on every change.
+Useful if you serialize the capability set into your own store:
+
+```js
+self.capabilities.state$.subscribe((state) => {
+ uiStore.setCapabilities(state);
+});
+```
+
+## Why not just gate on token type?
+
+It's tempting to skip the capability stream and say "guests can't end
+calls" in the UI. Two reasons not to:
+
+1. **The same token can have different capabilities in different
+ rooms.** Rooms can override permissions per-resource. The
+ capability stream reflects the resolved permission for *this*
+ call.
+2. **Capabilities can be re-evaluated mid-call.** When the server
+ re-emits `call.joined` after a permission change, the capability
+ stream reflects the new state. Token-based gating would be stuck
+ on the value the token was minted with.
+
+The local capability stream and the server are always in sync because
+they come from the same `call.joined` event. Trust it.
+
+## Server-side enforcement
+
+Capabilities you see locally are exactly what the platform enforces
+— calling `participant.remove()` without the `member.remove`
+capability will fail server-side. The local checks are *UX*, not
+security: the server is the authority, the flags exist so your UI
+doesn't show buttons that would error out.
+
+## Presence
+
+Per-user presence isn't currently exposed through the SDK. Derive
+online state from your own application telemetry — a last-seen
+heartbeat from your backend, or "currently in a call" tracked from
+your own call lifecycle webhooks.
+
+## Reference
+
+- [`SelfCapabilities`] — the class
+- [`self$`] / [`self`] — self capabilities
+- [`member$`] / [`member`] — other-member capabilities
+- [`end$`], [`setLayout$`], [`sendDigit$`], [`screenshare$`], [`device$`], [`lock$`], [`vmutedHide$`] — call-level capabilities
+- [`MemberCapabilities`], [`OnOffCapability`], [`CallCapabilitiesState`] — the data shapes
+
+[`SelfCapabilities`]: /docs/browser-sdk/v4/reference/self-capabilities
+[`self$`]: /docs/browser-sdk/v4/reference/self-capabilities/self$
+[`self`]: /docs/browser-sdk/v4/reference/self-capabilities/self$
+[`member$`]: /docs/browser-sdk/v4/reference/self-capabilities/member$
+[`member`]: /docs/browser-sdk/v4/reference/self-capabilities/member$
+[`end$`]: /docs/browser-sdk/v4/reference/self-capabilities/end$
+[`setLayout$`]: /docs/browser-sdk/v4/reference/self-capabilities/set-layout$
+[`sendDigit$`]: /docs/browser-sdk/v4/reference/self-capabilities/send-digit$
+[`screenshare$`]: /docs/browser-sdk/v4/reference/self-capabilities/screenshare$
+[`device$`]: /docs/browser-sdk/v4/reference/self-capabilities/device$
+[`lock$`]: /docs/browser-sdk/v4/reference/self-capabilities/lock$
+[`vmutedHide$`]: /docs/browser-sdk/v4/reference/self-capabilities/vmuted-hide$
+[`MemberCapabilities`]: /docs/browser-sdk/v4/reference/interfaces/member-capabilities
+[`OnOffCapability`]: /docs/browser-sdk/v4/reference/interfaces/on-off-capability
+[`CallCapabilitiesState`]: /docs/browser-sdk/v4/reference/interfaces/call-capabilities-state
diff --git a/fern/products/browser-sdk/pages/v4/guides/manage-resources/client-preferences.mdx b/fern/products/browser-sdk/pages/v4/guides/manage-resources/client-preferences.mdx
new file mode 100644
index 000000000..3a3718d69
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/manage-resources/client-preferences.mdx
@@ -0,0 +1,132 @@
+---
+title: "Client Preferences"
+slug: /guides/client-preferences
+sidebar-title: "Client Preferences"
+position: 4
+max-toc-depth: 3
+---
+
+`client.preferences` is the bag of **per-client defaults** the SDK
+reads when no per-call options override them: which mic / camera to
+use, whether to receive video by default, ICE / recovery tuning,
+codec ordering, custom `userVariables` to attach to every call.
+Preferences live in the browser, optionally persist to local storage,
+and are distinct from per-Subscriber configuration (which lives on
+the platform — see [Subscribers](/docs/browser-sdk/v4/guides/subscribers)).
+
+This page covers *how* preferences fit into the SDK lifecycle. The
+full list of properties is on [`ClientPreferences`] — it's a plain
+object, so getting / setting is uninteresting.
+
+## Defaults vs. per-call overrides
+
+The mental model:
+
+```
+client.preferences ← defaults
+ ↓
+client.dial(dest, options) ← per-call overrides win
+```
+
+Anything you set on `preferences` applies to every subsequent
+[`dial()`][`SignalWire.dial()`] that doesn't pass a competing
+field. Per-call options always win:
+
+```js
+client.preferences.receiveVideo = false; // audio-first default
+
+// This one call gets video regardless:
+await client.dial("/private/team", { video: true });
+```
+
+Set on `preferences` when "every call in my app should behave this
+way" (e.g. default codec ordering, a tier-wide `userVariables`
+payload). Set per-call when it's situational.
+
+## Persistence
+
+By default, preferences live in memory only. Set
+`savePreferences: true` when constructing the client and the SDK
+hydrates from `localStorage` on startup and writes back on every
+setter:
+
+```js
+const client = new SignalWire(provider, { savePreferences: true });
+```
+
+What's persisted: timeouts, ICE settings, codec preferences,
+device-management flags, and `userVariables` — anything cleanly
+JSON-serializable. What isn't: `MediaDeviceInfo` references (device
+IDs aren't stable across sessions on every browser, so the SDK never
+persists them — picks happen at runtime via the device controller).
+
+If you want a different storage backend (IndexedDB, server-side per
+user), leave `savePreferences` off and mirror manually:
+
+```ts
+function setReceiveVideo(value: boolean) {
+ client.preferences.receiveVideo = value;
+ myStore.set("receiveVideo", value);
+}
+```
+
+There's no `update$` observable on `ClientPreferences` — it's a
+synchronous bag, not a reactive store. Most apps don't need a
+reactive bridge; preferences get read at dial time and rarely
+mutate interactively.
+
+## `userVariables`: per-call context
+
+`userVariables` is the only preference that's not really about SDK
+behavior — it's a free-form payload attached to every outbound
+Verto invite. The receiving side (an AI agent, a SWML script, your
+own backend) reads it via `signalwire-address:event` events. Use it
+to forward per-user / per-session context to the call route:
+
+```js
+client.preferences.userVariables = {
+ plan: user.plan,
+ locale: navigator.language,
+};
+```
+
+Set on preferences for app-wide values; pass to `dial()` for
+per-call attribution.
+
+## Time units
+
+All timeouts on the preferences surface are exposed in **seconds**,
+though they're stored as milliseconds internally. So:
+
+```js
+client.preferences.connectionTimeout = 30; // 30 seconds
+client.preferences.iceRestartTimeout = 10; // 10 seconds
+```
+
+This is the one quirk worth knowing — every other field uses
+whatever unit the underlying API uses (kbps, integer levels, etc.).
+
+## ICE / recovery / visibility
+
+There are knobs for the resilience pipeline (UDP/TCP TURN, relay
+fallback, ICE restart timeouts, network-change detection) and for
+page-visibility behavior (auto-mute video on hidden tab, refresh
+device list on visible). The defaults are tuned for real-world
+networks — **don't tighten them without a specific symptom in mind.**
+If you're chasing a reliability issue, see
+[Troubleshooting](/docs/browser-sdk/v4/guides/troubleshooting) before
+touching these.
+
+The full set is on [`ClientPreferences`].
+
+## Reference
+
+- [`ClientPreferences`] — the property surface
+- [`SignalWire.preferences`] — the instance
+- [`SignalWireOptions`] — `savePreferences`, `skipDeviceMonitoring`, `reconnectAttachedCalls`, `persistSession`
+- [`SignalWire.dial()`] — per-call overrides
+
+[`ClientPreferences`]: /docs/browser-sdk/v4/reference/client-preferences
+[`SignalWire.preferences`]: /docs/browser-sdk/v4/reference/signalwire
+[`SignalWireOptions`]: /docs/browser-sdk/v4/reference/interfaces/signal-wire-options
+[`SignalWire.dial()`]: /docs/browser-sdk/v4/reference/signalwire/dial
diff --git a/fern/products/browser-sdk/pages/v4/guides/manage-resources/overview.mdx b/fern/products/browser-sdk/pages/v4/guides/manage-resources/overview.mdx
new file mode 100644
index 000000000..00c217c5f
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/manage-resources/overview.mdx
@@ -0,0 +1,122 @@
+---
+title: "Overview"
+slug: /guides/manage-resources
+sidebar-title: "Overview"
+position: 1
+max-toc-depth: 3
+---
+
+The SignalWire platform models every callable thing — a person, a room,
+an AI agent, a SWML script — as a [**Resource**](/docs/platform/resources).
+Each Resource is reachable via one or more [**Addresses**](/docs/platform/addresses)
+in the form `//` (e.g. `/private/jane`, `/public/support`).
+**Subscribers** are the Resource type that represents a user of your
+application, with credentials, identity, and assigned phone numbers.
+
+The Browser SDK exposes three handles for working with these platform
+concepts at runtime, all hanging off the connected `SignalWire`
+client:
+
+| Platform concept | SDK access point | What it gives you |
+| -------------------------------------- | ---------------------------- | ------------------------------------------------------------------ |
+| The authenticated [Subscriber](/docs/platform/subscribers) | `client.user` / `client.user$` | Identity (id, email, name, company), assigned addresses, SAT claims. |
+| The [Addresses](/docs/platform/addresses) reachable to that user | `client.directory` / `client.directory$` | Paginated, observable list of [`Address`] entries — search, dial, message. |
+| Per-client settings | `client.preferences` | Device choices, media defaults, ICE/recovery tuning — optionally persisted. |
+| Per-call capability flags | `call.self.capabilities` | What the current participant is allowed to do (mute, layout, screenshare, end). |
+
+This section walks through each one in turn.
+
+## Naming note: "Subscriber" vs [`User`]
+
+Browser SDK v4 renamed the SDK-side object from `Subscriber` to
+[`User`]. This is a pure SDK surface change — the platform still calls
+these resources "Subscribers" everywhere it matters:
+
+- The product is still called **Subscriber Access Token** (SAT).
+- The REST API endpoints are still `/api/fabric/subscribers/...`.
+- The Dashboard still shows a **Subscribers** tab.
+- The [`Address`] for one of these resources still has `type === 'subscriber'`.
+
+So when you read **"Subscriber"** in platform docs or dashboard UI, the
+SDK-side equivalent is `client.user`. The two refer to the same thing
+viewed from different sides.
+
+## How they fit together
+
+```
+ ┌──────────────────────────────┐
+ │ SignalWire (platform) │
+ │ Resources + Addresses │
+ └──────────────┬───────────────┘
+ │ REST mint
+ ▼
+ ┌──────────────────────────────┐
+ Backend mints SAT │ Subscriber Access Token │
+ on user login ─► │ for THIS subscriber │
+ └──────────────┬───────────────┘
+ │ passed to SDK
+ ▼
+ ┌────────────────────────────────────────────────────────────┐
+ │ const client = new SignalWire(provider) │
+ │ │
+ │ client.user$ ─► this subscriber's profile │
+ │ client.directory$ ─► addresses they can reach │
+ │ client.preferences ─► their per-client settings │
+ │ │
+ │ const call = await client.dial(address) │
+ │ call.self.capabilities ─► what they may do in THIS call │
+ └────────────────────────────────────────────────────────────┘
+```
+
+The platform decides which Addresses a subscriber can reach (based on
+context, ACLs, and the SAT's scopes). The SDK surfaces that as an
+observable directory — your UI doesn't have to know how the platform
+arrives at the list.
+
+## What you'll find in this section
+
+
+
+ Working with `client.user`: profile fields, assigned addresses,
+ push notification keys, and the v3 → v4 `Subscriber` → [`User`]
+ rename.
+
+
+ `client.directory` and the [`Address`] entity: paginated listing,
+ lookup by URI, channels, messaging, and call history.
+
+
+ `client.preferences`: persisted device choices, media defaults,
+ custom `userVariables`, and ICE / recovery tuning.
+
+
+ `call.self.capabilities`: drive your UI off real server-granted
+ permissions instead of guessing what a participant may do.
+
+
+
+For creating Subscribers, minting tokens, or managing Resources from
+your backend, see the platform's
+[REST API reference](/docs/apis) and
+[Subscribers overview](/docs/platform/subscribers). The Browser SDK
+itself never creates or destroys Resources — it only authenticates
+*as* one and consumes the Addresses the platform exposes to it.
+
+## Reference
+
+- [`SignalWire.user`] / [`SignalWire.user$`] — authenticated user
+- [`SignalWire.directory`] / [`SignalWire.directory$`] — paginated address book
+- [`SignalWire.preferences`] — per-client settings ([`ClientPreferences`])
+- [`SelfCapabilities`] — per-call capability flags
+- [`User`], [`Address`], [`Directory`] — the entity types
+
+[`SignalWire.user`]: /docs/browser-sdk/v4/reference/signalwire/user$
+[`SignalWire.user$`]: /docs/browser-sdk/v4/reference/signalwire/user$
+[`SignalWire.directory`]: /docs/browser-sdk/v4/reference/signalwire/directory$
+[`SignalWire.directory$`]: /docs/browser-sdk/v4/reference/signalwire/directory$
+[`SignalWire.preferences`]: /docs/browser-sdk/v4/reference/signalwire
+[`ClientPreferences`]: /docs/browser-sdk/v4/reference/client-preferences
+[`SelfCapabilities`]: /docs/browser-sdk/v4/reference/self-capabilities
+[`User`]: /docs/browser-sdk/v4/reference/user
+[`Address`]: /docs/browser-sdk/v4/reference/address
+[`Directory`]: /docs/browser-sdk/v4/reference/interfaces/directory
diff --git a/fern/products/browser-sdk/pages/v4/guides/manage-resources/subscribers.mdx b/fern/products/browser-sdk/pages/v4/guides/manage-resources/subscribers.mdx
new file mode 100644
index 000000000..812f34d19
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/manage-resources/subscribers.mdx
@@ -0,0 +1,165 @@
+---
+title: "Subscribers"
+slug: /guides/subscribers
+sidebar-title: "Subscribers"
+position: 2
+max-toc-depth: 3
+---
+
+[**Subscribers**](/docs/platform/subscribers) are the SignalWire
+Resource type that represents a user of your application. Each
+Subscriber has credentials, a profile, a private
+[Address](/docs/platform/addresses) for direct dialing, and may own
+public phone numbers. When the Browser SDK authenticates with a
+[Subscriber Access Token (SAT)](/docs/browser-sdk/v4/guides/authentication), the
+platform identifies the caller as one specific Subscriber — and the
+SDK surfaces that identity as `client.user`.
+
+This guide is about the runtime side: reading the authenticated user's
+profile, reacting to identity changes, and understanding the v4
+rename. For *creating* and *managing* Subscribers themselves (which
+happens on your backend, not in the browser), see
+[Subscribers in the platform docs](/docs/platform/subscribers) and the
+[REST API](/docs/apis/rest/subscribers/create-subscriber).
+
+## v3 → v4: `Subscriber` → [`User`]
+
+Browser SDK v4 renamed the SDK-side object:
+
+| v3 (deprecated) | v4 |
+| -------------------------- | ------------------- |
+| `client.subscriber$` | `client.user$` |
+| `client.subscriber` | `client.user` |
+| `Subscriber` class | [`User`] class |
+| `Participant.subscriberId` | `Participant.userId` |
+
+This is a pure SDK rename — the platform, dashboard, REST API, and
+token type are all unchanged. The reason for the rename: not every
+authenticated session corresponds to a "Subscriber" resource — guest
+tokens, for example, produce a session that doesn't map to a stored
+Subscriber. [`User`] is the more general term.
+
+In docs and code samples below we use `user`. If you're reading
+platform-level material that says "Subscriber," it means the same
+thing — the Subscriber Resource the SDK is currently authenticated as.
+
+## Accessing the authenticated user
+
+`client.user` is populated automatically when the client connects —
+the SDK fetches the profile from `/api/fabric/subscriber/info` on
+your behalf. Subscribe to `client.user$` to wait for it without
+risking a `null` read:
+
+```js
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(
+ new StaticCredentialProvider({ token: "YOUR_SAT" })
+);
+
+client.user$.subscribe((user) => {
+ if (!user) return; // not yet authenticated
+ console.log("Signed in as", user.displayName ?? user.email);
+});
+```
+
+`client.user$` is a BehaviorSubject — once the profile loads, late
+subscribers get the cached value synchronously.
+
+## Profile
+
+The [`User`] instance carries the platform-side Subscriber profile:
+identity (id, email, firstName / lastName / displayName),
+organisation context (jobTitle, companyName, timeZone, country,
+region), the assigned `addresses` and `pushNotificationKey`, plus
+`appSettings.scopes` from the SAT. See [`User`] for the full field
+list. A typical "who am I" panel:
+
+```js
+client.user$.subscribe((user) => {
+ if (!user) return;
+ document.querySelector("#name").textContent =
+ user.displayName ?? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim();
+ document.querySelector("#email").textContent = user.email;
+ document.querySelector("#company").textContent = user.companyName ?? "";
+});
+```
+
+### Assigned addresses
+
+`user.addresses` is the list of [Resource Addresses](/docs/platform/addresses)
+this user owns directly — typically one private address (e.g.
+`/private/jane-doe`) plus any phone numbers or aliases the platform
+has provisioned for them. These are the addresses *other* users will
+dial to reach this user.
+
+This is distinct from `client.directory`, which is the broader list
+of addresses this user can *reach* (other Subscribers, rooms, AI
+agents, scripts the platform exposes to them). See
+[Address Book & Directory](/docs/browser-sdk/v4/guides/address-book).
+
+### Scopes
+
+`user.appSettings?.scopes` reflects the permission scopes the SAT was
+minted with. Use it to gate features your backend has opted the user
+into:
+
+```js
+client.user$.subscribe((user) => {
+ if (!user) return;
+ const canRecord = user.appSettings?.scopes.includes("recording");
+ recordButton.hidden = !canRecord;
+});
+```
+
+The scopes are advisory in the UI — the *real* enforcement is
+server-side, but reading them locally avoids showing buttons that
+would fail.
+
+## Creating and managing Subscribers
+
+Subscriber lifecycle happens on your backend, not in the browser. The
+Browser SDK can only sign in *as* a Subscriber — it can't create,
+update, or delete one. The REST endpoints for that work are:
+
+| Operation | REST endpoint |
+| ------------- | -------------------------------------------------------- |
+| List | `GET /api/fabric/subscribers` |
+| Create | `POST /api/fabric/subscribers` |
+| Retrieve | `GET /api/fabric/subscribers/{id}` |
+| Update | `PUT /api/fabric/subscribers/{id}` |
+| Delete | `DELETE /api/fabric/subscribers/{id}` |
+| Mint a SAT | `POST /api/fabric/subscribers/{id}/tokens` |
+| Mint guest | `POST /api/fabric/subscribers/{id}/tokens/guest` |
+
+These are called from your server with your SignalWire API
+credentials — **never** from the browser. The typical pattern: on
+login, your backend looks up (or creates) a Subscriber for the
+authenticated user, mints a SAT, and returns it to the browser to
+hand to the SDK. See [Authentication](/docs/browser-sdk/v4/guides/authentication)
+for the full token flow.
+
+## Sign-out
+
+There's no "sign out the Subscriber" call on the platform — the
+Subscriber resource persists. To end the *session*, disconnect the
+SDK client and discard the SAT:
+
+```js
+await client.disconnect();
+// then drop your stored token, redirect to login, etc.
+```
+
+A disconnected client can be garbage-collected; for the next login,
+construct a new `SignalWire` with a fresh credential provider.
+
+## Reference
+
+- [`SignalWire.user`] / [`user$`] — the authenticated user
+- [`User`] — class with profile fields (id, email, firstName, lastName, displayName, addresses, pushNotificationKey, appSettings, satClaims)
+- [`SignalWire.disconnect()`] — end the session
+
+[`SignalWire.user`]: /docs/browser-sdk/v4/reference/signalwire/user$
+[`user$`]: /docs/browser-sdk/v4/reference/signalwire/user$
+[`User`]: /docs/browser-sdk/v4/reference/user
+[`SignalWire.disconnect()`]: /docs/browser-sdk/v4/reference/signalwire/disconnect
diff --git a/fern/products/browser-sdk/pages/v4/guides/web-components/click-to-call-widget.mdx b/fern/products/browser-sdk/pages/v4/guides/web-components/click-to-call-widget.mdx
new file mode 100644
index 000000000..5df2dfe0c
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/web-components/click-to-call-widget.mdx
@@ -0,0 +1,126 @@
+---
+title: "Click-to-Call Widget"
+slug: /guides/click-to-call-widget
+sidebar-title: "Click-to-Call"
+position: 2
+max-toc-depth: 3
+---
+
+`` is the fastest way to put a working call button on
+a web page. Set a `token` and a `destination`, and visitors get a
+single styled button that dials your support or sales line and opens
+the full call UI in a modal.
+
+Under the hood it's a thin wrapper around `` configured
+in modal mode — the button is the trigger, everything else (media,
+controls, optional AI transcript) is delegated to the widget once the
+user clicks.
+
+## Minimum embed
+
+The smallest viable integration is one script tag and one element:
+
+```html
+
+
+
+```
+
+That's the whole integration. Drop those two snippets into any HTML
+page and you have a working call button.
+
+If you're using a bundler instead of the embed pathway, swap the script
+tag for `import "@signalwire/web-components/sw-click-to-call"` — the
+element markup is identical. See [Overview](/docs/browser-sdk/v4/guides/web-components)
+for the two pathways.
+
+## Provisioning a token
+
+`token` accepts two formats:
+
+- **Embed token** (`c2c_…` or `c2t_…` prefix) — created in the
+ SignalWire Dashboard's *Embeds* section. The widget automatically
+ routes through `embeds.signalwire.com` and the destination is baked
+ into the embed configuration on the server side; the `destination`
+ attribute is ignored when an embed token already pins one.
+- **Subscriber Access Token (SAT)** — a JWT minted by your backend for
+ an authenticated user. Use this when the call should be attributed
+ to a known account. Pair it with an explicit `destination`.
+
+For public-facing pages (marketing, support, sales), the embed token
+is the right choice: it's safe to expose in HTML, scoped to a single
+destination, and revocable from the dashboard. Use a SAT only when the
+visitor is logged into your app.
+
+See [Authentication](/docs/browser-sdk/v4/guides/authentication) for details.
+
+## Attributes
+
+`token` is required; everything else is optional. `destination`
+selects which resource to dial (ignored when the embed token already
+pins one). `label` sets the button text. `audio-only` flips to a
+no-video flow. See the
+[`` reference](/docs/browser-sdk/v4/reference/web-component/sw-click-to-call)
+for the full attribute list.
+
+## Listening for events
+
+The element bubbles three composed events: `sw-dial` (dialing
+started), `sw-call-hangup` (visitor clicked hangup), `sw-call-ended`
+(call reached any terminal state). Wire them up for analytics or a
+post-call confirmation UI:
+
+```js
+document.querySelector("sw-click-to-call")
+ .addEventListener("sw-dial", (e) => {
+ analytics.track("call_started", { destination: e.detail.destination });
+ });
+```
+
+## Styling
+
+The button is themed with the SignalWire DTCG tokens shipped in
+`theme.css`. Override any of them at a parent element to restyle the
+button without touching shadow DOM:
+
+```css
+sw-click-to-call {
+ --interactive-status-success: #1d4ed8; /* button background */
+ --radius-md: 999px; /* pill shape */
+ --type-family-body: "Inter", sans-serif;
+ --type-size-body: 15px;
+}
+```
+
+For deeper control, target the inner `button` via its
+[CSS Part](https://developer.mozilla.org/en-US/docs/Web/CSS/::part):
+
+```css
+sw-click-to-call::part(button) {
+ padding: 14px 28px;
+ box-shadow: 0 4px 14px rgba(29, 78, 216, 0.4);
+}
+```
+
+See [Theming](/docs/browser-sdk/v4/guides/web-components-theming) for the full token
+list and brand-color recipes.
+
+## When to reach for `sw-call-widget` instead
+
+`` is intentionally narrow: one destination, one
+button, modal-only. If you need any of the following, drop down to
+`` directly:
+
+- Multiple destinations (a directory, dynamic routing).
+- Inline (non-modal) rendering — embedded inside a page section.
+- A custom trigger element (use the default slot of ``).
+- Receiving incoming calls (`allow-incoming-calls`).
+- Programmatic `dial()` / `hangup()` control.
+
+Both elements share the same underlying call lifecycle — see the
+[`` reference](/docs/browser-sdk/v4/reference/web-component/sw-call-widget)
+for the full surface.
diff --git a/fern/products/browser-sdk/pages/v4/guides/web-components/customization.mdx b/fern/products/browser-sdk/pages/v4/guides/web-components/customization.mdx
new file mode 100644
index 000000000..66af199f2
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/web-components/customization.mdx
@@ -0,0 +1,195 @@
+---
+title: "Customization"
+slug: /guides/web-components-customization
+sidebar-title: "Customization"
+position: 4
+max-toc-depth: 3
+---
+
+When the all-in-one `` doesn't match your layout — or
+you only want one or two pieces of it — drop down to the primitives.
+Every widget on the page is built from the same elements you can use à
+la carte, wired together through reactive contexts. Compose them, swap
+them, slot custom UI in.
+
+This guide covers three patterns, ordered by how much of the built-in
+widget you're keeping:
+
+1. **Slot-based composition** — keep ``, replace a
+ region (trigger, background) with your own markup.
+2. **Listening to events & driving the widget** — pure attribute /
+ event / method API; the widget still owns the lifecycle.
+3. **Building a UI from primitives** — drop ``
+ entirely and compose ``, ``,
+ ``, etc. yourself.
+
+## Slots
+
+`` has a `background` slot for the full-bleed
+background behind the call, and a default slot for the trigger
+element shown while idle. Anything you place in the default slot
+becomes the click target — `widget.dial()` fires on click. A trigger
+button with a custom background:
+
+```html
+
+
+
+
+
+ Talk to sales
+
+
+```
+
+Any element placed in the default slot becomes the trigger — clicking
+anywhere inside it calls `widget.dial()` for you. The widget swaps
+into call mode (inline or modal, depending on the `modal` attribute)
+once dialing starts.
+
+## Programmatic control
+
+`` exposes two imperative methods you can call from JS
+when you want to drive dialing yourself instead of relying on the
+trigger slot:
+
+```js
+const widget = document.querySelector("sw-call-widget");
+
+document.querySelector("#dial-btn").addEventListener("click", async () => {
+ try {
+ await widget.dial();
+ } catch (err) {
+ console.error("dial failed", err);
+ }
+});
+
+document.querySelector("#hangup-btn").addEventListener("click", () => {
+ widget.hangup();
+});
+```
+
+The widget bubbles `sw-dial` and `sw-call-ended` (plus forwarded
+agent events) — same pattern as native DOM events. Listen from
+anywhere:
+
+```js
+widget.addEventListener("sw-call-ended", (e) => {
+ analytics.track("call_ended", { status: e.detail.status });
+});
+```
+
+See [``] for the full event surface.
+
+## Pass-through attributes
+
+A handful of widget attributes toggle which sub-features mount —
+`transcription` enables the AI transcript drawer, `allow-incoming-calls`
+listens for inbound calls on the same token, `audio-only` skips the
+camera. `user-variables` (JSON string) is forwarded into the Verto
+invite for the receiving side to read.
+
+See [``] for the full attribute list.
+
+## Building a UI from primitives
+
+When you want full control over the layout, skip ``
+and compose the primitives directly. `` is the only
+required wrapper — it owns the reactive contexts that the SDK-aware
+components subscribe to.
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+```
+
+The same pattern works with the embed bundle — replace the imports with
+`const { SignalWire, StaticCredentialProvider } = SignalWireUI;` and drop
+the `
+
+
+
+
+
+
+