The session speaks MatrixRTC and sees media through MediaBackend in sdk/src/backend/api.ts. Everything LiveKit lives in sdk/src/backend/livekit/, the only place in the SDK that imports livekit-client; the import boundary rule now enforces that outside sdk/src/backend/ the LiveKit packages appear in type imports only. The seam sits above the connection manager: one connection per transport url is the LiveKit multi-SFU strategy, not a MatrixRTC concept, so a cascading SFU or full mesh replaces the folder, not the session. A backend prepares the local transport the session discovered (the token exchange and the delegation probe, reported as canDelegateDelayedLeave), hands the delayed leave over per delay id, gives the local member one publish switch plus publish/unpublish, builds a remote member's media from its membership and registers that membership's transport for as long as the member lives, and reports its connections for the transport registry, which keys on the raw transport serialised with sorted keys. The backend's context carries no Matrix client (a backend picks what it calls in its own options), no membership list, and no js-sdk session: media keys arrive as a stream replayed on subscribe, and MatrixKeyProvider subscribes to it. The MatrixRTC mode no longer reaches LiveKit; it becomes a tokenEndpoint choice made where the default backend is built. Two behaviour changes ride along. Publishing is one boolean, joined and homeserver reachable, where before a sync hiccup after leave() resumed the upstream of tracks that were meant to stay silent. And ResolvedConnection keeps one ResolvedTransport object per fetch, so a state change no longer reads as a change of what was fetched. MatrixRTCClientOptions gains backend?: MediaBackendFactory, defaulting to createLivekitBackend built from the client options. authenticateWithTransport takes tokenEndpoint instead of matrixRTCMode.
38 KiB
From CallViewModel to MatrixRTCClient
How Element Call gets from today's CallViewModel, which does everything, to a
view model that consumes the SDK. The target shape is in
SdkArchitecture.md; this document is the inventory of
what moves, what stays, and how far along it is. It refers to source files and
symbols, so it ages with the code; the architecture document should not.
Status
| Slice | State |
|---|---|
1. Scaffold: sdk/ packaging, the interface, the harness, the smoke test |
done, October 2026 |
2. First implementation of createMatrixRTCClient under sdk/src, the media adapter, the e2e suite |
done, 1 October 2026 |
3. Copy the shared modules into sdk/src/ with their tests, drop every src/ import, turn on the import boundary |
done, 2 October 2026 |
4. createCallViewModel$ consumes the client; media view models take media$; Element Call imports the primitives and enums from the SDK and deletes its own copies |
done, 2 October 2026 |
5. sendData/data$ on the client; the legacy bundle sdk-target-based-on-call-view-model/ is rebuilt on the SDK with its interface unchanged, since it is in production use |
done, 2 October 2026 |
12. The media backend seam: everything LiveKit behind MediaBackend in sdk/src/backend/livekit/, the backend option, the lint guard; see MediaBackendPlan.md |
done, 4 October 2026 |
Element Call now consumes the SDK. InCallView creates the client from the
configuration, settings and URL parameters, and hands it to createCallViewModel$,
which keeps what makes the session a call. The modules below were deleted from
src/ once the SDK had its copies, and Behavior, ObservableScope, the
observable operators, E2eeType, EncryptionSystem and MatrixRTCMode are
re-exported from the SDK by their old paths so the rest of Element Call did not
have to change in the same step. The view model tests drive a fake
MatrixRTCClient (mockMatrixRTCClient in src/utils/test.ts) instead of mocked
LiveKit rooms; the tests of the deleted modules live on in sdk/src.
What still binds Element Call to LiveKit, as slices
Element Call still imports the LiveKit packages in a few places. Each is a gap in
the SDK's API rather than something to rename: the import goes away when the SDK
grows the piece. The names Element Call owes to the outside stay as they are: the
livekit.livekit_service_url key in config.json, the persisted setting keys
extended-livekit-logs and custom-livekit-url, the Posthog fields
callBackend and callReconnectingCountLivekit, the rageshake field
call_backend, and the translated error strings.
| Slice | Where it shows today |
|---|---|
6. Preview before join(): an SDK API that creates the local tracks for the lobby and hands them out as MediaTracks, so the preview renders like MediaView |
LobbyView (usePreviewTracks), VideoPreview (LocalVideoTrack, facingModeFromLocalTrack) |
7. A transport-neutral processor type on LocalMediaInputs.videoProcessor$, so the blur feature in src/tracks/ no longer types against LiveKit |
TrackProcessorContext, BlurBackgroundTransformer, the TrackProcessor type in api.ts |
| 8. Device enumeration without LiveKit's helpers, on the browser API or an SDK utility | MediaDevices (createMediaDeviceObserver), CallView (Room.getLocalDevices) |
9. A logging option on the client: log level and a sink for the transport's logs, so the rageshake wiring leaves initializer.tsx |
initializer.tsx, the extended-livekit-logs setting |
10. A capabilities field: output selection and background processors, asked of the SDK rather than of LiveKit |
CallFooterViewModel (supportsAudioOutputSelection, supportsBackgroundProcessors) |
11. Element Call's error and analytics types on the SDK's: DisconnectReason and the error classes, and a rename of the two Livekit* error classes on both sides at once |
PosthogEvents (DisconnectReason), errors.ts (ConnectionError, LivekitConnectionError) |
What was copied from src/, and what changed on the way
All of these are gone from src/ as of slice 4; the table records where they went.
Each module came with its tests (sdk/src/utils/test.ts and test-fixtures.ts are
the subset of src/utils/test.ts and test-fixtures.ts they need). In slice 4
Element Call switches to the SDK's copy and deletes its own.
In src/ |
In sdk/src/ |
What changed |
|---|---|---|
state/Behavior.ts, state/ObservableScope.ts, utils/observable.ts |
reactive/ |
as is |
state/SessionBehaviors.ts |
session/SessionBehaviors.ts |
as is |
state/CallViewModel/remoteMembers/Connection.ts |
session/Connection.ts |
throws MatrixRTCErrors; the SDK still subclasses it as ResolvedConnection to keep the JWT answer, to be folded in |
state/CallViewModel/remoteMembers/ConnectionFactory.ts |
session/ConnectionFactory.ts |
the interface only; ECConnectionFactory reads settings and MediaDevices, the SDK has LivekitConnectionFactory |
state/CallViewModel/remoteMembers/ConnectionManager.ts |
session/ConnectionManager.ts |
as is |
state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts |
session/MatrixLivekitMembers.ts |
as is; the SDK maps its result to RTCMember, TaggedParticipant is internal |
state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts, utils/displayname.ts |
session/MatrixMemberMetadata.ts, utils/displayname.ts |
as is |
state/CallViewModel/localMember/HomeserverConnected.ts |
session/HomeserverConnected.ts |
the grace period defaults to defaultSessionTimings instead of Config.get() |
state/CallViewModel/localMember/LocalTransport.ts |
session/LocalTransport.ts |
the type and isLocalTransport; discovery without the custom-url setting |
state/CallViewModel/localMember/RtcTransportAutoDiscovery.ts |
session/RtcTransportAutoDiscovery.ts |
takes fallbackTransportUrl instead of a resolved config |
state/CallViewModel/localMember/LocalMember.ts |
session/LocalMember.ts |
rewritten; only observeSharingScreen$ was copied |
livekit/openIDSFU.ts |
session/openIDSFU.ts |
the delegated leave timeout is a parameter (delayTimeoutMs) instead of Config.get() |
livekit/options.ts |
session/livekitOptions.ts |
buildLiveKitOptions(mediaQuality?) only; getLiveKitOptions and the Config singleton are gone |
e2ee/matrixKeyProvider.ts |
session/MatrixKeyProvider.ts |
as is |
e2ee/e2eeType.ts, the EncryptionSystem type from e2ee/sharedKeyManagement.ts |
encryption.ts |
the React hooks stayed behind |
MatrixRTCMode, the timings and media quality types from config/ConfigOptions.ts |
config.ts |
defaultSessionTimings and defaultMediaQuality replace DEFAULT_CONFIG |
utils/errors.ts |
errors.ts |
MatrixRTCError with code and category replaces ElementCallError; messages are plain English, the host translates by code |
doNetworkOperationWithRetry from utils/matrix.ts |
utils/network.ts |
as is |
Rewritten in the SDK rather than copied, because the originals read settings,
config, analytics or the host bridge: the local member (session/LocalMember.ts),
the publisher (session/Publisher.ts), the connection factory and LiveKit room
options (session/ConnectionFactory.ts), transport discovery
(session/LocalTransport.ts), the join (session/joinJsSdkSession.ts) and the
key provider selection (session/KeyProvider.ts).
What becomes what in createCallViewModel$
Everything below createCallViewModel$ that is not presentation and not in the
call-specific table of the architecture document. Grouped by what it is today in
src/state/CallViewModel/CallViewModel.ts.
| Binding today | In the SDK |
|---|---|
livekitKeyProvider via getE2eeKeyProvider |
internal, keyed off options.encryptionSystem |
matrixRTCMode resolution from Config.get() |
an input; resolution stays in the caller |
matrixRTCSession parameter |
internal: the session manager for the slot, getRoomSession(room) |
memberships$, membershipsAndTransports |
internal; surfaces as RTCMember.transport$ |
ownMembershipIdentity |
internal; surfaces as RTCMember.id |
localTransport$ via getLocalTransport |
internal |
connectionFactory, connectionManager |
internal; surfaces as connectedTransports$ |
remoteMatrixLivekitMembers$ |
remoteMembers$ |
localMembership via createLocalMembership$, enterRTCSession, Publisher, delayId$ |
internal; surfaces as join, leave, status$, connected$, reconnecting$, the screen share fields |
localRtcMembership$, localMatrixLivekitMember$ |
localMember$ |
matrixLivekitMembers$, participantCount$ |
memberCount$ |
matrixRoomMembers$, matrixMemberMetadataStore |
RTCMember.displayName$ / avatarUrl$ |
allConnections$ |
connectedTransports$ |
livekitRoomItems$ |
dropped; audio renders from remoteMembers$[].media$ |
keyRotationSuppressed$ |
same |
sharingScreen$, toggleScreenSharing |
localMember$.sharingScreen$, .toggleScreenSharing |
errors$ and the fatalError$ mapping |
fatalError$, as MatrixRTCError |
join, leave, connected$, reconnecting$, screenShareError$, dismissScreenShareError |
same names on MatrixRTCClient / LocalRTCMember |
callIntent option, updateCallIntent from the camera |
applicationData["m.call.intent"]; the camera sync is still in the SDK, see the open decisions |
Changes to the call sites that the rename brings: localMember$ and
remoteMembers$ replace localMatrixLivekitMember$ and
remoteMatrixLivekitMembers$; connection$ on a member goes away, since the view
model only ever used livekitRoom (for waitingForMedia$ and encryption errors,
both on MemberMedia now) and transport.livekit_service_url (now
transport$.id); connectedTransports$ replaces allConnections$, since the
developer panel only needed which transports exist and whether they are up.
What stays in CallViewModel
Everything in the call-specific table, plus everything that takes userMedia$ or a
UI input and turns it into something to draw:
- Media view models (
createWrappedUserMediaand below), built fromremoteMembers$/localMember$instead of participants. CallNotificationLifecycle.tsin full:ringAttempts$,autoLeave$,ringingMedia$,ringingStatusLocation.leave$(=autoLeave$+hangup()+hostBridge.hangUp$),hangup().screenShares$.- Sounds:
joinSoundEffect$,leaveSoundEffect$,newHandRaised$,newScreenShare$,audibleReactions$,visibleReactions$. handsRaised$,reactions$, until they move ontoRTCMember.- All layout:
spotlightSpeaker$,grid$,spotlight$,pip*,windowMode$,layoutSwitchVm, every*LayoutMedia$,layout$,visibleTiles$,tileStoreGeneration$,overflowing$,spotlightExpanded$. - Chrome:
showHeader$,showFooter$,edgeToEdge$,showModals$,settingsOpen$,showSpotlightIndicators$,showSpeakingIndicators$,showNameTags$, tap and hover subjects. - Audio routing:
earpieceMode$,audioOutputSwitcher$, which readMediaDevices. CallViewModelOptionskeepshostBridge,controlledAudioDevices,header,showControls,hideScreensharing,waitForCallPickup,autoLeaveWhenOthersLeft,windowSize$,toggleScreensharing(test override), and forwardsencryptionSystem,matrixRTCMode,sendNotificationTypeandapplicationDatatoMatrixRTCClientOptions.
The media view models and the participant
The goal of slice 4 on the media side: nothing in Element Call imports
Participant, RemoteParticipant, LocalParticipant, TrackPublication,
Track or TrackReference. This is where the participant flows today, and what
each use becomes.
Where the participant flows today
Start: remoteMatrixLivekitMembers$ in CallViewModel.ts, built by
createRemoteMatrixLivekitMembers$ in
src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts, as
participant: { type: "remote"; value$: Behavior<RemoteParticipant | null> }.
Consumers, in order of depth:
MatrixLivekitMembers.tsmatchesparticipant.identityagainstmembership.rtcBackendIdentity; logsparticipant.sid.CallViewModel.tslivekitRoomItems$readsisLocal,identity, handsconnection.livekitRoomplus identities toLivekitRoomAudioRenderer.CallViewModel.tsuserMedia$passes the wholeTaggedParticipantintocreateWrappedUserMedia.src/state/media/WrappedUserMediaViewModel.ts:observeParticipantEvents(p, TrackPublished, TrackUnpublished, LocalTrackPublished, LocalTrackUnpublished)thenp.isScreenShareEnabledto spawn screen share view models.src/state/media/UserMediaViewModel.ts:observeParticipantMedia(p)formicrophoneTrack?.isMuted/cameraTrack?.isMuted;IsSpeakingChanged→p.isSpeaking;observeRtpStreamStats$(p, source, type).src/state/media/MemberMediaViewModel.ts:observeTrackReference$(p, source)for audio and video;publication.isEncrypted;p.isLocal;p.identitymatched insideRoomEvent.EncryptionErrormessages; inbound RTP frame counters.src/state/media/RemoteUserMediaViewModel.ts,RemoteScreenShareViewModel.ts:p.setVolume(v)/p.setVolume(v, ScreenShareAudio);participant === nullmeans "waiting for media".src/state/observeTrackReference.ts:p.getTrackPublication(source)wrapped as{ participant, publication, source }.src/state/media/observeRtpStreamStats.ts:publication.trackmust beRemoteTrack | LocalTrack, thentrack.getRTCStatsReport().- Views:
src/tile/MediaView.tsxrenders<VideoTrack trackRef={video}>from@livekit/components-react;src/livekit/MatrixAudioRenderer.tsxusesuseTrackson the LiveKit room, filters onref.participant.isLocal/.identity, and callsRemoteAudioTrack.setAudioContext/setWebAudioPlugins.
Local-only extras, same shape but on LocalParticipant
(localMember/LocalMember.ts, Publisher.ts,
src/state/media/LocalUserMediaViewModel.ts): trackPublications,
audioTrackPublications, setScreenShareEnabled, setMicrophoneEnabled,
setCameraEnabled, enableCameraAndMicrophone, isMicrophoneEnabled,
isCameraEnabled, unpublishTrack, and on the camera LocalVideoTrack:
facingModeFromLocalTrack, restartTrack, mediaStreamTrack,
TrackEvent.Restarted / Muted.
Participant API actually used, and what covers it
| LiveKit surface | Used for | Used in | Now |
|---|---|---|---|
identity |
match to rtcBackendIdentity; audio renderer allow-list; E2EE error matching; logs |
1, 2, 6, 10 | matched inside the SDK; RTCMember.id |
sid |
logs only | 1 | SDK logs |
isLocal |
skip local audio; encryption status short-circuit | 2, 6, 10 | MemberMedia.local |
isSpeaking + IsSpeakingChanged |
speaking$ |
5 | MemberMedia.speaking$ |
isScreenShareEnabled + publish events |
spawn screen share VM | 4 | MemberMedia.screenShareEnabled$ |
observeParticipantMedia(p) → microphoneTrack?.isMuted, cameraTrack?.isMuted |
audioEnabled$, videoEnabled$ |
5 | microphone$ / camera$ → muted$ |
getTrackPublication(source) for the four sources |
TrackReference for video, encryption, stats, screen share audio presence |
6, 7, 8 | the four track behaviors |
publication.isEncrypted |
unencryptedWarning$ |
6 | MediaTrack.encrypted$ |
publication.track.getRTCStatsReport() |
audioStreamStats$, videoStreamStats$, "receiving okay" heuristic |
5, 6, 9 | MediaTrack.stats$ |
setVolume(v), setVolume(v, ScreenShareAudio) |
VolumeControls sink |
7 | AudioMediaTrack.setVolume |
TrackReference → <VideoTrack> |
render video | 10 | MediaTrack.attach on a <video> |
room useTracks + RemoteAudioTrack.setAudioContext/setWebAudioPlugins |
render audio with earpiece pan/gain | 10 | one <audio> per audio track, setAudioContext |
room RoomEvent.EncryptionError with identity + "MissingKey"/"InvalidKey" |
encryptionStatus$ |
6 | MemberMedia.encryptionError$ |
Everything else in the media view models (observeSpeaker$, VolumeControls,
SortingBin, hand raise, reactions, display name, avatar, aspect ratio) already
works on plain booleans and observables and does not change.
How the call sites map onto MemberMedia
audioEnabled$=microphone$ → track?.muted$ === false;videoEnabled$likewise fromcamera$.unencryptedWarning$= any of the four tracks withencrypted$ === false.encryptionStatus$=encryptionError$plus a "frames decoded" check oncamera$.stats$/microphone$.stats$; same logic, no participant.screenShares$inWrappedUserMediaViewModelkeys offscreenShareEnabled$.VolumeControlssink =microphone$.value?.setVolume/screenShareAudio$.value?.setVolume.waitingForMedia$= transport present andmedia === null, unchanged semantics;TaggedParticipantgoes away.video$: Behavior<TrackReference | undefined>onBaseMemberMediaViewModelbecomesBehavior<VideoMediaTrack | undefined>;MediaViewreplaces<VideoTrack trackRef>with a<video>plusattach/detachin an effect, keeping theonResizecallback for the aspect ratio.VideoPreview.tsxalready follows this pattern.LivekitRoomAudioRendererstops usinguseTrackson the room and renders one<audio>permicrophone$/screenShareAudio$acrossremoteMembers$. That removes the "no matching matrix call member" warning path, since an unmatched track can no longer be seen.- The
showConnectionStatssetting gates the subscription tostats$in the view model; the SDK polls only while subscribed. - Stories and tests mock
attachby settingsrcObjectfromcanvas.captureStream()on the element, or by doing nothing and asserting the call.
Packaging, as it was planned
sdk/ mirrors component/ file for file. The table that drove the scaffold, kept
for the record:
component/ |
sdk/ |
Differences |
|---|---|---|
package.json (@element-hq/element-call-component) |
package.json (@element-hq/matrixrtc-sdk) |
peers: matrix-js-sdk, livekit-client, rxjs. No React, no Compound. exports: "." only. No ./style.css, no sideEffects. Same prepare. |
pnpm-workspace.yaml (marker, autoInstallPeers: false) |
same | none |
tsconfig.build.json |
same | include: ["./index.ts"] |
index.tsx + api.ts |
index.ts + src/api.ts |
one entry; no separate types entry until a host asks |
host.ts, localization.ts, ElementCall.module.css, build/scopeStylesToRoot.ts |
none | no host bridge, no i18n, no styles |
dev/ on port 3001 |
dev/ on port 3002 via vite-sdk-dev.config.ts |
plain DOM |
vite-component.config.ts |
vite-sdk.config.ts |
vitePluginsConfig({ mode, html: false }), outDir: "sdk/dist", externals = js-sdk and LiveKit entries plus rxjs |
root scripts build:component* |
build:sdk* |
build:sdk:types = tsc -p sdk/tsconfig.build.json |
root tsconfig.json include |
adds ./sdk/**/*.ts |
|
scripts/check-component-externals.mjs |
parameterised by config; sdk added to the sources, sdk/dev excluded |
|
knip.ts |
vite-sdk.config.ts, vite-sdk-dev.config.ts, sdk/index.ts as entry |
|
vitest.config.ts unit project |
adds sdk/**/*.test.ts |
|
package.json lint:oxlint paths |
adds sdk |
Why not a pnpm workspace package yet: the root has no packages: in
pnpm-workspace.yaml, so a workspace split means restructuring the app, the
component's prepare trick, CI and the lockfile at the same time as moving the
code, and dev-time HMR across workspace packages needs the same source alias
anyway. It is the right end state if the SDK gets a release cadence of its own.
Reference: the CallViewModel public API before the migration
Source: src/state/CallViewModel/CallViewModel.ts. Only consumer outside tests:
src/room/InCallView.tsx. A snapshot, so that the split can be checked against
what was there.
Factory
createCallViewModel$(scope, matrixRTCSession, matrixRoom, mediaDevices, muteStates, options, handsRaisedSubject$, reactionsSubject$, trackProcessorState$): CallViewModel
scope: ObservableScope: ending the scope is one of the two ways to leave.matrixRTCSession: MatrixRTCSession,matrixRoom: MatrixRoom: one room per view model.mediaDevices: MediaDevices,muteStates: MuteStatesoptions: CallViewModelOptions(below)handsRaisedSubject$: Observable<Record<string, RaisedHandInfo>>reactionsSubject$: Observable<Record<string, ReactionInfo>>trackProcessorState$: Behavior<ProcessorState>
Helper: callViewModelOptionsFromParams(params: UrlParams) picks
controlledAudioDevices, header, showControls, hideScreensharing,
sendNotificationType, callIntent out of the URL params. autoLeaveWhenOthersLeft
and waitForCallPickup are left to the caller.
CallViewModelOptions
| Field | Type | Note |
|---|---|---|
encryptionSystem |
EncryptionSystem |
required |
hostBridge? |
HostBridge |
host can hang up / observe join+leave; default none |
controlledAudioDevices? |
boolean |
host controls audio output devices |
header? |
HeaderStyle |
default Standard |
showControls? |
boolean |
default true |
hideScreensharing? |
boolean |
default false |
sendNotificationType? |
RTCNotificationType |
notification sent on join |
callIntent? |
RTCCallIntent |
|
autoLeaveWhenOthersLeft? |
boolean |
|
waitForCallPickup? |
boolean |
telephone-style ringing UI |
windowSize$ |
Behavior<{width; height}> |
required; drives WindowMode |
matrixRTCMode? |
MatrixRTCMode |
MatrixRTC version / compat mode |
livekitRoomFactory?, connectionState$?, localTransport?, connectionFactory?, toggleScreensharing? |
test overrides |
The interface, grouped
Lifecycle / connection (to the client): join(), leave(), hangup(),
leave$: Observable<"user" | AutoLeaveReason>, autoLeave$, connected$,
reconnecting$, allConnections$: Behavior<ConnectionManagerData>,
fatalError$: Behavior<ElementCallError | null>, keyRotationSuppressed$.
Members / participants (to the client): participantCount$,
localMatrixLivekitMember$, remoteMatrixLivekitMembers$, livekitRoomItems$,
handsRaised$, reactions$ (keyed by ${userId}:${deviceId}).
Screen sharing (capability to the client, error UI stays): toggleScreenSharing,
sharingScreen$, screenShareError$, dismissScreenShareError().
Ringing (stays): ringingVm$, ringingStatusLocation.
Audio routing (stays): earpieceMode$, audioOutputSwitcher$.
Sounds and transient events (stay): joinSoundEffect$, leaveSoundEffect$,
newHandRaised$, newScreenShare$, audibleReactions$, visibleReactions$,
MAX_PARTICIPANT_COUNT_FOR_SOUND = 8, THROTTLE_SOUND_EFFECT_MS = 500.
Layout and tiles (stay): layout$, tileStoreGeneration$,
showSpotlightIndicators$, showSpeakingIndicators$, showNameTags$,
spotlightExpanded$, toggleSpotlightExpanded$, layoutSwitchVm$.
Chrome visibility and UI interaction (stay): showHeader$, showFooter$,
edgeToEdge$, overflowing$, showModals$, settingsOpen$, setSettingsOpen$,
tapScreen(), tapControls(), hoverScreen(), unhoverScreen().
Pending rename noted in the source
A TODO above CallViewModelOptions proposes: member/membership → rtcMember,
participant → livekitParticipant, matrixLivekitItem → callMember; and in the
js-sdk callMembership → rtcMembership. The SDK's vocabulary (architecture
document, "Vocabulary") settles the first three; the js-sdk rename is still open.
Build targets before the migration
vite-sdk.config.ts (pnpm build:sdk), vite-component.config.ts,
vite-embedded.config.ts, vite.config.ts. The old SDK target
(sdk-target-based-on-call-view-model/) is the one whose dependency direction the
migration flips. It is in production use, so slice 5 rebuilt it on the SDK instead of
removing it: createMatrixRTCSdk keeps its signature, join/leave/stop,
connected$, data$ ({ rtcBackendIdentity, data } on the matrixRTC topic),
sendData, sendRoomMessage, localMember$ and remoteMembers$ keep their shapes.
The one break is inside the member objects: connection and participant are
always null now, because the SDK does not hand out LiveKit objects; membership
is unchanged. The bundle is removed once its consumers use the package directly.