# MatrixRTC SDK architecture `@element-hq/matrixrtc-sdk` is the model under Element Call, on its own: one `MatrixRTCClient` per room that joins and leaves the MatrixRTC session, discovers and connects to the media transports, publishes the local media, distributes the encryption keys, and hands out every member with its media as observables. It has no UI, reads no configuration and no settings, and never names LiveKit in its interface. Element Call becomes one consumer of it: `CallViewModel` keeps what makes a session a _call_ (ringing, auto-leave, layout, sounds, reactions, settings, the host bridge) and takes everything else from the client. The dependency direction is therefore `Element Call → sdk`, never the reverse. The migration from today's `CallViewModel` to that shape is in [`SdkMigration.md`](./SdkMigration.md). ## The architecture ``` host application (Element Call, a whiteboard, watch-together, …) │ │ createMatrixRTCClient(scope, matrixClient, room, localMedia, options) ▼ ┌──────────────────────────────────────────────────────────────────────────────┐ │ MatrixRTCClient │ │ │ │ public API, all Behaviors: │ │ join() leave() status$ connected$ reconnecting$ fatalError$ │ │ localMember$ remoteMembers$ memberCount$ │ │ keyRotationSuppressed$ connectedTransports$ │ │ │ │ per member RTCMember { id userId deviceId membership$ displayName$ │ │ avatarUrl$ transport$ media$ } │ │ per media MemberMedia { tracks$ encryptionError$ } │ │ per track MediaTrack { source kind muted$ encrypted$ stats$ attach() }│ │ │ │ ┌──────────────────────────┐ memberships ┌───────────────────────────┐ │ │ │ MatrixRTC core │ ───────────────▶ │ transports │ │ │ │ (js-sdk MatrixRTCSession,│ transports │ │ │ │ │ later the rust-rtc │ │ ConnectionManager │ │ │ │ crate behind a bridge) │ │ one Connection per │ │ │ │ │ │ transport url │ │ │ │ memberships from room │ │ Connection │ │ │ │ state and sticky events│ │ OpenID → JWT → LiveKit │ │ │ │ own membership, delayed │ │ room, participants, │ │ │ │ leave, retries │ media keys │ tracks │ │ │ │ media keys over │ ───────────────▶ │ Publisher │ │ │ │ to-device messages │ (key provider) │ local tracks, upstream │ │ │ └──────────────────────────┘ └───────────────────────────┘ │ │ │ │ │ │ └──── RTCMember = membership + participant ─┘ │ │ matched on rtcBackendIdentity = LiveKit identity │ └──────────────────────────────────────────────────────────────────────────────┘ │ │ ▼ ▼ MatrixClient (sync, room state, to-device, OpenID) LiveKit SFU ``` The left box is MatrixRTC: who is in the session, with which transport, and the keys. The right box is media: a connection to every transport any member advertises, and our own publication on the one we advertise. The client is the join of the two: a member exists once its membership does, and gets its media once a participant with the matching identity shows up on the member's transport. ### How Element Call uses it ``` Element Call, as app, widget or component ┌────────────────────────────────────────────────────────────────┐ │ views: InCallView, tiles, header, footer, settings │ │ ▲ Behaviors │ │ CallViewModel │ │ layout, ringing and notifications, auto-leave, sounds, │ │ reactions, hand raise, settings, audio routing, host bridge │ │ ▲ status$, localMember$, remoteMembers$, MemberMedia │ │ MatrixRTCClient @element-hq/matrixrtc-sdk │ └────────────────────────────────────────────────────────────────┘ │ MatrixClient + Room │ livekit-client ▼ ▼ homeserver SFU ``` All three modes already hold a `MatrixClient` and a `Room` by the time a call starts: standalone after `useLoadGroupCall`, the component from the host's `client` prop, the widget from `createRoomWidgetClient`. The view model passes them on and builds its media view models from `RTCMember` and `MemberMedia` instead of from LiveKit participants. ### Joining, in order ``` host MatrixRTCClient homeserver / JWT service LiveKit SFU │ create ───▶│ │ │ │ │ GET rtc/transports ──────────────▶│ │ │ │◀── preferred transport ───────────│ │ │ │ OpenID token, then /sfu/get ─────▶│ │ │ │◀── sfu url, jwt, alias, identity ─│ = resolved$ of the │ │ │ │ local transport │ │ │ connect(url, jwt) ───────────────────────────────────────▶│ │ join() ───▶│ │ │ │ │ membership state event + delayed leave ──▶│ │ │ │ create tracks, resume upstream ──────────────────────────▶│ │ │ media key to every other member (to-device) ──▶│ │ │ │◀── other memberships (sync) ──────│◀── participants ────────│ │◀─ status$ "connected", localMember$, remoteMembers$ with media$ ──────────│ ``` The transport is resolved and connected before `join()`, so that the membership can name it and the first frames go out as soon as the state event is sent. Until `join()` the tracks exist but their upstream is paused: a host can show a preview without anyone hearing it. `leave()` pauses the upstream again and sends the leave. ## Vocabulary | Word | Meaning here | Elsewhere | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | client | a `MatrixRTCClient`: one room, one slot, one local member | `MatrixClient` is always written out | | session | the MatrixRTC session in the room: the set of memberships. Only used for the js-sdk object, which stays inside the SDK as `JsSdkRTCSession` | MSC4143 | | membership | one device's MatrixRTC state event, the js-sdk `CallMembership`. The js-sdk name is kept until it renames it | | | member | an `RTCMember`: a membership plus what the SDK derives from it (display name, transport, media) | Element Call's "matrixLivekitMember" | | participant | a LiveKit participant, the media side of a member. Never leaves the SDK | | | transport | where media is exchanged: a LiveKit service url today, described by `TransportMetadata` | MSC4143 "focus" | | application, slot | what the session is for (`m.call`) and which one of them in the room (`ROOM`) | MSC4143 | Names use `RTC` in capitals, as the js-sdk does: `MatrixRTCClient`, `RTCMember`, `matrixRTCMode`. ## Reactive primitives The whole public API is made of `Behavior`s: an rxjs observable that always holds a current value (`.value`) and replays it on subscription. A consumer subscribes to what it renders and reads `.value` where it needs the present state. Everything is bound to an `ObservableScope` the host creates; ending the scope ends the client, its connections and its tracks. Both come from the SDK (`Behavior`, `constant`, `ObservableScope`); the host needs rxjs and nothing else. ## What a host must provide Found the hard way while building the harness; each one fails in a way that looks like an SDK bug. - **The room the sync loop maintains.** `client.getRoom(roomId)` once the join has synced. `joinRoom` returns a detached copy for a room joined just now, and a client built on it never sees a member. - **Power to send the membership event.** Every member needs to write `org.matrix.msc3401.call.member` (and the sticky event in Matrix 2.0 mode). A plain room only lets its creator send state; Element Call's rooms set the level to 0. - **An encrypted room for per-participant keys.** Media keys travel in encrypted to-device messages, which the crypto only delivers to devices it tracks, which are the members of encrypted rooms. In an unencrypted room the key never arrives and every remote track decodes nothing. - **A logged-in, syncing client with crypto set up**, and the transport advertised by the homeserver (`rtc/transports`). The SDK has no fallback url of its own. ## The client: `MatrixRTCClient` Rule of thumb for what belongs here: if it would be the same for a different application on top of the same MatrixRTC session, it is in the client. If it is specific to calls, or depends on the window, a setting, a platform, or a React context, it is in the view model. ### Creating one ```ts import { type MatrixClient, type Room } from "matrix-js-sdk"; import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc"; export interface MatrixRTCClientOptions { encryptionSystem: EncryptionSystem; /** Resolved by the host; the SDK reads neither config.json nor settings. */ matrixRTCMode: MatrixRTCMode; /** * MSC4075 notification sent with the join. A parameter of the MatrixRTC * join itself, so it is here even though it is named after calls; reacting * to a notification (ringing, timeouts, declines) is the application's job. */ sendNotificationType?: RTCNotificationType; /** The application the session belongs to, as named in the membership. Default `m.call`. */ application?: string; /** The application's slot in the room. Default `ROOM`. */ slot?: string; /** * Whatever the application wants to say about itself in the membership. * Opaque to the SDK. Today the js-sdk carries one key, `m.call.intent` * (`"audio"` or `"video"`); anything else is dropped until it can. */ applicationData?: Record; /** Session timings the host has configured; the defaults otherwise. */ timings?: Partial; /** Limits on what is published; LiveKit's defaults otherwise. */ mediaQuality?: MediaQuality; /** Use this transport instead of asking the homeserver. */ transportUrl?: string; /** Use this transport when the homeserver advertises none. */ fallbackTransportUrl?: string; } /** * One thing to publish: a source, where to capture it from and how to encode * it. Where the device and the settings are left out, the browser's and * LiveKit's defaults apply. */ export type PublishRequest = | { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings } | { source: "camera"; deviceId?: string; /** Background blur and the like. */ processor?: TrackProcessor; capture?: VideoCaptureSettings; } | { source: "screenShare"; /** Whether to capture the screen's audio too. Default true. */ audio?: boolean; capture?: VideoCaptureSettings; }; /** * Device enumeration, permission prompts and the lobby preview stay with the * host. */ export interface LocalMediaInputs { /** Published at the join; `publish` on the local member adds to it from then on. */ publish: PublishRequest[]; /** Undefined where the host routes audio itself, or to leave the browser's choice. */ audioOutputDeviceId$: Behavior; } /** * Takes the whole MatrixClient, not a narrow pick, and finds the MatrixRTC * session itself. The js-sdk is the MatrixRTC implementation today; when the * rust-rtc crate replaces it, the SDK has to bridge the crate to the js-sdk * client (events, state, to-device, delayed events, OpenID), and only the SDK * knows what that bridge needs. Holding the client keeps that change inside * the SDK. The js-sdk session is not part of the public API. * * The room has to be the one the client's sync loop maintains; see "What a * host must provide". */ export function createMatrixRTCClient( scope: ObservableScope, client: MatrixClient, room: Room, localMedia: LocalMediaInputs, options: MatrixRTCClientOptions, ): MatrixRTCClient; ``` ### The client ```ts export type ConnectionStatus = | "waitingForTransport" | "connecting" | "connected" | "reconnecting" | "disconnected"; /** Why the client is not connected: the first failing of its three links. */ export type DisconnectReason = "sync" | "membership" | "probablyLeft" | "media"; /** * An error raised by the client. `code` and `category` say what went wrong in * a form a host can translate; `cause` holds the backend's error. */ export class MatrixRTCError extends Error { code: ErrorCode; category: ErrorCategory; } export interface MatrixRTCClient { join(): void; leave(): void; /** The local member's state machine, collapsed to what a host shows. */ status$: Behavior; /** Connected to the homeserver, the session and the local transport, all three. */ connected$: Behavior; /** Connected once, and currently not. */ reconnecting$: Behavior; /** Null while connected, the first failing link otherwise. */ disconnectReason$: Behavior; /** * A transport, Matrix or connection error that stops the session. Null * while fine. A failed publication is not fatal: the member can still * receive, so it stays on the local member's media. */ fatalError$: Behavior; /** Null until our own membership has been seen in the room. */ localMember$: Behavior; remoteMembers$: Behavior; /** `remoteMembers.length`, plus one for the local member once it exists. One per membership, so one user may count several times. */ memberCount$: Behavior; /** * Whether the session has grown large enough that MatrixRTC has stopped * rotating the media encryption key. */ keyRotationSuppressed$: Behavior; /** * Transports the client currently holds a live connection to. The * connections themselves stay internal; this is what a developer panel * needs. */ connectedTransports$: Behavior; /** * Sends a short text to every member on the local transport, over a * reliable data channel beside the media. A packet holds about 15 KiB; * anything larger belongs in a room event. Rejects while the local * transport is not connected. Encrypted on the wire like the media, but * not end to end with the media key. */ sendData(topic: string, text: string): Promise; /** * What remote members sent with `sendData`, on every transport the client * is connected to. A message from an identity that is not a member is * dropped, so a host only ever hears from attested members. */ data$: Observable; } /** One `sendData` call as it arrives at a remote member. */ export interface DataMessage { member: RemoteRTCMember; topic: string; text: string; } ``` ### Members ```ts import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; export interface RTCMember { local: boolean; /** * The identity the media backend knows this member by: the membership's * `rtcBackendIdentity`, which is `${userId}:${deviceId}` before sticky * events and a hash of the member id in Matrix 2.0 mode. */ id: string; userId: string; deviceId: string; membership$: Behavior; /** * Matrix room state rather than MatrixRTC, but the SDK already holds the * room and every consumer wants them next to the membership. Disambiguated * among the members of the session. */ displayName$: Behavior; avatarUrl$: Behavior; /** Which transport this member is on; undefined when the membership names none. */ transport$: Behavior; /** * Null while the member has a transport but no participant has shown up on * it yet ("waiting for media"). Hand raise and reactions are room events * keyed by member and stay with the host for now; they are the obvious next * fields here. */ media$: Behavior; } export interface RemoteRTCMember extends RTCMember { local: false; } export interface LocalRTCMember extends RTCMember { local: true; media$: Behavior; /** * Publishes a source and resolves with its track once it is in `tracks$`. * Before the transport is connected the request is remembered and applied * once it is. Rejects where the device could not be used, including the * user closing the picker. One publication per source: publishing a source * again unmutes it. */ publish( request: PublishRequest, ): Promise; /** Removes one of our tracks; a screen share takes its audio with it. */ unpublish(id: string): Promise; } ``` ### Transports ```ts import { type Transport } from "matrix-js-sdk/lib/matrixrtc"; /** * One transport advertised in a membership. Transport independent: `type` and * `id` are all the SDK needs; `raw` and `resolved$` are there for a * backend-specific developer panel and for connection diagnostics. One object * per transport, however many memberships name it. */ export interface TransportMetadata { /** `"livekit"` today. */ type: string; /** Stable key, unique per transport in the session. For LiveKit, the service url. */ id: string; /** The transport object as it appears in the membership, e.g. `{ type, livekit_service_url }`. */ raw: Transport; /** * What the backend had to fetch before it could connect. Undefined until * the connection has resolved it, and again after the connection stops or * fails. The local transport resolves before the join, since the membership * needs it; remote ones when their connection starts. */ resolved$: Behavior; } export type ResolvedTransport = | { type: "livekit"; /** The SFU websocket url, as opposed to the JWT service url in `raw`. */ url: string; /** A secret: fit for a developer panel, never for a log line. */ token: string; roomAlias: string; identity: string; } | { type: string; [key: string]: unknown }; ``` ### Call-specific things kept out of the client Checked against `CallViewModel` and its options. These stay in Element Call because they only make sense for a call: | Today | Why it is call-specific | Where it lives instead | | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `autoLeave$` / `AutoLeaveReason` (`"allOthersLeft" \| "timeout" \| "decline"`) | hanging up because the other side declined or everyone left is call etiquette, not session semantics | `CallViewModel.leave$`, built from `remoteMembers$` and the room timeline | | `ringAttempts$`, `createCallNotificationLifecycle$`, `createSentCallNotification$`, `createReceivedDecline$` | ringing, pickup timeouts and decline events are MSC4075 call-notify UX | `src/state/CallViewModel/CallNotificationLifecycle.ts`, fed by `remoteMembers$` and the Matrix room | | `waitForCallPickup`, `autoLeaveWhenOthersLeft` options | inputs to the above | `CallViewModelOptions` | | `hideScreensharing` option | a UI policy; the session can always share a screen if the platform can | `CallViewModel` offers no screen share control | | `ringingVm$`, `ringingStatusLocation` | view models | `CallViewModel` | | `hostBridge.hangUp$`, `hangup()`, `userHangup$` | widget / host integration | `CallViewModel` | | `ElementCallError` | carries Element Call translation keys | the view model wraps `MatrixRTCError`, reading `cause` | | `participantCount$` sound limits, join/leave sounds | call UX | `CallViewModel` | | `callIntent` | what a membership says about the call | `applicationData["m.call.intent"]`, set by the host | Kept despite the name: `sendNotificationType`, because the js-sdk join sends the notification as part of entering the session. Only _reacting_ to it is call-specific. ## Media: `MemberMedia` Every member has media. Today, in Element Call, that media _is_ a `livekit-client` participant, handed straight through to the view models. In the SDK the participant is wrapped in a `MemberMedia`, so that nothing outside the SDK imports `Participant`, `TrackPublication`, `Track` or `TrackReference`. The adapter in `sdk/src/media/` is the only place that reads a participant. ```ts export type MediaSource = | "microphone" | "camera" | "screenShare" | "screenShareAudio" /** Published without a source; the application knows what it is. */ | "unknown"; export type MediaStreamStats = RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined; /** One published track of a member. */ export interface MediaTrack { source: MediaSource; kind: "audio" | "video"; /** Stable for the life of the track. */ id: string; muted$: Behavior; /** False when the SFU reports the track as unencrypted. */ encrypted$: Behavior; /** * Inbound statistics for a remote track, outbound for a local one. Polled * once a second, but only while someone is subscribed, so a statistics * panel costs nothing while closed. */ stats$: Behavior; /** * Rendering. The view hands its