Skip to content

Conference

Multi-party conferences on an external SFU: RoomKit orchestrates and joins as a bot participant; the SFU owns the media plane (RFC §12.10). See the Conference guide for concepts and wiring.

Channel

ConferenceChannel

ConferenceChannel(channel_id, *, backend, stt=None, tts=None, realtime=None, pipeline=None, interruption=None, recording=None, recorder=None, bot_identity='roomkit', bot_grants=None, default_grants=None, e2ee=False, close_room_on_detach=False, speak_text_events=False, close_providers=True, max_queued_frames=100, identity_address_keys=None, identity_trusts_unasserted_metadata=False)

Bases: ConferenceAttachmentMixin, ConferenceSessionMixin, ConferenceAccessMixin, ConferenceSubscriptionMixin, ConferencePlugMixin, ConferenceLanesMixin, FrameworkAwareChannel, Channel

Multi-party conference channel backed by an external SFU.

Example::

channel = ConferenceChannel("conf", backend=backend, stt=stt, tts=tts)
kit.register_channel(channel)
await kit.attach_channel("room-1", "conf")

access = await channel.mint_access("room-1", "p-alice")

sender_is_participant class-attribute instance-attribute

sender_is_participant = True

A conference utterance is attributed to a participant, not to an address.

What a lane puts on sender_id is the identity the track was published under, which is the Room Participant.id this channel keeps its roster on (RFC §12.10.2 rule 2). Resolving it would be resolving on the backend's opaque identity, which rule 3 rules out for exactly the reason it fails here: no resolver can match it, so every utterance comes back UNKNOWN, and a hook written to refuse unknown senders makes the transcripts of a participant the framework identified on arrival disappear.

A conference resolves once, when a participant arrives and its provider's address is still there to resolve (:mod:roomkit.channels._conference_identity). Speaking again asks nothing new.

active_lanes property

active_lanes

Processing lanes currently running, keyed by track.

capabilities

capabilities()

Audio, and only audio — see the module docstring for why.

The binding copies this at attachment and it is what routing and transcoding read, so it is the one place an integrator can find out what a conference carries without waiting for frames that never come.

info

info()

What the bot is, where it is, and what it is doing with the media.

Disclosure rules for a transcribing bot differ by jurisdiction, so the RFC mandates no announcement and instead requires that an integrator be able to ask (§17.7): the bot's identity and hidden status, and whether speech recognition, vision or recording is running — at any time, not only when the channel was configured.

Which is why the answer is per conference, under rooms. A channel serving three rooms is configured once and behaves differently in each: the bot may be in one and not another, and a binding closed to Access.NONE stops collection in that room alone. "Is this meeting being transcribed" is the question a disclosure obligation asks, and a channel-wide flag cannot answer it — stt_configured says only what the channel was built with.

set_framework

set_framework(framework)

Wire the channel to the framework.

Attaching does not come through here: on_room_attached and on_room_detached are the Channel contract and the framework awaits them itself — see :mod:roomkit.channels._conference_attachment.

may_interrupt

may_interrupt(participant_id)

Whether a participant is allowed to interrupt the bot.

deliver async

deliver(event, binding, context)

Speak an event into the conference on the single bot track.

What a conference is willing to read aloud is the channel's decision, and it is the whole of what happens here: a meeting is not a place to recite orchestration metadata, nor every message arriving from another channel. Synthesis, publication, and the barge-in that can stop it belong to ConferenceVoice.

close async

close()

Run the channel's one shutdown, or join the one already running.

There is one logical shutdown per channel (RFC 12.10.4): concurrent callers await the same shielded task, a caller cancelled mid-wait abandons only its own wait, and once the shutdown reaches its terminal result every later call replays that result — an immediate return after a success, the same ConferenceCloseError after a failure — rather than running the steps again.

Backend

ConferenceBackend

ConferenceBackend()

Bases: ABC

Abstract base class for SFU conference backends.

The backend does three things: it administers conference rooms, it mints the credentials human clients use to join the SFU directly, and it gives the framework one bot connection through which to subscribe to tracks and publish the AI's voice.

What it deliberately does not do is carry human-to-human media. Clients connect to the SFU themselves; the framework never proxies their signalling or their packets.

Example::

backend = MyConferenceBackend()
backend.on_track_audio(handle_audio)

await backend.ensure_room("room-1")
access = await backend.mint_access("room-1", "p-alice", ConferenceGrants())
bot = await backend.join_as_bot("room-1", "roomkit", ConferenceGrants.observer())

name abstractmethod property

name

Backend identifier, used in logs and configuration errors.

capabilities abstractmethod property

capabilities

What this backend supports.

The framework refuses configurations the backend cannot honour rather than discovering it at runtime — egress recording without EGRESS_RECORDING, remote unmute without REMOTE_UNMUTE, bot video without VIDEO_PUBLISH.

ensure_room abstractmethod async

ensure_room(room_id, metadata=None, e2ee=False)

Create the conference room if it does not exist.

Idempotent: called whenever a channel attaches, including for a room that is already conferring.

Parameters:

Name Type Description Default
room_id str

Room this conference belongs to, one-to-one.

required
metadata dict[str, Any] | None

Provider-specific room configuration.

None
e2ee bool

Request end-to-end encryption. Must raise a configuration error when the backend lacks ConferenceCapability.E2EE. With E2EE active the bot receives ciphertext, so transcription, vision and framework recording are unavailable unless the bot is admitted as a key holder.

False

close_room abstractmethod async

close_room(room_id)

Tear down the conference room and disconnect its participants.

mint_access abstractmethod async

mint_access(room_id, participant_id, grants, *, display_name=None)

Mint credentials for a participant to join the SFU directly.

The framework passes its own Participant.id. A backend whose SFU cannot carry a caller-supplied identity must keep the mapping itself and translate at this boundary, because every attribution guarantee downstream depends on participant_id meaning the same thing on both sides.

display_name is presentation, never identity: a backend that can carry it should put it in the credential so the SFU's own clients render the participant as the room named them, and report it back on ConferenceParticipant.display_name — which is what returns names to a roster rebuilt from the join's catch-up after a restart (RFC 12.10.3). One that cannot simply ignores it; attribution never depends on it.

The returned credential is opaque to the framework: the integrator hands it to its client application, and the provider's SDK consumes it.

list_participants abstractmethod async

list_participants(room_id)

Return the participants currently connected to the conference.

remove_participant abstractmethod async

remove_participant(room_id, participant_id)

Disconnect a participant from the conference.

mute_track abstractmethod async

mute_track(room_id, track_id)

Mute a published track as a moderator.

Always available, unlike unmuting.

unmute_track abstractmethod async

unmute_track(room_id, track_id)

Unmute a published track as a moderator.

Requires ConferenceCapability.REMOTE_UNMUTE, and must raise a configuration error without it rather than failing silently or appearing to succeed. Unmuting someone else's microphone is a privacy decision rather than a technical one, and SFUs commonly refuse it unless explicitly enabled server-side.

join_as_bot abstractmethod async

join_as_bot(room_id, identity, grants)

Connect the framework to the conference as a participant.

Grants are applied to the bot session rather than assumed: a speaking bot needs publish_audio, while an observer is subscribe-only and hidden.

The backend must not auto-subscribe the bot to anything. The framework's subscription set is authoritative, and it is expressed through subscribe_track() alone.

leave abstractmethod async

leave(bot)

Disconnect the bot session, leaving the conference running.

update_bot_grants async

update_bot_grants(bot, grants)

Replace a connected session's grants in place. Requires BOT_GRANT_UPDATE.

Same session, same connection, subscriptions and callbacks undisturbed — the SFU changes what the session may do, which is what lets a hot-plugged voice speak without a re-join (RFC 12.10.4). grants is the session's whole grant set, not a delta.

Concrete rather than abstract so that a backend written before this call existed keeps working: the channel only calls it on a backend whose capabilities declare BOT_GRANT_UPDATE, and falls back to a re-join otherwise. This default is the contract's required refusal — a configuration error rather than silence or apparent success (RFC 12.10.3).

subscribe_track abstractmethod async

subscribe_track(bot, track_id)

Start delivering a track's frames to the bot.

The only way frames begin arriving. A backend must not deliver on_track_audio or on_track_video for a track that was never passed here — selective subscription is what keeps unconsumed video out of the framework process entirely.

unsubscribe_track abstractmethod async

unsubscribe_track(bot, track_id)

Stop delivering a track's frames to the bot.

publish_audio abstractmethod async

publish_audio(bot, chunk)

Publish decoded PCM audio on the bot's track.

AudioChunk is the outbound stream type: it names its own encoding in format and marks the end of an utterance with is_final. Implementations must reject a chunk that is not PCM rather than forwarding it, because encoding belongs to the backend — a caller choosing the wire format would defeat this interface.

One bot track, heard by every participant. Targeted per-participant audio is not supported: the AI is synthesized once and published once.

What that single track guarantees a backend, in return: an utterance arrives contiguously — the framework never interleaves two — and ends on a chunk whose is_final is set, so is_final is a boundary an implementation can rely on rather than a hint. An utterance a participant cut short ends the same way, and that closing chunk may carry no audio at all (data=b""): there is nothing left to play, only an end to declare. It is a boundary, not a flush — an implementation must not discard queued audio on it, or a synthesizer whose real ending is queued behind an empty final chunk would lose it. What silences the room on a barge-in is :meth:stop_playback.

With one exception, which is the end of the session rather than the end of an utterance. An utterance the channel abandons because it has left the conference is not closed: the session the terminal chunk would name is on its way out, so publishing into it would race the :meth:leave behind it. Nothing in band can carry that boundary — a process that crashed or a connection that dropped announces nothing either — so an implementation must take :meth:leave, and a session disconnecting by any other means, as ending whatever utterance was in flight on it (RFC section 12.10.4).

stop_playback abstractmethod async

stop_playback(bot)

Discard the bot's queued, unplayed audio: the barge-in gesture.

The one call that says the room asked for silence now, rather than at the end of whatever the transport has buffered. An implementation must immediately discard the audio it accepted through :meth:publish_audio but has not yet delivered for playout; what has already left it is beyond recall, and the smaller its local queue the smaller that residue.

This does not end the utterance. is_final remains the only boundary: the closing chunk still follows, and must be accepted after the stop. The track stays usable — the next utterance publishes normally.

A stop for a session this backend no longer holds is a no-op, not an error: the silence the call asks for is already true of a session that is gone. Deliberately unlike :meth:publish_audio, which refuses media for a session that is out — a refusal there protects a track from writes, and there is no track left to protect (RFC section 12.10.3).

publish_video abstractmethod async

publish_video(bot, frame)

Publish a raw video frame on the bot's track.

Requires ConferenceCapability.VIDEO_PUBLISH.

The frame must be raw (frame.is_raw). The backend owns encoding, symmetrically with the decoding it owns inbound: requiring an encoded frame would mean the framework choosing a codec the SFU accepts, which is exactly what this interface exists to avoid.

close abstractmethod async

close()

Release backend resources. Idempotent.

on_participant_joined

on_participant_joined(callback)

Register a callback for participants joining the media session.

on_participant_left

on_participant_left(callback)

Register a callback for participants leaving the media session.

on_track_published

on_track_published(callback)

Register a callback for tracks being published.

on_track_unpublished

on_track_unpublished(callback)

Register a callback for tracks being unpublished.

on_track_muted

on_track_muted(callback)

Register a callback for a publisher muting their track.

The track record's muted flag is already updated when the callback runs. Presence, not media: a muted VIDEO track is how most clients express "camera off" (RFC 12.10.3).

on_track_unmuted

on_track_unmuted(callback)

Register a callback for a publisher unmuting their track.

on_track_audio

on_track_audio(callback)

Register a callback for decoded audio from subscribed tracks.

on_track_video

on_track_video(callback)

Register a callback for video from subscribed tracks.

on_active_speaker_changed

on_active_speaker_changed(callback)

Register a callback for dominant-speaker changes.

on_connection_quality

on_connection_quality(callback)

Register a callback for per-participant quality reports.

on_bot_session_ended

on_bot_session_ended(callback)

Register a callback for the bot's session ending without a leave().

Reported when the backend observes the end — a dropped connection, an eviction, the room deleted underneath the bot. A backend that cannot observe the loss reports nothing, and knowingly inherits the failure mode: a dropped bot its channel goes on reporting present.

LiveKit

Install with: pip install roomkit[livekit]

LiveKitConferenceBackend

LiveKitConferenceBackend(config=None)

Bases: ConferenceBackend

ConferenceBackend backed by a LiveKit SFU.

Example::

backend = LiveKitConferenceBackend(LiveKitConfig(url="ws://127.0.0.1:7880"))
await backend.ensure_room("room-1")
access = await backend.mint_access("room-1", "p-alice", ConferenceGrants())
bot = await backend.join_as_bot("room-1", "roomkit", ConferenceGrants.for_bot())

capabilities property

capabilities

What this backend has wired, which is not everything LiveKit sells.

Declared: separate screen-share tracks, dominant-speaker events and per-participant quality reports — each one an event this backend translates and forwards. Plus, when the deployment says so, REMOTE_UNMUTE and SIP_GATEWAY, because both depend on server configuration this backend cannot see.

Absent, and each for its own reason. EGRESS_RECORDING: LiveKit can do it, nothing here asks it to, and framework-side recording already works through the lanes. VIDEO_PUBLISH: the bot has nothing to show until an avatar gives it something, so the source is not built and the capability is not claimed. E2EE: admitting the bot to a conference's key exchange is a contract ConferenceBackend does not have, so declaring it would promise a bot that can hear an encrypted room when it cannot.

ensure_room async

ensure_room(room_id, metadata=None, e2ee=False)

Create the LiveKit room if it is not there.

create_room is idempotent on the name, which is what the interface asks for: a channel attaching to a room that is already conferring re-issues this call and nothing changes.

mint_access async

mint_access(room_id, participant_id, grants, *, display_name=None)

Mint a join credential for a participant the framework named.

participant_id becomes LiveKit's participant identity verbatim, so the value comes back on every participant and track LiveKit reports. That is rule 2 of RFC section 12.10.2 satisfied by the SFU itself rather than by a mapping table this backend would have to keep.

display_name rides the token as LiveKit's participant name — what LiveKit's own clients render — and comes back on every ParticipantInfo the server reports, which is how a roster rebuilt after a restart gets its names back (RFC 12.10.3).

list_participants async

list_participants(room_id)

Who is connected, as the server sees it.

Read through the server API rather than off the bot's own room, so the answer is available before a bot has joined and does not depend on one being connected. The names LiveKit's two protocols use differ, so they are brought to the realtime dialect here — provenance is decided on those names, and a control-plane spelling would leave a dial-in unresolvable.

join_as_bot async

join_as_bot(room_id, identity, grants)

Connect the framework to the conference as a participant.

The bot's token carries the grants it was given and nothing more, and it joins with auto-subscription off: the framework's subscription set is the authoritative one, and a bot the SDK subscribed on its own behalf would deliver media nobody asked for.

update_bot_grants async

update_bot_grants(bot, grants)

Replace the bot session's permissions on the server, in place.

UpdateParticipant re-permissions the connected session — same connection, subscriptions and callbacks undisturbed — which is what lets a hot-plugged voice speak without a re-join (RFC 12.10.4). The session is looked up first so a session this backend no longer holds is refused here rather than turned into a server call about a participant that may have left.

leave async

leave(bot)

Take the bot out, and forget the session only once it is out.

Popping first was how a failed disconnect became invisible: the registry had already forgotten the session, so a retry found nothing to leave and the channel's books called the bot gone while it may still have been in the meeting. The session stays registered until the disconnect returns, and the failure propagates for the channel's leaving ledger to record (RFC 12.10.4).

stop_playback async

stop_playback(bot)

Drop the bot's queued, unplayed audio — the barge-in gesture.

A session this backend no longer holds is a no-op rather than an error, unlike :meth:publish_audio: the SFU may have dropped the bot in the same breath as the barge-in, and the silence the call asks for is already true of a session that is gone (RFC section 12.10.3).

publish_video async

publish_video(bot, frame)

Refused: this backend does not publish the bot's video.

LiveKit can carry it. Nothing here builds the source that would, because the bot has nothing to show until an avatar gives it something — so the capability is not declared and this is what not declaring it means (RFC section 12.10.3).

close async

close()

Release the sessions and the API client. Idempotent.

A session whose disconnect fails stays registered and is raised, together, once every session has been attempted and the client is released — a close that only logged them reported bots possibly still in their meetings as a clean shutdown, which is the one answer the channel's books must never get (RFC 12.10.4). The channel records the failure against its own close and keeps naming the sessions.

LiveKitConfig dataclass

LiveKitConfig(url=None, api_key=None, api_secret=None, access_ttl=timedelta(minutes=15), audio_sample_rate=48000, audio_channels=1, publish_queue_ms=300, remote_unmute=False, sip_gateway=False, room_metadata_key='roomkit')

Connection and behaviour settings for :class:LiveKitConferenceBackend.

url, api_key and api_secret fall back to LiveKit's own environment variables — LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET — so a deployment that already sets them needs no RoomKit-specific configuration. The URL is the signalling one (ws:// or wss://); it is what clients are handed, and the server API derives its own endpoint from it.

url class-attribute instance-attribute

url = None

Signalling endpoint. Defaults to LIVEKIT_URL.

api_key class-attribute instance-attribute

api_key = field(default=None, repr=False)

API key. Defaults to LIVEKIT_API_KEY.

api_secret class-attribute instance-attribute

api_secret = field(default=None, repr=False)

API secret. Defaults to LIVEKIT_API_SECRET. Kept out of repr().

access_ttl class-attribute instance-attribute

access_ttl = timedelta(minutes=15)

How long a minted credential stays valid.

Short by default: the credential leaves the process, and RFC section 12.10.2 recommends short-lived ones. Long enough that a client which fetches its token then asks the user for microphone permission still gets in.

audio_sample_rate class-attribute instance-attribute

audio_sample_rate = 48000

Rate to ask LiveKit's decoder for on subscribed audio tracks.

48 kHz is Opus's own rate and LiveKit's default, so it is the closest thing to "what the publisher sent". Normalising it to what a recognizer wants is the lane's job (RFC section 12.10.4), not this backend's — every frame declares the format it arrives in.

audio_channels class-attribute instance-attribute

audio_channels = 1

Channel count to ask for on subscribed audio tracks.

Mono because that is what the pipeline works in and what LiveKit defaults to. Set it to 2 to have the SFU hand over a stereo publisher's audio unmixed, and the lane's downmix do the work instead.

publish_queue_ms class-attribute instance-attribute

publish_queue_ms = 300

How much of the AI's voice LiveKit may buffer ahead of playout.

The buffer is what keeps the bot's speech gap-free when synthesis stutters. It is not what a participant who interrupts must sit through: a barge-in discards it (stop_playback, RFC section 12.10.3), and the size bounds only what keeps playing when that gesture fails or a chunk already on its way lands behind it. 300 ms keeps even that residue within the range a person reads as responsive.

remote_unmute class-attribute instance-attribute

remote_unmute = False

Whether the server allows unmuting someone else's track.

Off by default because LiveKit's own default is off: unmuting a remote microphone needs room.enable_remote_unmute in the server configuration. Setting this without that is how a moderation UI comes to offer a button the server refuses, so the capability is declared only when an integrator says the server was configured for it.

sip_gateway class-attribute instance-attribute

sip_gateway = False

Whether PSTN participants can dial into this deployment's conferences.

Off by default for the same reason: LiveKit's SIP service needs a trunk and a dispatch rule before a phone can reach a room. When it is on, dial-ins arrive as ordinary participants whose sip. attributes this backend asserts, and identity resolution can reach them.

room_metadata_key class-attribute instance-attribute

room_metadata_key = 'roomkit'

Key under which ensure_room metadata is stored in LiveKit's room.

LiveKit's room metadata is one opaque string. Nesting under a key rather than writing the mapping at the top level leaves room for whatever else a deployment keeps there.

Mock

The test double, with fault injection — see Testing Patterns.

MockConferenceBackend

MockConferenceBackend(*, capabilities=NONE)

Bases: ConferenceBackend

Conference backend that scripts SFU events for tests.

Example::

backend = MockConferenceBackend()
bot = await backend.join_as_bot("room-1", "roomkit", ConferenceGrants())

track = await backend.simulate_track_published("room-1", "p-alice")
await backend.subscribe_track(bot, track.id)
await backend.simulate_audio(track, AudioFrame(data=b"..."))

Made to misbehave::

backend.fail("join_as_bot", TimeoutError)      # the SFU refuses the bot
backend.delay("track_audio", 0.05)             # slow delivery
dial_in = MockTrackFormat(sample_rate=8_000, sample_width=1)
track = await backend.simulate_track_published(
    "room-1", "p-bob", audio_format=dial_in
)
await backend.simulate_audio(track, backend.frame_for(track))

fail

fail(method, error=None, *, times=None)

Make a backend call raise. See :meth:MockFaults.fail.

delay

delay(operation, seconds)

Make a backend call or a callback fanout take time.

See :meth:MockFaults.delay.

update_bot_grants async

update_bot_grants(bot, grants)

Replace a connected session's grants, as a capable SFU would.

The whole grant set, not a delta (RFC 12.10.3). bot_grants keeps the last set per session, so a test can assert what the SFU currently believes the bot may do — after the join and after every update.

stop_playback async

stop_playback(bot)

Record the barge-in gesture. There is nothing here to discard.

The mock publishes synchronously — nothing queues, so a real flush would have no observable effect, and inventing one would have the mock outdo every real SFU. What a test asserts is that the gesture reached the backend at all, and for which bot. The open utterance is deliberately left open: a stop is not a boundary, and the closing chunk that follows is still owed (RFC section 12.10.3).

utterances_for

utterances_for(bot)

What was published on one bot's track, in order.

A channel serving several rooms holds a bot session per room, and utterances interleaves them the way the calls arrived. Asking per bot is asking about one track.

simulate_participant_joined async

simulate_participant_joined(room_id, participant_id, *, display_name=None, metadata=None, client_metadata=None, asserts_provenance=True)

Announce a participant joining the media session.

metadata is what the SFU itself asserts — a dial-in's caller number as the trunk reported it, which is what identity resolution consumes. client_metadata is what the participant's own client supplied at join: surfaced like any other attribute, never vouched for, and so never an address (RFC §12.10.2). A key given in both is the SFU's.

asserts_provenance=False is the third kind of backend — one that cannot tell the two apart and says so. Everything it surfaces becomes unvouched, whichever argument it arrived in.

simulate_bot_disconnected async

simulate_bot_disconnected(bot, reason='connection lost')

End the bot's session the way an SFU does: without a leave().

The session is forgotten first — a dropped connection is not a participant, and a later leave() for it finds nothing to do — and then reported, which is the order the contract promises (RFC 12.10.3).

simulate_track_published async

simulate_track_published(room_id, participant_id, kind=AUDIO, *, track_id=None, audio_format=None)

Publish a track, optionally in a format of its own.

audio_format is what this publisher negotiated with the SFU. Participants negotiate separately and nothing obliges them to agree, so a conference of three can carry three formats — and a track that declares one only accepts frames in it.

frame_for

frame_for(track, *, ms=20, amplitude=0.25)

Build a frame in the format track was published in.

amplitude is a fraction of full scale: the default is loud enough for an energy VAD to call speech, and 0.0 gives the silence that ends an utterance.

simulate_audio async

simulate_audio(track, frame)

Deliver an audio frame, if the bot subscribed to the track.

Returns whether it was delivered. An unsubscribed track produces no frame at all — a real SFU forwards nothing to a subscriber that did not ask, and a mock that delivered anyway would make selective subscription untestable.

simulate_video async

simulate_video(track, frame)

Deliver a video frame, if the bot subscribed to the track.

simulate_track_muted async

simulate_track_muted(track_id)

The publisher mutes their own track — a camera toggled off included.

simulate_bot_echo async

simulate_bot_echo(bot)

Report the bot back through its own callbacks, as some SFUs do.

Announces the bot as a participant and publishes a track in its name. Without self-exclusion the framework would then create a participant record for its own bot and transcribe the AI's own speech, so this is the scripted event sequence that proves the rule holds.

MockTrackFormat dataclass

MockTrackFormat(sample_rate=16000, channels=1, sample_width=2)

The audio format a participant negotiated for one track.

Defaults to what the rest of the framework assumes downstream of format normalisation, so a track that declares nothing behaves as before.

Example::

dial_in = MockTrackFormat(sample_rate=8_000, channels=1, sample_width=1)
studio = MockTrackFormat(sample_rate=48_000, channels=2, sample_width=4)

sample_width class-attribute instance-attribute

sample_width = 2

Bytes per sample: 1 (8-bit), 2 (16-bit) or 4 (32-bit), always signed.

matches

matches(frame)

Whether a frame is in this format.

MockUtterance dataclass

MockUtterance(bot_id, chunks=list(), complete=False)

The chunks published for one utterance on one bot's track.

An utterance runs until a chunk marks itself final. Two utterances published concurrently on the same bot therefore land in the same record, which is the point: that is what interleaving looks like, and a flat list of chunks cannot show it.

Two bots never share a record. A bot is a track — one per conference room — so chunks alternating between two of them are two rooms talking at once, which is ordinary, while chunks alternating within one are a single track carrying two answers, which is not. A record that could not tell them apart would report the first as the second.

bot_id instance-attribute

bot_id

The bot session the chunks were published on, which is to say the track.

complete class-attribute instance-attribute

complete = False

Whether a chunk with is_final closed it.

data property

data

Everything published for this utterance, in arrival order.

MockDelivery dataclass

MockDelivery(track_id, kind, started_at, elapsed)

How long one frame took to reach every subscriber.

started_at instance-attribute

started_at

Event-loop time the emission began.

elapsed instance-attribute

elapsed

Seconds the emission took, subscribers included.

MockFaults

MockFaults(*, methods, emissions)

Per-operation failures and delays for a mock backend.

Operations are named: backend methods (join_as_bot, leave, ...) can both fail and be slowed; callback emissions (track_audio, participant_joined, ...) can only be slowed, since a backend's emission loop swallows what its subscribers raise and a failure there would be invisible by construction.

Example::

faults = MockFaults(methods={"leave"}, emissions={"track_audio"})
faults.fail("leave", TimeoutError, times=1)   # first teardown only
faults.delay("track_audio", 0.05)             # slow delivery

fail

fail(operation, error=None, *, times=None)

Make operation raise.

Parameters:

Name Type Description Default
operation str

Backend method to fail.

required
error ErrorSpec | None

Exception instance, class, or factory. Defaults to a RuntimeError naming the operation.

None
times int | None

How many calls to fail. None fails every call; 1 fails the first and lets the rest through, which is how a retry or a second teardown gets tested.

None

delay

delay(operation, seconds)

Make operation take seconds before doing its work.

Applies to backend methods and to callback emissions alike. A delayed emission is what a slow media path looks like from outside.

clear

clear(operation=None)

Drop the injected behaviour, for one operation or for all of them.

apply async

apply(operation)

Sleep, then raise, as operation was configured to.

In that order: a call that fails slowly is what a timeout looks like, and a call that failed instantly would never let a test hold the window open around it.

Access and grants

ConferenceAccess dataclass

ConferenceAccess(url, token, expires_at=None, provider_data=dict())

Credentials a client uses to join the conference directly.

Treated as opaque: the backend mints it, the integrator hands it to its client application, and the provider's client SDK consumes it. Framework code must not depend on its internal structure beyond these fields.

url instance-attribute

url

Endpoint the client connects to.

token class-attribute instance-attribute

token = field(repr=False)

Provider-specific credential.

Excluded from repr() so that logging an access object, or letting one surface in a traceback, cannot leak the credential.

expires_at class-attribute instance-attribute

expires_at = None

When the credential stops being valid, if it expires.

Short-lived credentials are recommended.

provider_data class-attribute instance-attribute

provider_data = field(default_factory=dict)

Additional provider-specific fields.

Must not carry credentials: unlike token it appears in repr(), and the framework has no way to know which provider keys are sensitive.

ConferenceGrants dataclass

ConferenceGrants(publish_audio=True, publish_video=True, publish_screen_share=True, subscribe=True, moderate=False, hidden=False)

Permissions encoded into a participant's conference access.

The defaults are deliberately permissive so the common case works unconfigured. Narrowing them is the integrator's call and is recommended wherever a role does not need to publish. This is a SHOULD, not a MUST: do not flip these defaults to deny-by-default without changing the specification first.

publish_audio class-attribute instance-attribute

publish_audio = True

May publish a microphone track.

publish_video class-attribute instance-attribute

publish_video = True

May publish a camera track.

publish_screen_share class-attribute instance-attribute

publish_screen_share = True

May publish a screen-share track.

subscribe class-attribute instance-attribute

subscribe = True

May receive other participants' tracks.

moderate class-attribute instance-attribute

moderate = False

May mute or remove other participants.

hidden class-attribute instance-attribute

hidden = False

Invisible to other participants (bots, monitors).

for_bot classmethod

for_bot(*, speaks=False, listens=True)

Least privilege for the framework's own bot.

The permissive defaults above are for humans, whose needs the framework cannot know: an attendee may unmute, turn a camera on or share a screen at any point, and refusing that by default would break the common case. The bot is the opposite — the framework configured it, so it knows exactly what it will do, and asking the SFU for more than that is privilege nobody will use.

So: publish_audio only when a synthesizer is configured — without one there is nothing to publish. subscribe only when something consumes the tracks it would receive; a channel that only speaks subscribes to none, and the grant would be permission to receive every participant's media for nobody to read. publish_screen_share never: the bot has no screen. publish_video stays off until the bot is given something to show; an avatar would be what turns it on, and none is configurable yet.

listens defaults to true because that is what a conference bot is usually for, and because it is what makes :meth:observer mean what it says.

hidden is deliberately not decided here. It is a disclosure choice, not a privilege, and Section 17.7 leaves it to the integrator.

observer classmethod

observer()

Subscribe-only and hidden — the Observer participation pattern.

A silent bot that is also invisible, which is why it is expressed as one: whether a silent transcribing bot may stay invisible to participants is a legal question rather than a framework one, and the framework exposes the bot's hidden status so integrators can meet the disclosure rules that apply to them.

ConferenceCapability

Bases: Flag

Capabilities a ConferenceBackend can support.

Backends declare these via their capabilities property so the framework can refuse configurations the backend cannot honour, rather than failing at runtime.

Example::

class MyBackend(ConferenceBackend):
    @property
    def capabilities(self) -> ConferenceCapability:
        return (
            ConferenceCapability.SCREEN_SHARE
            | ConferenceCapability.ACTIVE_SPEAKER
        )

NONE class-attribute instance-attribute

NONE = 0

No optional capabilities (default).

SCREEN_SHARE class-attribute instance-attribute

SCREEN_SHARE = auto()

Separate screen-share tracks.

EGRESS_RECORDING class-attribute instance-attribute

EGRESS_RECORDING = auto()

Server-side (SFU) recording and export.

SIP_GATEWAY class-attribute instance-attribute

SIP_GATEWAY = auto()

PSTN/SIP participants can dial into the conference.

ACTIVE_SPEAKER class-attribute instance-attribute

ACTIVE_SPEAKER = auto()

Dominant-speaker change events.

CONNECTION_QUALITY class-attribute instance-attribute

CONNECTION_QUALITY = auto()

Per-participant connection quality reports.

VIDEO_PUBLISH class-attribute instance-attribute

VIDEO_PUBLISH = auto()

The bot can publish video tracks (avatar embodiment).

REMOTE_UNMUTE class-attribute instance-attribute

REMOTE_UNMUTE = auto()

A moderator can unmute another participant's track.

Separate from muting because unmuting someone else's microphone is a privacy decision, not a technical one: SFUs commonly refuse it by default and require an explicit server-side opt-in. Muting is always available.

BOT_GRANT_UPDATE class-attribute instance-attribute

BOT_GRANT_UPDATE = auto()

A connected bot session's grants can be changed in place.

A server-side participant update — the SFU changes what the session may do without reconnecting it. A capability because many SFUs can only set permissions at admission; against those, the one way to change a live bot's grants is to replace the session, and hot-plugging falls back to exactly that re-join (RFC 12.10.4). What this buys is continuity: a re-permission with the session, its subscriptions and the event bridge intact.

E2EE class-attribute instance-attribute

E2EE = auto()

End-to-end encryption between clients.

Constrains rather than extends what the framework can do: with E2EE active the bot receives ciphertext, so STT, vision and recording are unavailable unless the bot is admitted as a key holder.

Media models

ConferenceParticipant dataclass

ConferenceParticipant(participant_id, display_name=None, connected_at=(lambda: now(UTC))(), tracks=list(), metadata=dict(), asserted_metadata=None)

A participant's media presence in a conference.

participant_id instance-attribute

participant_id

Identity of the participant.

For a participant the framework admitted, this is the RoomKit Participant.id passed to mint_access() and echoed back by the backend. For one it did not admit — a PSTN dial-in, or an out-of-band admission — it is the backend's own stable identity.

display_name class-attribute instance-attribute

display_name = None

Human-readable name, when the SFU carries one.

Presentation, never identity: attribution rides participant_id alone (RFC 12.10.2), and this is what the SFU's own clients render. It usually rode in on the credential mint_access() issued, which is what lets a roster rebuilt from the join's catch-up get its names back after a restart — the credential outlives the process that minted it.

connected_at class-attribute instance-attribute

connected_at = field(default_factory=lambda: datetime.now(UTC))

When the participant joined the media session.

tracks class-attribute instance-attribute

tracks = field(default_factory=list)

Tracks this participant currently publishes.

metadata class-attribute instance-attribute

metadata = field(default_factory=dict)

Provider-supplied participant attributes.

Not decoration: for a participant the framework did not name, this is where the resolvable address lives — a PSTN dial-in carries its caller number here, and that number is what identity resolution consumes.

What it does not say is who put each attribute there, which is what :attr:asserted_metadata is for.

asserted_metadata class-attribute instance-attribute

asserted_metadata = None

The subset of :attr:metadata the SFU itself asserts.

One attribute map on most SFUs carries two very different things: facts the server established — the number a SIP trunk reported, a claim in a token it authenticated, an attribute set through a server-side API — and values a participant's own client supplied when it joined. Only the first kind can found an identity, and nothing in the map's shape tells them apart, so the backend says which is which here.

Three states, all meaningful (RFC §12.10.2):

  • a mapping: these attributes the SFU asserts, and identity may be resolved on an address among them;
  • {}: this backend distinguishes, and the SFU asserts nothing here;
  • None: this backend cannot distinguish. A statement, not an omission — the channel resolves nothing from it unless the integrator says otherwise.

A backend that fills this with everything it has is asserting a guess, and a guess is indistinguishable from a fact to whoever acts on it.

ConferenceTrack dataclass

ConferenceTrack(id, room_id, participant_id, kind, muted=False, metadata=dict())

A single media stream published by a conference participant.

id instance-attribute

id

Backend-scoped stable identifier.

room_id instance-attribute

room_id

Owning conference room.

Carried on the track because the frame callbacks receive only a track: a single backend instance serves many rooms, so without this the frames would not be routable.

participant_id instance-attribute

participant_id

Publishing participant.

Track identity is what attributes speech to a speaker, which is why a conference needs no diarization.

kind instance-attribute

kind

What the track carries.

muted class-attribute instance-attribute

muted = False

Whether the publisher has muted this track.

metadata class-attribute instance-attribute

metadata = field(default_factory=dict)

Provider-specific fields (sid, source, ...).

TrackKind

Bases: StrEnum

Kind of media carried by a conference track.

AUDIO class-attribute instance-attribute

AUDIO = 'audio'

Microphone audio.

VIDEO class-attribute instance-attribute

VIDEO = 'video'

Camera video.

SCREEN_SHARE class-attribute instance-attribute

SCREEN_SHARE = 'screen_share'

Screen share video.

BotSession dataclass

BotSession(id, room_id, identity, joined_at=(lambda: now(UTC))(), metadata=dict())

The framework's own connection to a conference.

One per conference. Every frame the framework receives and every frame it publishes passes through this single connection — it is the only crossing of the media-plane boundary.

id instance-attribute

id

Backend-scoped session identifier.

room_id instance-attribute

room_id

Conference the bot is connected to.

identity instance-attribute

identity

The bot's identity in the conference.

Used to recognise the bot's own participant and tracks when a backend reports them back, so they can be excluded from participant records, processing lanes and subscriptions.

joined_at class-attribute instance-attribute

joined_at = field(default_factory=lambda: datetime.now(UTC))

When the bot connected.

Defaulted to construction time, which is when a backend builds the session it is returning from join_as_bot(). A backend with a more accurate figure — one the SFU reports — sets it instead. What reads it is conference_ended's duration_ms (RFC 8.2).

metadata class-attribute instance-attribute

metadata = field(default_factory=dict)

Provider-specific fields.

Pipeline payloads

ConferenceTranscription dataclass

ConferenceTranscription(track_id, participant_id, room_id, text)

What a lane produced, before it enters the room.

Carried to ON_TRANSCRIPTION so a hook can identify the track and the participant, block the text, or rewrite it.

ConferenceBargeIn dataclass

ConferenceBargeIn(room_id, track_id, participant_id, interrupted_text, audio_position_ms)

A participant spoke over the bot and was allowed to interrupt it.

Carried to ON_BARGE_IN. The interrupting participant is named, which is what distinguishes a conference barge-in from a 1:1 one (RFC 12.10.5) — BargeInEvent identifies a voice session, and a conference lane has none.

Interruption

ConferenceInterruptionConfig dataclass

ConferenceInterruptionConfig(strategy=IMMEDIATE, scope=ANY, allowlist=list())

Multi-party interruption policy.

In a 1:1 voice session any user speech may interrupt playback. In a conference, who may interrupt is policy rather than mechanics.

strategy class-attribute instance-attribute

strategy = InterruptionStrategy.IMMEDIATE

How an interruption is confirmed once it is allowed.

scope class-attribute instance-attribute

Which participants are allowed to interrupt at all.

allowlist class-attribute instance-attribute

allowlist = field(default_factory=list)

Participant identities allowed to interrupt when scope is ALLOWLIST.

ConferenceInterruptionScope

Bases: StrEnum

Who may interrupt the bot while it is speaking.

ANY class-attribute instance-attribute

ANY = 'any'

Speech on any audio track interrupts playback.

NONE class-attribute instance-attribute

NONE = 'none'

The bot always finishes speaking (presentation or IVR style).

ALLOWLIST class-attribute instance-attribute

ALLOWLIST = 'allowlist'

Only listed participants may interrupt (moderator pattern).

Recording

ConferenceRecordingConfig dataclass

ConferenceRecordingConfig(mode=FRAMEWORK, storage='local', format='wav', metadata=dict())

Configuration for recording a conference.

mode class-attribute instance-attribute

Who produces the recording.

storage class-attribute instance-attribute

storage = 'local'

Integrator-defined storage identifier, resolved at runtime.

format class-attribute instance-attribute

format = 'wav'

Output format. Composed video egress typically uses mp4.

metadata class-attribute instance-attribute

metadata = field(default_factory=dict)

Recording metadata (room_id, participant_id, ...).

ConferenceRecordingMode

Bases: StrEnum

Where a conference recording is produced.

FRAMEWORK class-attribute instance-attribute

FRAMEWORK = 'framework'

Recorded by RoomKit from the tracks the bot subscribes to.

The path that always works: no backend capability, functions against the mock backend, and the file lands wherever the implementation writes it. Audio tracks are already subscribed for transcription, so recording them adds a file write and no additional media subscription.

EGRESS class-attribute instance-attribute

EGRESS = 'egress'

Delegated to the SFU. Requires EGRESS_RECORDING.

Exists for one reason: a composed video recording — grid or active-speaker layout — cannot be produced by the framework without subscribing every video track, decoding all of them, compositing and re-encoding, which is the media-plane work RoomKit does not do. Carries no unified result contract: the SFU announces completion out of band, and the integrator collects the output through the provider's own mechanism.

ConferenceRecordingStarted dataclass

ConferenceRecordingStarted(room_id, track_id, participant_id, id, kind, sample_rate, channels=None, codec='')

A track's recording has opened.

Carried to ON_RECORDING_STARTED. Names the track and the participant publishing it, which is what a conference has instead of the session the voice path's event carries (RFC 12.10.8).

channels class-attribute instance-attribute

channels = None

Audio channel count of the track, as the recording was opened on it.

codec class-attribute instance-attribute

codec = ''

Sample format of the track, e.g. pcm_s16le.

With :attr:sample_rate and :attr:channels it is the whole of what the recording was opened on, and this event is the only place an integrator learns it: two participants in one conference need not have negotiated the same one.

ConferenceRecordingStopped dataclass

ConferenceRecordingStopped(room_id, track_id, participant_id, id, url, duration_seconds, size_bytes, format)

A track's recording has closed, and this is where it went.

Carried to ON_RECORDING_STOPPED. url is what the recorder reported — a path, an object-store URL, whatever it writes to — and it is the whole point of the event: without it the files exist and nothing says where.

Errors

ConferenceAlreadyAttachedError, ConferenceCapabilityError, ConferenceCloseError and ParticipantNotAdmittedError are documented on the Errors page.