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/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/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..11d7f7f61
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx
@@ -0,0 +1,194 @@
+---
+title: "Device Management"
+slug: /guides/device-management
+sidebar-title: "Device Management"
+position: 4
+max-toc-depth: 3
+---
+
+The Browser SDK enumerates the user's microphones, cameras, and
+speakers for you, surfaces them as observables, and reactively
+updates when the OS reports a `devicechange` event. Pick a device,
+hand it to the SDK, and the SDK applies it to any active call.
+
+The device API lives on the client (`client.audioInputDevices$`,
+etc.) — not on the call. Picks made before dialing are used for the
+next call; picks made mid-call are applied immediately.
+
+## Permission first
+
+Device labels are only populated after the user has granted
+microphone/camera permission at least once. Until then, `MediaDeviceInfo.label`
+is empty. To unlock labels at app start, request a transient stream:
+
+```js
+async function unlockDeviceLabels() {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ // Add `video: true` if your app also needs camera labels.
+ });
+ stream.getTracks().forEach((t) => t.stop()); // release immediately
+ } catch (err) {
+ // User denied — labels stay empty, but device IDs still work.
+ }
+}
+```
+
+Do this on a user gesture (click), not on page load — browsers
+penalize cold prompts.
+
+## The pattern
+
+Three things every device picker does:
+
+1. **Enumerate** — subscribe to the kind's observable
+ ([`audioInputDevices$`][`SignalWire.audioInputDevices$`],
+ [`videoInputDevices$`][`SignalWire.videoInputDevices$`],
+ [`audioOutputDevices$`][`SignalWire.audioOutputDevices$`]) and render
+ options. The streams re-emit on every OS device change.
+2. **Reflect the active choice** — subscribe to the matching
+ `selected…Device$` and keep your `` value in sync. This
+ matters because the SDK may auto-switch (see [Device recovery]
+ below) — the picker has to follow.
+3. **Apply user input** — on `change`, find the chosen
+ `MediaDeviceInfo` in the current list and pass it to
+ [`selectAudioInputDevice`][`SignalWire.selectAudioInputDevice()`]
+ (etc.). The SDK takes the device object, not the `deviceId` string.
+
+The three loops are identical except for the kind name. See the
+[complete device picker](#a-complete-device-picker) below for the
+de-duplicated version.
+
+If a call is active when the user picks, the change applies live —
+the SDK renegotiates the track without interrupting the call. Turn
+that off by setting `client.preferences.syncDevicesToActiveCalls =
+false` if you'd rather batch device changes between calls.
+
+[Device recovery]: #device-recovery
+
+### Speaker selection is browser-gated
+
+`selectAudioOutputDevice` only works in browsers that implement
+`HTMLMediaElement.setSinkId`. Chromium-based browsers support it;
+Firefox does not, so on Firefox the picker still renders but
+selecting a different speaker is a no-op — playback uses the system
+default. Show the picker anyway; users with multiple outputs on
+Chromium will appreciate it, and on Firefox they won't notice it
+doesn't work.
+
+## Device recovery
+
+The SDK monitors the active device's `MediaStreamTrack` for `ended`
+events (unplugged, OS reclaimed the device) and auto-switches to the
+next available device of the same kind. When it does, it emits a
+`DeviceRecoveryEvent` on `client.deviceRecovered$`:
+
+```js
+client.deviceRecovered$.subscribe((event) => {
+ showToast(
+ "Device changed",
+ `${event.kind} switched to ${event.newDevice?.label ?? "default"} (${event.reason})`
+ );
+});
+```
+
+Recovery is transparent — there's no interruption to the call — but
+the user should know which mic they're actually transmitting on.
+
+You can disable monitoring at construction with `skipDeviceMonitoring:
+true` if you'd rather handle device events yourself.
+
+## Default constraints
+
+Per-track constraints (resolution, frame rate, sample rate, echo
+cancellation, etc.) can be set globally on `client.preferences` or
+per-call as `inputAudioDeviceConstraints` / `inputVideoDeviceConstraints`
+in `DialOptions`:
+
+```js
+// Global default — applies to every dial
+client.preferences.defaultVideoConstraints = {
+ width: { ideal: 1280 },
+ height: { ideal: 720 },
+ frameRate: { ideal: 30 },
+};
+
+// Per-call override
+await client.dial("/private/team", {
+ video: true,
+ inputVideoDeviceConstraints: { width: { ideal: 1920 }, height: { ideal: 1080 } },
+});
+```
+
+See [Client Preferences → Default media constraints](/docs/browser-sdk/v4/guides/client-preferences#default-media-constraints).
+
+## Audio processing
+
+Echo cancellation, noise suppression, and auto-gain are
+*per-track*, not per-device — they live on the
+[`SelfParticipant`][`SelfParticipant.enableStudioAudio()`] of the
+active call. Toggling them while a different mic is selected applies
+to whatever track is currently being captured. See
+[Call Controls → Audio processing](/docs/browser-sdk/v4/guides/call-controls#self-mute--audio-state).
+
+## A complete device picker
+
+```js
+function bindDeviceSelectors(client) {
+ const wireUp = (kind, listSelect, selectMethod) => {
+ client[`${kind}Devices$`].subscribe((devices) => {
+ listSelect.innerHTML = "";
+ for (const d of devices) {
+ const o = document.createElement("option");
+ o.value = d.deviceId;
+ o.textContent = d.label || `${kind} ${d.deviceId.slice(0, 8)}`;
+ listSelect.appendChild(o);
+ }
+ const selected = client[`selected${capitalize(kind)}Device`];
+ if (selected) listSelect.value = selected.deviceId;
+ });
+
+ client[`selected${capitalize(kind)}Device$`].subscribe((device) => {
+ if (device) listSelect.value = device.deviceId;
+ });
+
+ listSelect.onchange = () => {
+ const device = client[`${kind}Devices`].find(
+ (d) => d.deviceId === listSelect.value
+ );
+ if (device) client[selectMethod](device);
+ };
+ };
+
+ wireUp("audioInput", micSelect, "selectAudioInputDevice");
+ wireUp("videoInput", cameraSelect, "selectVideoInputDevice");
+ wireUp("audioOutput", speakerSelect, "selectAudioOutputDevice");
+}
+```
+
+The kitchen-sink demo (`playground/kitchen-sink-demo/src/main.ts`)
+wires this end-to-end.
+
+## Reference
+
+- [`SignalWire.audioInputDevices$`], [`SignalWire.videoInputDevices$`], [`SignalWire.audioOutputDevices$`] — device lists
+- [`SignalWire.selectAudioInputDevice()`], [`selectVideoInputDevice()`], [`selectAudioOutputDevice()`] — pick a device
+- [`SignalWire.selectedAudioInputDevice$`], [`selectedVideoInputDevice$`], [`selectedAudioOutputDevice$`] — reactive selection state
+- [`SignalWire.deviceRecovered$`] — auto-switch events
+- [`ClientPreferences.defaultAudioConstraints`] / [`defaultVideoConstraints`] — default track constraints
+- [`SelfParticipant.enableStudioAudio()`] — disable all audio processing in one call
+
+[`SignalWire.audioInputDevices$`]: /docs/browser-sdk/v4/reference/signalwire/audio-input-devices$
+[`SignalWire.videoInputDevices$`]: /docs/browser-sdk/v4/reference/signalwire/video-input-devices$
+[`SignalWire.audioOutputDevices$`]: /docs/browser-sdk/v4/reference/signalwire/audio-output-devices$
+[`SignalWire.selectAudioInputDevice()`]: /docs/browser-sdk/v4/reference/signalwire/select-audio-input-device
+[`selectVideoInputDevice()`]: /docs/browser-sdk/v4/reference/signalwire/select-video-input-device
+[`selectAudioOutputDevice()`]: /docs/browser-sdk/v4/reference/signalwire/select-audio-output-device
+[`SignalWire.selectedAudioInputDevice$`]: /docs/browser-sdk/v4/reference/signalwire/selected-audio-input-device$
+[`selectedVideoInputDevice$`]: /docs/browser-sdk/v4/reference/signalwire/selected-video-input-device$
+[`selectedAudioOutputDevice$`]: /docs/browser-sdk/v4/reference/signalwire/selected-audio-output-device$
+[`SignalWire.deviceRecovered$`]: /docs/browser-sdk/v4/reference/signalwire/device-recovered$
+[`ClientPreferences.defaultAudioConstraints`]: /docs/browser-sdk/v4/reference/client-preferences
+[`defaultVideoConstraints`]: /docs/browser-sdk/v4/reference/client-preferences
+[`SelfParticipant.enableStudioAudio()`]: /docs/browser-sdk/v4/reference/self-participant/enable-studio-audio
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..2b24e488a
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx
@@ -0,0 +1,226 @@
+---
+title: "Inbound Calls"
+slug: /guides/inbound-calls
+sidebar-title: "Inbound Calls"
+position: 3
+max-toc-depth: 3
+---
+
+To receive calls, the client needs to be **registered** with the
+platform — that tells SignalWire to route any call addressed to the
+authenticated Subscriber's address into this client. Once registered,
+incoming calls appear in `client.session.incomingCalls$`; you accept
+or reject each one through methods on the `Call` instance.
+
+Embed tokens **cannot** receive calls. Use a Subscriber Access Token
+(SAT) — see [Authentication](/docs/browser-sdk/v4/guides/authentication).
+
+## Register the client
+
+`register()` happens automatically when you construct the client
+unless you set `skipRegister: true`. To be explicit (or to re-register
+after `unregister()`):
+
+```js
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(
+ new StaticCredentialProvider({ token: "YOUR_SAT" })
+);
+
+// Wait until the client is connected + authenticated, then register.
+client.ready$.subscribe(async (ready) => {
+ if (!ready) return;
+ await client.register();
+ console.log("ready to receive calls");
+});
+```
+
+`isRegistered$` is the observable form — true when registration is
+live, false on `unregister()` or transport loss.
+
+## Subscribe to incoming calls
+
+```js
+import { filter } from "rxjs";
+
+client.session.incomingCalls$.subscribe((calls) => {
+ // calls is the current list — usually 0 or 1, can be more
+ const ringing = calls.filter((c) => c.status === "ringing");
+ for (const call of ringing) {
+ presentRingingUi(call);
+ }
+});
+```
+
+The list is reactive: it changes when a call rings, when its status
+transitions out of `ringing`, and when calls are reattached on
+reconnect. Filter by status to find what you should actually present.
+
+## Accepting a call
+
+[`Call.answer()`] opens the media negotiation. It accepts an optional
+`MediaOptions` to override what tracks you send:
+
+```js
+function presentRingingUi(call) {
+ showModal({
+ callerName: call.from ?? "Unknown",
+ onAccept: () => {
+ call.answer({
+ audio: true,
+ video: true, // send video back
+ });
+ wireCallToUi(call); // subscribe to status$, remoteStream$, etc.
+ hideModal();
+ },
+ onReject: () => {
+ call.reject();
+ hideModal();
+ },
+ });
+}
+```
+
+`answer()` is synchronous — it flips an internal flag and the SDK
+proceeds with negotiation. Watch `status$` to know when media is
+flowing:
+
+```js
+call.status$
+ .pipe(filter((s) => s === "connected"))
+ .subscribe(() => console.log("call live"));
+```
+
+## Knowing what media the caller offered
+
+The caller's offer dictates which directions the SDK can answer in.
+`call.mediaDirections` reports the negotiated transceiver direction
+per kind:
+
+```js
+const dirs = call.mediaDirections; // { audio, video }
+const canReceiveAudio = dirs.audio.includes("recv"); // 'sendrecv' or 'recvonly'
+const canReceiveVideo = dirs.video.includes("recv");
+```
+
+Use this to pre-populate the accept modal — there's no point letting
+the user enable video if the offer is audio-only.
+
+```js
+acceptVideoCheckbox.disabled = !canReceiveVideo;
+acceptVideoCheckbox.checked = canReceiveVideo;
+```
+
+## Rejecting
+
+```js
+call.reject();
+```
+
+`reject()` declines the call before media is negotiated. The caller
+sees a busy / declined indication and the `Call` transitions to
+`destroyed`. Don't call any other method on the call after
+rejecting.
+
+## A complete inbound modal
+
+```js
+import { filter } from "rxjs";
+
+let currentRinging = null;
+
+client.session.incomingCalls$.subscribe((incomingCalls) => {
+ const ringing = incomingCalls.filter((c) => c.status === "ringing");
+
+ if (ringing.length > 0 && !currentRinging) {
+ currentRinging = ringing[0];
+ showRingingModal(currentRinging);
+
+ // Auto-hide if the call leaves ringing for any reason
+ currentRinging.status$
+ .pipe(filter((s) => s !== "ringing"))
+ .subscribe(() => {
+ if (currentRinging?.id === ringing[0].id) {
+ hideRingingModal();
+ currentRinging = null;
+ }
+ });
+ } else if (ringing.length === 0 && currentRinging) {
+ hideRingingModal();
+ currentRinging = null;
+ }
+});
+
+function showRingingModal(call) {
+ modal.innerHTML = `Accept
+ Reject `;
+ modal.classList.add("visible");
+
+ modal.querySelector("#accept").onclick = () => {
+ call.answer({
+ audio: true,
+ video: call.mediaDirections.video.includes("recv"),
+ });
+ wireCallToUi(call);
+ };
+
+ modal.querySelector("#reject").onclick = () => {
+ call.reject();
+ };
+}
+```
+
+This is the same shape the kitchen-sink demo uses (see
+`playground/kitchen-sink-demo/src/main.ts`, `subscribeToIncomingCalls`).
+
+## Multiple simultaneous calls
+
+`incomingCalls$` can have more than one entry. A few options:
+
+- **Auto-reject extras while busy.** Iterate over the list, reject
+ everything past the one you're already showing.
+- **Queue them.** Maintain your own pending list; only present the
+ next one after the current is answered or rejected.
+- **Present them all.** Show a stacked notification with multiple
+ accept actions. Each entry is its own `Call` instance with its own
+ observables.
+
+The SDK doesn't enforce a policy here — that's up to your UX.
+
+## Unregistering
+
+```js
+await client.unregister();
+```
+
+After `unregister()`, the platform stops routing calls to this client.
+Use it before logging out, or to put the user into a "do not disturb"
+state without disconnecting the client entirely. `register()` puts
+them back into the routing pool.
+
+## Push notifications
+
+The `User.pushNotificationKey` field carries a key your backend can
+register with a push service so users get notified of incoming calls
+when the tab isn't open. The SDK doesn't manage push subscriptions
+itself — wiring through Web Push (or your mobile platform's
+equivalent) is application territory.
+
+## Reference
+
+- [`SignalWire.register()`] — opt into receiving inbound calls
+- [`SignalWire.unregister()`] — stop receiving inbound calls
+- [`SignalWire.isRegistered$`] — reactive registration state
+- [`SignalWire.session`] → [`SessionState.incomingCalls$`] — the ringing-call stream
+- [`Call.answer()`] / [`Call.reject()`] — accept or decline
+- [`Call.mediaDirections`] — what media the caller offered
+
+[`SignalWire.register()`]: /docs/browser-sdk/v4/reference/signalwire/register
+[`SignalWire.unregister()`]: /docs/browser-sdk/v4/reference/signalwire/unregister
+[`SignalWire.isRegistered$`]: /docs/browser-sdk/v4/reference/signalwire/is-registered$
+[`SignalWire.session`]: /docs/browser-sdk/v4/reference/signalwire/session
+[`SessionState.incomingCalls$`]: /docs/browser-sdk/v4/reference/interfaces/session-state
+[`Call.answer()`]: /docs/browser-sdk/v4/reference/webrtc-call/answer
+[`Call.reject()`]: /docs/browser-sdk/v4/reference/webrtc-call/reject
+[`Call.mediaDirections`]: /docs/browser-sdk/v4/reference/webrtc-call/media-directions$
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..11ea1dcbe
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/outbound-calls.mdx
@@ -0,0 +1,233 @@
+---
+title: "Outbound Calls"
+slug: /guides/outbound-calls
+sidebar-title: "Outbound Calls"
+position: 2
+max-toc-depth: 3
+---
+
+`client.dial()` is the entry point for every outbound call — to a
+room, another Subscriber, a SIP endpoint, or a PSTN number routed
+through a SignalWire Resource. Pass a destination string (or an
+`Address` from the directory), media options, and you get back a
+`Call` instance whose lifecycle you observe through its streams.
+
+## The shortest example
+
+```js
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(
+ new StaticCredentialProvider({ token: "YOUR_SAT" })
+);
+
+const call = await client.dial("/public/test-room", {
+ audio: true,
+ video: true,
+});
+
+call.status$.subscribe((status) => console.log("status:", status));
+call.remoteStream$.subscribe((stream) => {
+ document.querySelector("video#remote").srcObject = stream;
+});
+```
+
+That's enough to place a video call, attach the remote video, and log
+status transitions. Everything else in this page is "what if I need
+to…" elaboration.
+
+## Destinations
+
+`destination` accepts:
+
+- A Resource Address URI like `"/public/sales"` or `"/private/alice"`.
+- A phone number routed through a Fabric address: `"/public/sales?channel=audio"`.
+- An `Address` instance from `client.directory.addresses$`. The SDK
+ picks the right channel automatically.
+
+```js
+// String form
+await client.dial("/private/alice");
+
+// Address form — channel selection is automatic
+import { firstValueFrom } from "rxjs";
+const directory = await firstValueFrom(client.directory$);
+const alice = directory.addresses.find((a) => a.name === "/private/alice");
+await client.dial(alice);
+```
+
+URI query parameters override defaults — `?channel=audio` forces
+audio-only, `?channel=video` forces video.
+
+## Options
+
+The second argument to `dial()` controls what media you send /
+receive and lets you attach per-call metadata. See [`DialOptions`] for
+the full shape. The pattern: pass what you want for this specific
+call; anything omitted falls back to
+[`client.preferences`](/docs/browser-sdk/v4/guides/client-preferences),
+which is where app-wide defaults live.
+
+Per-call `userVariables` are arbitrary key/value pairs forwarded to
+the receiving side (an AI agent, a SWML script, your own backend),
+useful for routing or attribution:
+
+```js
+const call = await client.dial("/public/sales", {
+ audio: true,
+ video: true,
+ userVariables: { plan: "enterprise", page: location.pathname },
+});
+```
+
+## Lifecycle
+
+Subscribe to [`status$`][`Call.status$`] and drive your UI off it.
+The stream is a BehaviorSubject — late subscribers get the current
+status immediately. The terminal states are `'destroyed'` (hung up
+cleanly) and `'failed'` (unrecoverable error); both mean it's time
+to tear down call UI. See [`CallStatus`] for the full enum.
+
+```js
+call.status$.subscribe((status) => {
+ statusBadge.textContent = status.toUpperCase();
+ if (status === "destroyed" || status === "failed") {
+ teardownCallUi();
+ }
+});
+```
+
+## Attaching media to the DOM
+
+`localStream$` and `remoteStream$` emit `MediaStream` objects you
+assign to `` or `` elements:
+
+```js
+call.localStream$.subscribe((stream) => {
+ if (stream) localVideoEl.srcObject = stream;
+});
+
+call.remoteStream$.subscribe((stream) => {
+ if (stream) remoteVideoEl.srcObject = stream;
+});
+```
+
+For autoplay reasons (Safari especially), have `` in your markup, and never set `muted` on the remote
+element — that mutes the remote audio.
+
+```html
+
+
+```
+
+## Hanging up
+
+```js
+await call.hangup();
+```
+
+`hangup()` transitions to `'disconnecting'`, sends a Verto `bye`,
+then destroys the call. After it resolves, the call instance is no
+longer usable — drop your references.
+
+If the *other* side hangs up, you'll see `status$` go through
+`'disconnecting'` → `'destroyed'` automatically.
+
+## Errors
+
+```js
+call.errors$.subscribe((callError) => {
+ const label = callError.fatal ? "Fatal" : "Recoverable";
+ console.error(`[${callError.kind}] ${label}:`, callError.error);
+ if (callError.fatal) {
+ // status$ will also transition to 'failed' — clean up there
+ } else {
+ // surface a toast / banner, the call continues
+ }
+});
+```
+
+Fatal errors automatically transition `status$` to `'failed'` and
+destroy the call. Non-fatal errors (e.g. a transient media issue
+that the recovery pipeline handled) keep the call alive — wire them
+to a banner so the user gets feedback without losing the connection.
+
+See [Troubleshooting](/docs/browser-sdk/v4/guides/troubleshooting) for the common
+error kinds.
+
+## Cleanup
+
+Always unsubscribe from observables when the call ends, or attach the
+subscriptions to a per-call `Subscription` you can `unsubscribe()` in
+your teardown handler:
+
+```js
+import { Subscription } from "rxjs";
+
+const subs = new Subscription();
+
+subs.add(call.status$.subscribe(/* … */));
+subs.add(call.remoteStream$.subscribe(/* … */));
+
+// When the call ends:
+subs.unsubscribe();
+```
+
+The SDK cleans up its own internal subscriptions when the call is
+destroyed — but anything *you* added needs to come down with it,
+otherwise you'll leak DOM updates on the next call.
+
+## Reconnecting attached calls on reload
+
+Set `reconnectAttachedCalls: true` (and `persistSession: true`) on the
+`SignalWire` client to survive a page reload mid-call:
+
+```js
+const client = new SignalWire(provider, {
+ reconnectAttachedCalls: true,
+ persistSession: true,
+});
+```
+
+After reconnect, `client.session.calls` contains any re-attached
+calls — usually exactly one:
+
+```js
+client.isConnected$.subscribe((connected) => {
+ if (!connected) return;
+ const existing = client.session.calls;
+ if (existing.length > 0) {
+ const call = existing[0];
+ wireCallToUi(call); // same subscriptions as a fresh dial
+ }
+});
+```
+
+The SDK stores active call IDs in sessionStorage. On reload, the
+server pushes a `verto.attach` event and the SDK reconstructs the
+`Call` instance with its state intact.
+
+See [Inbound Calls](/docs/browser-sdk/v4/guides/inbound-calls) for receiving calls
+and [Call Controls](/docs/browser-sdk/v4/guides/call-controls) for what to do once
+a call is connected.
+
+## Reference
+
+- [`SignalWire.dial()`] — place an outbound call
+- [`Call.status$`], [`Call.errors$`] — lifecycle and errors
+- [`Call.localStream$`], [`Call.remoteStream$`] — media bindings
+- [`Call.hangup()`] — end the call
+- [`DialOptions`] — the full options surface
+- [`CallStatus`] — every value `status$` may emit
+- [`SignalWire.session`] — re-attached calls live in `session.calls`
+
+[`SignalWire.dial()`]: /docs/browser-sdk/v4/reference/signalwire/dial
+[`Call.status$`]: /docs/browser-sdk/v4/reference/webrtc-call/status$
+[`Call.errors$`]: /docs/browser-sdk/v4/reference/webrtc-call/errors$
+[`Call.localStream$`]: /docs/browser-sdk/v4/reference/webrtc-call/local-stream$
+[`Call.remoteStream$`]: /docs/browser-sdk/v4/reference/webrtc-call/remote-stream$
+[`Call.hangup()`]: /docs/browser-sdk/v4/reference/webrtc-call/hangup
+[`DialOptions`]: /docs/browser-sdk/v4/reference/interfaces/dial-options
+[`CallStatus`]: /docs/browser-sdk/v4/reference/type-aliases/call-status
+[`SignalWire.session`]: /docs/browser-sdk/v4/reference/signalwire/session
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..3d7627768
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/getting-started/authentication.mdx
@@ -0,0 +1,169 @@
+---
+title: "Authentication"
+slug: /guides/authentication
+sidebar-title: "Authentication"
+position: 2
+max-toc-depth: 3
+---
+
+The SDK supports two authentication methods: **Subscriber Access Tokens (SAT)** for full-featured access, and **Embed Tokens** for guest access. This guide explains when to use each and how to implement them.
+
+## Token types
+
+| Token Type | Use Case | Features | Expires |
+| --------------------------------- | ------------------------ | ----------------------------------------------- | ------------------ |
+| **Subscriber Access Token (SAT)** | Authenticated users | Full access: directory, inbound calls | Yes (configurable) |
+| **Embed Token** | Guests, embedded widgets | Outbound calls only | Yes |
+
+## Subscriber Access Tokens (SAT)
+
+SATs are JWTs that authenticate a specific subscriber. Use these when your users have accounts in your system.
+
+### Getting a SAT
+
+Generate SATs from your backend using the SignalWire REST API.
+Project ID and API Token come from the
+[**API Credentials**](/docs/platform/your-signalwire-api-space) page in
+your SignalWire Dashboard — keep these on the server side; never
+expose them in client code.
+
+
+
+The response includes a `token` field containing the SAT.
+
+### Using a SAT
+
+```js
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const credentials = new StaticCredentialProvider({
+ token: "eyJhbGciOiJS...",
+});
+
+const client = new SignalWire(credentials);
+```
+
+### Token refresh
+
+SATs expire. For long-running applications, implement a credential provider that refreshes tokens:
+
+```js
+class RefreshingCredentialProvider {
+ async authenticate(context) {
+ // context.fingerprint - DPoP key fingerprint for client-bound tokens
+ const response = await fetch("/api/signalwire-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();
+ }
+}
+
+const client = new SignalWire(new RefreshingCredentialProvider());
+```
+
+The SDK calls `refresh()` automatically before the token expires if the method is defined.
+
+## Embed Tokens
+
+Embed tokens provide guest access without requiring a subscriber account. They're ideal for:
+
+- Public-facing call widgets
+- Customer support embeds
+- One-time meeting links
+
+### Getting an embed token
+
+Mint embed tokens server-side with your Dashboard
+[API credentials](/docs/platform/your-signalwire-api-space). Each
+token is scoped to a single `resource_id`.
+
+
+
+### Using an embed token
+
+The simplest approach uses `embeddableCall()`:
+
+```js
+import { embeddableCall } from "@signalwire/js";
+
+const call = await embeddableCall({
+ host: "yourspace.signalwire.com",
+ embedToken: "YOUR_EMBED_TOKEN",
+ to: "/public/room-name",
+});
+```
+
+This handles everything: creating credentials, connecting, and dialing.
+
+## Security best practices
+
+### Never expose credentials in client code
+
+```js
+// Bad - token hardcoded
+const credentials = new StaticCredentialProvider({ token: "eyJhbGciOiJS..." });
+
+// Good - token fetched from your backend
+async function getCredentials() {
+ const response = await fetch("/api/get-signalwire-token", {
+ headers: { Authorization: `Bearer ${userSessionToken}` },
+ });
+ const { token } = await response.json();
+ return new StaticCredentialProvider({ token });
+}
+```
+
+### Use short-lived tokens
+
+- Interactive sessions: 1–4 hours
+- Embed tokens: 15–60 minutes
+- Background services: longer, with refresh
+
+### Validate on your backend
+
+Before issuing tokens, verify the user is authorized in your system, then generate the SAT server-side.
+
+## Connection options
+
+The second argument to `new SignalWire(...)` is a
+[`SignalWireOptions`](/docs/browser-sdk/v4/reference/interfaces/signal-wire-options)
+object — flags for skipping auto-connect / auto-register, enabling
+session persistence and call reattach across reloads, persisting
+preferences to localStorage, and so on. Defaults connect and register
+automatically; reach for the options when you want to defer or
+override that.
+
+```js
+const client = new SignalWire(credentials, {
+ reconnectAttachedCalls: true,
+ persistSession: true,
+});
+```
+
+## Debugging authentication
+
+```js
+client.isConnected$.subscribe((c) => console.log("Connected:", c));
+client.isRegistered$.subscribe((r) => console.log("Registered:", r));
+client.ready$.subscribe((r) => console.log("Ready:", r));
+
+client.errors$.subscribe((error) => {
+ if (error.name === "InvalidCredentialsError") {
+ // Redirect to login or refresh token
+ }
+});
+```
+
+| Symptom | Likely Cause | Solution |
+| ----------------------------- | -------------------------------- | -------------------------------- |
+| `InvalidCredentialsError` | Token expired or malformed | Generate a new token |
+| `WebSocketConnectionError` | Network issue or wrong host | Check host URL and network |
+| `NotConnectedError` | Calling methods before connected | Wait for `ready$` to emit `true` |
+| Connection closes immediately | Token for wrong environment | Verify token matches host |
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..a08188c4a
--- /dev/null
+++ b/fern/products/browser-sdk/pages/v4/guides/getting-started/overview.mdx
@@ -0,0 +1,185 @@
+---
+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 `
+```
+
+The script exposes a global [`SignalWire`] object.
+
+## 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 `
+
+
+
+
+
+
+