Skip to content

Built-in Channels

ConferenceChannel has a page of its own: Conference.

SMSChannel

SMSChannel(channel_id, *, provider=None, from_number=None, default_country_code=None)

Create an SMS transport channel.

Numbers are compared and stored in E.164; default_country_code ("1", "+44") places a national number a provider sends without it.

RCSChannel

RCSChannel(channel_id, *, provider=None, fallback=True, default_country_code=None)

Create an RCS (Rich Communication Services) transport channel.

Parameters:

Name Type Description Default
channel_id str

Unique identifier for this channel.

required
provider Any

RCS provider instance (e.g., TwilioRCSProvider).

None
fallback bool

If True (default), allow SMS fallback when RCS unavailable.

True
default_country_code str | None

Places a national number (E.164, see :func:SMSChannel).

None

Returns:

Type Description
TransportChannel

A TransportChannel configured for RCS messaging.

EmailChannel

EmailChannel(channel_id, *, provider=None, from_address=None)

Create an Email transport channel.

Addresses are compared and stored lower-case, without a display name (Alice <Alice@X.com> is alice@x.com).

AIChannel

AIChannel(channel_id, provider, system_prompt=None, temperature=0.7, max_tokens=None, max_context_events=50, tool_handler=None, tools=None, max_tool_rounds=50, tool_loop_timeout_seconds=300.0, tool_loop_warn_after=25, max_empty_retries=1, continuation=None, thinking_coalesce_ms=80.0, thinking_coalesce_chars=256, retry_policy=None, fallback_provider=None, skills=None, skills_in_prompt=True, script_executor=None, sandbox=None, external_tool_handler=None, human_input_handler=None, memory=None, tool_policy=None, thinking_budget=None, enable_thinking=None, reasoning_effort=None, response_schema=None, evict_threshold_tokens=5000, enable_planning=False, config_provider=None, tool_search=None, tool_search_pinned=None, tool_search_threshold=DEFAULT_TOOL_SEARCH_THRESHOLD, tool_search_threshold_pct=DEFAULT_TOOL_SEARCH_THRESHOLD_PCT, tool_search_miss_hint=None, turn_budget_tokens=None, turn_budget_usd=None, tool_search_threshold_tokens=DEFAULT_TOOL_SEARCH_THRESHOLD_TOKENS, tool_timeout_seconds=30.0, tool_timeouts=None, describe_empty_event=None, speak_policy=None, speak_timeout=2.0, thinker=None, think_wait=1.5, server_instructions=None)

Bases: AIThinkingMixin, AISpeakingMixin, AIStreamingMixin, AIGenerationMixin, AIToolsMixin, AIContextMixin, AIResilienceMixin, AIToolPolicyMixin, AISteeringMixin, AIEventsMixin, Channel

AI intelligence channel that generates responses using an AI provider.

tool_handler property writable

tool_handler

The host's tool handler: it serves every tool neither the channel nor orchestration serves (RFC §21.1).

Replacing it replaces the host's handler only: the channel's own tools and the ones orchestration set up keep being served.

provider property

provider

The underlying AI provider.

system_prompt property

system_prompt

The system prompt used to build request context each turn.

extra_tools property

extra_tools

The host's tools, then the ones orchestration set up for every room.

active_turns property

active_turns

Turns being produced right now.

Every turn's tool loop registers itself in _active_loops for steering, from the start of its generation to its finally. The span starts when the turn is consumed: a streaming output handed back by on_event and not yet iterated reads 0, a window the caller's own wait has to cover. close() tears the provider down under whichever turn is running, so a caller retiring this object waits for zero first.

recent_events_window property

recent_events_window

Recent-events need = this channel's memory provider's window.

set_system_prompt

set_system_prompt(prompt)

Replace the system prompt for subsequent turns.

AIChannel rebuilds its request context from self._system_prompt at the start of every turn, so the new prompt takes effect on the next turn with no reconnect and no loss of memory or tool state — the supported way to swap personas/attitudes mid-conversation.

Note: when a config_provider is set the system prompt is resolved fresh per turn, so this value is overridden on the next turn.

active_skill_names

active_skill_names(room_id)

Skills whose instructions are binding in room_id right now.

Runtime state, not the catalogue: these are the activations recorded for this conversation, which is exactly what the system prompt already carries under "Active skill instructions". A host rendering its own manifest (skills_in_prompt=False) cannot otherwise tell an available skill from an active one, and pushing the model to load what is already binding costs a tool round and contradicts the rules in front of it.

on_event async

on_event(event, binding, context)

React to an event: the room's turn runner takes it, or the channel answers.

A strategy installed in a room (a loop, a supervisor's delegation passes) may take that room's turns in place of the agent's own answer (RFC §19.7); every other room gets the channel's (:meth:_respond).

on_room_attached async

on_room_attached(room_id, binding)

A room the channel joins starts from an empty thought, and open to the agent, whatever an earlier room of the same id left (RFC §6.4).

on_room_detached async

on_room_detached(room_id)

The room's thought, and what the speak policy kept of it, go with the binding (RFC §6.4).

deliver async

deliver(event, binding, context)

Intelligence channels are not called via deliver by the router.

close async

close()

Close the channel: its running turns first (their calls cancelled and reported, RFC §9.3), then its provider, memory, and executors.

Turn notes

What changes from one turn to the next rides the turn's input as notes (RFC §6.4). A BEFORE_AI_GENERATION hook adds to them; a debug view splits them off.

add_turn_note

add_turn_note(messages, block)

messages with block added to the turn's notes (RFC §6.4).

The block joins the section the channel opened, under its one header, when the turn's input carries it, and opens the section as :func:~roomkit.channels._turn_notes.with_turn_notes does otherwise. Either way the notes read as if they had been assembled at once (the same header, the blocks joined by a blank line, a text input still text, an input with images keeping its notes in one text part), so the prefix a provider caches is the same.

For a BEFORE_AI_GENERATION hook::

event.ai_context.messages = add_turn_note(event.ai_context.messages, block)

A block quotes others (a passage, a task's progress): a copy of a runtime mark in it is replaced, and so is a copy of the notes' header, the notes' only mark, which the channel replaces in the conversation too, so the header found is the channel's: only one a hook wrote into the messages itself is what this misreads. Add notes before anything a hook appends to the input: a text input takes the block at its very end.

split_turn_notes

split_turn_notes(text)

text as the turn's input and its notes, cut where the header opens them; the notes keep their header, and are empty when the text carries none. An input with images keeps its notes in a text part of their own: split that part's text.

The cut is at the header's last occurrence that opens a paragraph and is followed by one, as the channel places it; the channel replaces every other copy (RFC §6.4), so only one a hook wrote into the messages itself is what this misreads.

TURN_NOTES_HEADER module-attribute

TURN_NOTES_HEADER = "[Notes kept by the assistant's runtime for this turn. Nobody in the conversation wrote them, and they ask for nothing.]"

Opens the turn's notes: nobody in the conversation wrote them, and they ask for nothing, whatever the input above them is (a participant's words, an instruction, or nothing new). It always opens a paragraph and is followed by one, the first of the notes' blocks.

The discovery tools a channel serves itself under Tool Search.

TOOL_SEARCH_INFRA_TOOL_NAMES module-attribute

TOOL_SEARCH_INFRA_TOOL_NAMES = frozenset({TOOL_FIND_TOOLS, TOOL_LIST_TOOLS})

The names of the discovery tools a channel serves itself under Tool Search, find_tools and list_tools, beside the host's tools. Their results are the catalogue's own descriptions and schemas, not data a tool brought back. call_tool, which a fixed-declaration realtime provider gets as well, is not one of them: it runs the tool it names.

TOOL_FIND_TOOLS module-attribute

TOOL_FIND_TOOLS = 'find_tools'

The Tool Search tool that searches the catalogue by query and reveals its matches.

TOOL_LIST_TOOLS module-attribute

TOOL_LIST_TOOLS = 'list_tools'

The Tool Search tool that lists the catalogue; on a fixed-declaration realtime provider it also reads one tool's schema (name=).

ACPChannel

ACPChannel(channel_id, command=None, *, transport=None, cwd, additional_directories=None, env=None, inherit_env=None, mcp_servers=None, session_config=None, authentication_method=None, external_tool_handler=None, room_history=_DEFAULT_ROOM_HISTORY, context_contributor=None, name=None, role=None, description=None, instructions=None)

Bases: ACPConnectionMixin, ACPSessionsMixin, ACPTurnMixin, ACPEventsMixin, Channel

Connect a RoomKit Room to an external ACP coding agent.

One connection to the agent is opened lazily for the channel and one ACP session is created per Room. Prompts are serialized inside each session, while different Rooms progress concurrently over the same connection.

Pass command for the usual case — the agent is spawned here as a subprocess and spoken to over its stdio. Pass transport instead when the agent runs somewhere this process cannot spawn it (another machine, behind a relay); see :class:~roomkit.channels.acp_transport.ACPTransport. Exactly one of the two is required.

Parameters:

Name Type Description Default
channel_id str

RoomKit channel identifier.

required
command Sequence[str] | None

Executable and arguments used to start the ACP agent. No shell is involved. Mutually exclusive with transport.

None
transport ACPTransport | None

How to reach an agent this channel does not spawn. Mutually exclusive with command (and with env / inherit_env, which configure the spawn).

None
cwd str | Path

Absolute working directory declared to the ACP session — and, for a spawned agent, the process's own directory. With a custom transport it names a directory on the agent's machine.

required
additional_directories Sequence[str | Path] | None

Additional absolute directories exposed in the ACP session declaration.

None
env Mapping[str, str] | None

Environment variables added to the SDK's restricted inherited environment. Spawned agents only.

None
inherit_env Sequence[str] | None

Names of parent-process environment variables to forward to the agent. The ACP SDK strips the environment down to HOME/LOGNAME/PATH/SHELL/TERM/USER (MCP practice), which silently breaks tooling a coding agent relies on — e.g. without SSH_AUTH_SOCK, every git-over-SSH operation prompts for key passphrases on the controlling terminal. Values are read at each process spawn; unset names are skipped; explicit env entries win over inherited ones. Nothing is forwarded by default. Spawned agents only.

None
mcp_servers Sequence[Any] | MCPServersForRoom | None

The MCP servers each session is given, as ACP MCP-server descriptors of the official SDK: a list, the same for every room, or a function of the room id (sync or async), called each time a session opens for that room, so a server can be told which room it acts for (a stdio server's env, a URL).

None
session_config Mapping[str, str | bool] | None

ACP session config values set on every session the channel opens for a room, {"mode": "default"} for instance: the room's first session, one opened again after a recovery or an agent restart, and a standalone turn's. A value the session already has is not set again. A value the host sets on a room with :meth:set_config_option is set again too, over this, on the room's next session, until :meth:close_session forgets it. A value the agent refuses fails the opening, and the turn with it: the session is released, never prompted (RFC §6.4, ACP agent channel, Session setup). The mode matters most, since it decides what the agent asks external_tool_handler before acting.

None
authentication_method str | None

Optional ACP authentication method identifier.

None
external_tool_handler ExternalToolHandler | None

Permission policy and tool observability bridge. Without a handler, every permission request is rejected.

None
room_history int

How many room messages the agent catches up on when it is asked to act, having been skipped while it was not (RFC §19.3.2). An ACP session holds its history in the agent's process, so a room where two agents are addressed in turn would otherwise leave each one with a private thread and no way to know it. 0 turns the catch-up off. Only what the agent missed is sent, and only what visibility would have delivered to it (RFC §7.5 rule 8). It is also the window this channel declares to the framework, which loads the largest window any bound channel declares (floored at 50 events while a hook or an identity hook is registered, capped at 2000). When that tail stops short of what the agent missed, as it does on a room with no hook, the catch-up header says that earlier events were not loaded.

_DEFAULT_ROOM_HISTORY
context_contributor ACPContextContributor | None

What the host adds to a turn's prompt that the agent cannot go and fetch — member memories, a document corpus, an organisation's rules. Awaited once per solicited turn with the room's context and the triggering event; the blocks it returns open the prompt, ahead of the catch-up and the request. Non-empty blocks can supply the prompt for an event without text, such as a host-managed attachment. A turn with neither text nor blocks is skipped. One that raises is logged and the turn goes without it.

Turn-scoped context only. An ACP session keeps what it was already told, so a block that never changes is paid for again every turn; what is stable belongs to instructions, sent once per session, or to the agent's own configuration (AGENTS.md, MCP servers): this is conversation.

Nothing here is bounded. RoomKit does not truncate the blocks — it knows neither their unit nor the agent's tokenizer, and the model can change mid-session — and does not bound how long the contributor takes. Both budgets are the host's, and a slow contributor delays the broadcast for the whole room, not just for this agent. Nor can RoomKit filter what the blocks carry: the catch-up is filtered per reader because it is made of room events (RFC §7.5 rule 8), and these are not.

context.recent_events is the framework's tail, not a window this channel guarantees: sized by the largest window a bound channel declares, floored at 50 events while a hook or an identity hook is registered, capped at 2000. With room_history=0 on a room with no hook and no other history reader, it holds the triggering event alone. A contributor that needs the room's history reads it from the store.

None
name str | None

The agent's name, as an Agent's (RFC §19.1): a discussion's notes, a dispatch policy and a console present it by its name, role and description.

None
role str | None

The agent's role.

None
description str | None

What the agent does.

None
instructions str | None

The integrator's standing direction for the agent (a persona, how the room works), since its own system prompt is not RoomKit's to set: sent in the first prompt of each session the channel opens for a room, a session opened again included, as the application's block ahead of the catch-up, and not again while the session keeps it (RFC §6.4). What changes from turn to turn belongs to context_contributor.

None

info property

info

Return ACP connection and agent metadata without exposing arguments.

active_turns property

active_turns

Turns in flight: registered by _prompt_stream once it holds the room, while its session is opened, dropped when its stream closes. The whole of the turn as the consumer sees it, not only while the agent is answering.

recent_events_window property

recent_events_window

Room tail this channel reads — the catch-up window (RFC §19.3.2).

The framework sizes RoomContext.recent_events to the largest window any bound channel declares, under a floor it keeps for hooks (50 events, while one is registered). So a room_history under that floor reads a tail that was loaded anyway, one above it grows the tail to match, and on a room with no hook the declaration is what loads the tail at all: declaring the window is what keeps the two in step.

session_config

session_config(room_id)

Current ACP session config values for room_id, keyed by config id.

Agents publish their tunables through this one list — model, mode, effort, vendor switches. Empty until the room's session exists (sessions open on the first prompt).

Tracks what the agent announces. A switch made inside the agent with its own slash command may not be announced at all — the ACP bridge for Claude Code relays /model output as plain text and sends no config update — so drive changes through :meth:set_config_option when the value must stay observable.

config_options

config_options(room_id)

The agent's session tunables for room_id, as ACP describes them.

Full descriptors — id, name, current value, available choices — for surfaces that let a user pick one (a model picker). Empty until the session exists. :meth:session_config is the values-only shortcut.

set_config_option async

set_config_option(room_id, config_id, value)

Set one session config option — set_config_option(room, "model", "opus").

Returns the full config mapping the agent reports back, so the caller sees the value it landed on (agents resolve aliases) plus any option the change invalidated. Opens the room's session if the first prompt has not yet done so, which connects to the agent.

The value belongs to the room, not only to this session: the room's next session (after a recovery or an agent restart) is set to it too, over session_config, until :meth:close_session. A call that opens the room's session sets its value in place of the room's earlier one, so a value the agent has come to refuse is replaced, not replayed.

on_event async

on_event(event, binding, context)

Create a lazy ACP prompt stream for a Room event.

session_id

session_id(room_id)

Return the process-local ACP session identifier for a Room.

cancel async

cancel(room_id)

Request cancellation of the active ACP turn for a Room.

close_session async

close_session(room_id)

Forget one Room's ACP session, and close it where the agent can.

Forget is the whole of it: every map keyed by the session, and the room's turn lock once no session is left behind it, is dropped here. A long-lived channel cycling sessions (one per conversation, one per reconnect) would otherwise carry every dead session's config options until the channel itself closed. session/close goes only to an agent that announces it; one that does not keeps the session until the connection closes. Never raises for a close the agent refuses: returns True once the session is forgotten, False when there was none.

The config values the host set on the room are forgotten too: its next session opens on session_config alone.

close async

close()

Cancel turns, close sessions where the agent can, and close the transport.

Shutdown is bounded: the graceful ACP round trips share _SHUTDOWN_TIMEOUT, and the transport teardown runs even when they time out, fail, or the caller is cancelled mid-close (a second Ctrl-C landing on close_session). An agent that has stopped answering must not outlive — or hang — the process that started it.

WebSocketChannel

WebSocketChannel(channel_id, *, send_timeout=5.0)

Bases: Channel

WebSocket transport channel with connection registry.

Args: channel_id: Channel identifier. send_timeout: Seconds a single send may take before the connection is treated as failing. Broadcast runs under the room lock and is unbounded by design (RFC §10.1), so an unbounded send would let one unresponsive socket freeze the room.

supports_streaming_delivery property

supports_streaming_delivery

Whether any connected client supports streaming text delivery.

register_connection

register_connection(connection_id, send_fn, *, room_id, stream_send_fn=None)

Register a WebSocket connection and subscribe it to a room.

Parameters:

Name Type Description Default
connection_id str

Unique connection identifier.

required
send_fn SendFn

Callback for delivering complete events.

required
room_id str

The room this connection is for. Required: a channel instance can be attached to several rooms, and without this the channel has no way to tell which of its sockets belongs to the room it is delivering — it would have to send everything to everyone. Use :meth:subscribe to add further rooms to the same connection.

required
stream_send_fn StreamSendFn | None

Optional callback for delivering streaming messages. When provided, this connection receives progressive text delivery via the stream_start/stream_chunk/stream_end protocol.

None

subscribe

subscribe(connection_id, room_id)

Also deliver room_id's events to an existing connection.

For the client that holds several conversations open on one socket.

unsubscribe

unsubscribe(connection_id, room_id)

Stop delivering room_id's events to a connection.

rooms_for

rooms_for(connection_id)

Rooms a connection currently receives.

unregister_connection

unregister_connection(connection_id)

Unregister a WebSocket connection and drop its subscriptions.

supports_streaming_delivery_for

supports_streaming_delivery_for(room_id)

Whether any client in this room speaks the streaming protocol.

The channel-wide property answers for every socket the channel holds, which is the wrong question once connections are scoped: a room whose clients are all non-streaming would otherwise take the streaming path and fall back at the end, having set up a stream nobody reads.

deliver_stream async

deliver_stream(text_stream, event, binding, context)

Deliver a streaming response with interleaved events to clients.

The stream yields str for text deltas and RoomEvent for persisted events (text segments, tool calls). Streaming-capable connections receive:

  • stream_start — streaming begins
  • stream_chunk — text delta (drives the live bubble)
  • event — persisted event (tool call, text segment)
  • stream_end — streaming complete

Non-streaming connections receive all persisted events via the regular send_fn.

VoiceChannel

VoiceChannel(channel_id, *, stt=None, tts=None, backend=None, pipeline=None, streaming=True, enable_barge_in=True, barge_in_threshold_ms=200, interruption=None, batch_mode=False, voice_map=None, max_audio_frames_per_second=None, tts_filter=None, max_sentences=None, bridge=None, recording=None, close_providers=True, stt_language_lock=None, tts_context=None, pipeline_speakers=False)

Bases: VoiceSTTMixin, VoiceTTSMixin, VoiceHooksMixin, VoiceRecordingHooksMixin, VoiceTurnMixin, VoicePipelineMixin, FrameworkAwareChannel, Channel

Real-time voice communication channel.

Supports three STT modes: - VAD mode (default): VAD segments speech, streaming STT during speech with batch fallback on SPEECH_END. - Continuous mode: No VAD + streaming STT provider — all audio streamed, provider handles endpointing. - Batch mode (batch_mode=True): No VAD, audio accumulates post-pipeline. Caller controls when to transcribe via :meth:flush_stt. Useful for dictation, voicemail, and audio-file transcription with offline models.

When a VoiceBackend and AudioPipelineConfig are configured, the channel: - Registers for raw audio frames from the backend via on_audio_received - Routes frames through the AudioPipeline inbound chain: [Resampler] -> [Recorder] -> [AEC] -> [AGC] -> [Denoiser] -> VAD -> [Diarization] + [DTMF] - Fires hooks based on pipeline events (speech, silence, DTMF, recording, etc.) - Transcribes speech using the STT provider - Optionally evaluates turn completion via TurnDetector - Synthesizes AI responses using TTS and streams to the client

When no pipeline is configured, the channel operates without VAD — the backend must handle speech detection externally.

The STT language can be chosen per session at runtime with :meth:set_stt_language; it applies from the session's next stream. stt_language_lock installs an :class:~roomkit.voice.stt.language.STTLanguageLock that starts every session detecting (Deepgram multi), pins it to the language the speaker uses, and releases it when the results stop fitting.

A TTS provider whose context_level is not NONE receives the dialogue of the session on every streaming call (RFC §12.2.2): the channel keeps it per session, as tts_context bounds it, and releases it when the session is unbound.

Who spoke (RFC §12.2.3): with a diarizing STT in continuous mode each segment becomes its own message carrying speaker_label, speaker_epoch and sender_name. pipeline_speakers=True does the same from the pipeline's DiarizationProvider when the STT labels nothing: each transcript takes the speaker the stage heard the longest over it (refused in batch mode, where a flush may hold several). Opt-in, because it changes the name every message carries.

max_sentences caps how long the agent speaks (RFC §12.2 step 12s.e): once that many sentences are said, a streamed reply that goes on stops at its first word past them, as a barge-in stops it (nothing more generated, no tool call it would make next started, the text produced so far stored, marked cancelled); a text delivered whole is spoken to the budget. A sentence BEFORE_TTS drops does not count, and :meth:say has no budget.

backend property

backend

The voice backend (if configured).

supports_streaming_delivery property

supports_streaming_delivery

Whether this channel can accept streaming text delivery.

add_backend

add_backend(backend)

Serve sessions from another transport on this channel (RFC §12.7.3).

A bridged room often mixes transports — phone callers on SIP beside browser participants on WebRTC. The added backend's inbound audio enters this channel's pipeline, and its session-ready and client-disconnected signals drive the channel's session lifecycle, as for the backend given at construction, and so do its barge-in detection and out-of-band DTMF. Everything addressed to one of its sessions — bridged audio, TTS, assistant transcriptions, the playback cancel of an interruption, DTMF — goes out through it.

The pipeline is built once, for the primary backend's capabilities, so the channel must have been constructed with a backend. A session is matched to its transport by kit.join(..., backend=), or else by asking each added backend whether it holds the session. Adding a backend already served does nothing. Closing the channel closes the added backends too.

Raises:

Type Description
RuntimeError

The channel has no primary backend or pipeline.

set_bridge_filter

set_bridge_filter(fn)

Set a synchronous filter for bridged audio frames.

The filter runs in the audio callback thread before each frame is forwarded. It receives (source_session, frame) and returns the frame (possibly modified) or None to drop it.

This is the synchronous equivalent of BEFORE_BRIDGE_AUDIO — use it for fast operations like per-session muting or gain.

Parameters:

Name Type Description Default
fn BridgeFrameFilter | None

Filter function, or None to remove.

required

set_framework

set_framework(framework)

Set the framework reference for inbound routing.

Called automatically when the channel is registered with RoomKit.

on_trace

on_trace(callback, *, protocols=None)

Register a trace observer and bridge to the backend.

resolve_trace_room

resolve_trace_room(session_id)

Resolve room_id from voice session bindings.

bind_session

bind_session(session, room_id, binding, *, backend=None)

Bind a voice session to a room for message routing.

Parameters:

Name Type Description Default
session VoiceSession

The voice session to bind.

required
room_id str

Target room ID.

required
binding ChannelBinding

Channel binding descriptor.

required
backend VoiceBackend | None

Override backend for the bridge. When bridging sessions from different transports (e.g. SIP + WebRTC), pass the session's own backend so the bridge sends audio through the correct transport.

None

connect_session async

connect_session(session, room_id, binding)

Accept a voice session via process_inbound.

Delegates to :meth:bind_session which handles pipeline activation and framework events.

disconnect_session async

disconnect_session(session, room_id)

Clean up a voice session on remote disconnect.

update_binding

update_binding(room_id, binding)

Update cached bindings for all sessions in a room.

Called by the framework after mute/unmute/set_access so the audio gate in _on_audio_received sees the new state.

add_media_tap

add_media_tap(callback)

Register a tap on processed inbound audio frames (for room recording).

Delegates to the pipeline's on_processed_frame callback list.

add_outbound_media_tap

add_outbound_media_tap(callback)

Register a tap on outbound TTS audio (for room recording).

The callback receives (session, pcm_data, sample_rate) for every outbound chunk after pipeline processing.

unbind_session

unbind_session(session)

Remove session binding.

update_voice_map

update_voice_map(entries)

Merge entries into the per-agent voice map.

Called by :meth:ConversationPipeline.install to auto-wire voice IDs from :class:Agent instances.

set_stt_language

set_stt_language(session, language)

Choose the STT language for one session, from its next stream on.

None returns the session to the provider's configured language. A streaming STT fixes its language when the stream opens, so the choice lands on the next stream:

  • VAD mode — the next utterance. A stream that is open stays open; restarting it would cut the utterance in progress in two.
  • Continuous mode — right away: the current cycle is ended and the loop reconnects with the new language. Audio arriving in the gap is kept, as on every reconnect.
  • Batch mode — the next :meth:flush_stt.

The typical caller is an ON_TRANSCRIPTION hook reading event.language from a detecting stream (Deepgram multi) and pinning the session to what it heard; :class:~roomkit.voice.stt.language.STTLanguageLock packages that loop.

Raises:

Type Description
RuntimeError

No STT provider, or one whose supports_language_override is false.

get_stt_language

get_stt_language(session)

The STT language chosen for a session, None for the provider's default.

send_dtmf

send_dtmf(session, digit, duration_ms=160)

Send a DTMF digit to the remote party via the voice backend.

The digit is sent as an RFC 4733 telephone-event (out-of-band). Requires a backend with DTMF_SIGNALING capability (SIP, RTP).

Parameters:

Name Type Description Default
session VoiceSession

The active voice session.

required
digit str

DTMF digit ('0'-'9', '*', '#', 'A'-'D').

required
duration_ms int

Tone duration in milliseconds (default 160).

160

Raises:

Type Description
RuntimeError

If no backend is configured or session is ended.

ValueError

If digit or duration_ms is invalid.

interrupt async

interrupt(session, *, reason='explicit')

Interrupt ongoing TTS playback for a session.

interrupt_all async

interrupt_all(room_id, *, reason='task_delivery')

Interrupt all active TTS playback in a room.

Returns:

Type Description
int

Number of sessions that were interrupted.

wait_playback_done async

wait_playback_done(room_id, timeout=15.0)

Wait until active TTS playback finishes for all sessions in room_id.

Returns immediately if no playback is in progress. Uses per-session events that are set when send_audio() returns (before the echo drain delay), so callers don't wait for the 2-second drain window.

RealtimeVoiceChannel

RealtimeVoiceChannel(channel_id, *, provider, transport, owns_transport=True, system_prompt=None, voice=None, tools=None, temperature=None, input_sample_rate=16000, output_sample_rate=24000, transport_sample_rate=None, emit_transcription_events=True, tool_handler=None, human_input_handler=None, mute_on_tool_call=False, tool_result_max_length=16384, tool_timeout_seconds=10.0, tool_timeouts=None, pipeline=None, recording=None, skills=None, script_executor=None, skill_delivery_mode=None, server_instructions=None, tool_recovery=True, tool_search=None, tool_search_pinned=None, tool_search_threshold=20, reasoning_backend=None, reasoning_timeout_s=120.0, tool_policy=None)

Bases: RealtimeToolRecoveryMixin, RealtimeToolsMixin, RealtimeToolGateMixin, RealtimeDelegationMixin, RealtimeTranscriptionMixin, RealtimeSpeechMixin, RealtimeAudioMixin, RealtimeResponseMixin, VoiceRecordingHooksMixin, VoicePipelineMixin, RealtimeModelHost, FrameworkAwareChannel, Channel

Real-time voice channel using speech-to-speech AI providers.

Wraps APIs like OpenAI Realtime and Gemini Live as a first-class RoomKit channel. Audio flows directly between the user's browser and the provider; transcriptions are emitted into the Room so other channels (supervisor dashboards, logging) see the conversation.

Category is TRANSPORT so that: - on_event() receives broadcasts: another channel's text enters the sessions as its author's quoted words (RFC §12.4) - deliver() is called but returns empty (customer is on voice)

Example

from roomkit.voice.realtime.mock import MockRealtimeProvider, MockRealtimeTransport

provider = MockRealtimeProvider() transport = MockRealtimeTransport()

channel = RealtimeVoiceChannel( "realtime-1", provider=provider, transport=transport, system_prompt="You are a helpful agent.", ) kit.register_channel(channel)

Initialize realtime voice channel.

Parameters:

Name Type Description Default
channel_id str

Unique channel identifier.

required
provider RealtimeVoiceProvider

The realtime voice provider (OpenAI, Gemini, etc.).

required
transport VoiceBackend

The audio transport (WebSocket, etc.).

required
owns_transport bool

Close the transport with this channel (default True). Set False for a shared transport with unsubscribe support, such as FastRTC. Only this channel's sessions and callbacks close.

True
system_prompt str | None

Default system prompt for the AI.

None
voice str | None

Default voice ID for audio output.

None
tools list[dict[str, Any] | Any] | None

Tool definitions as dicts, or Tool objects with .definition and .handler. Tool objects have their handlers extracted and composed automatically.

None
temperature float | None

Default sampling temperature.

None
input_sample_rate int

Default input audio sample rate (Hz).

16000
output_sample_rate int

Default output audio sample rate (Hz).

24000
transport_sample_rate int | None

Sample rate of audio from the transport (Hz). When set and different from provider rates, enables automatic resampling. Transports can override it in session metadata: transport_sample_rate for capture and, when different, transport_output_sample_rate for playback. FastRTC and SIP declare their rates automatically. Without either metadata or a configured rate, audio passes through unchanged.

None
emit_transcription_events bool

If True, emit final transcriptions as RoomEvents so other channels see them.

True
tool_handler ToolHandler | None

Async callable to execute tool calls. Signature: async (name, arguments) -> str. If not set, falls back to handlers extracted from Tool objects, or ON_TOOL_CALL hooks. Raise roomkit.ToolRefusedError to decline a call, or roomkit.ToolFailedError to say it ran and failed: the message reaches the model verbatim and the call is marked failed, where a returned body would read as work that was done.

None
human_input_handler HumanInputToolHandler | None

The tools that ask a person, as on an AIChannel: the channel declares their tool_definitions in every session and serves them before tool_handler, on every door (the provider's call, a call recovered from speech, a reasoning backend's), under the handler's own timeout rather than the default call bound. Each request fires ON_USER_INPUT_REQUIRED, whose BLOCK rejects it, and the requests still open are settled when the channel closes (RFC §9.3, §21.6).

None
mute_on_tool_call bool

If True, mute the transport microphone during tool execution to prevent barge-in that causes providers (e.g. Gemini) to silently drop the tool result. Defaults to False — use set_access() for fine-grained control.

False
tool_result_max_length int

Maximum character length of tool results before truncation. Large results (e.g. SVG payloads) can overflow the provider's context window. Defaults to 16384.

16384
tool_timeout_seconds float | None

How long one tool call may take before its handler is cancelled and the call fails (RFC §21.6), so a handler that never answers cannot leave the caller in silence. Defaults to 10 s; None leaves calls unbounded. A tool that waits on another agent (orchestration's) keeps its own bound.

10.0
tool_timeouts Mapping[str, float | None] | None

A bound per tool name, above the default (None for a tool that may take as long as it needs).

None
tool_policy ToolPolicy | None

Allow/deny rules for the session's tools, with the meaning they have on an AIChannel (RFC §21.1). A tool the policy denies is not declared to the session, is never named by Tool Search, and is refused if called anyway, whether the call comes from the provider, from spoken text the channel recovered, or from a reasoning backend. Role overrides apply to the session's participant, read when the session starts.

None
pipeline AudioPipelineConfig | None

Optional AudioPipelineConfig for local audio processing (AEC, VAD, denoiser, etc.). When set, mic audio is processed through the pipeline before being forwarded to the provider, and pipeline VAD drives speech detection instead of server-side VAD. With a full-duplex provider the VAD is observation only: its events reach hooks and metrics but never interrupt the model (RFC §12.4.1).

None
recording Any | None

Optional ChannelRecordingConfig to enable room-level audio recording from this channel. Records both input (mic) and output (AI) audio tracks.

None
skills SkillRegistry | None

Optional SkillRegistry with discovered skills. When provided, skill infrastructure tools are injected and the skills preamble is appended to the system prompt.

None
script_executor ScriptExecutor | None

Optional ScriptExecutor for running skill scripts. Ignored when skills is None.

None
skill_delivery_mode str | None

How skill bodies reach the model. "inline_full" bakes every skill's full instructions into the initial system_instruction at session start; activate_skill becomes a declarative ACK and no provider.reconfigure is needed. "on_demand" puts only metadata in the prompt; activate_skill loads the body via provider.reconfigure. Defaults to "inline_full" when the provider reports supports_mid_session_reconfigure=False (e.g. Gemini 3.x Flash Live), "on_demand" otherwise.

None
server_instructions Mapping[str, str | None] | None

What the MCP servers whose tools this channel serves published for the model (:attr:~roomkit.tools.MCPToolProvider.instructions), by the host's label for each server, an identifier. Each is set apart in a block of its own and added to the session's system prompt after the channel's, while the provider takes tools (RFC §21.2). A server mapped to None is left out.

None
tool_recovery bool

If True, detect tool calls that the model speaks as text (e.g. call:name{args}) instead of issuing through the function calling API, and run them: a call said as a sentence of its own that ends the utterance, never one a sentence mentions. Defaults to True. Recovered calls pass the same pre-execution gate as any other — declared catalogue, tool policy, skill gating, argument schema, BEFORE_TOOL_USE — and their outcome returns as injected context rather than a tool result, because the model has no pending call to answer. Set to False to let a spoken call stay speech.

True
tool_search bool | None

Auto-enable Tool Search when the catalogue is large enough to exceed the realtime model's reliable tool-selection window (Google: 10–20 active tools on Gemini Live). Pass True / False to force, None (default) for automatic activation when len(tools) > tool_search_threshold.

None
tool_search_pinned list[str] | None

Tool names that should ALWAYS be visible (never hidden behind search). Use for tools the agent calls reflexively — e.g. hangup_call, get_datetime, in-flight session-control tools.

None
tool_search_threshold int

Auto-activation threshold and the cap on how many tools may be live at once. Defaults to 20 to match Google's published recommendation.

20
reasoning_backend ReasoningBackend | None

Serves the integrator-side reasoning delegations of a full-duplex provider (RFC §12.4.1): when the model hands work over, the backend receives the transcript recorded since the previous delegation and its outputs return to the model as spoken or silent context. Its tool calls pass the same pre-execution gate as any realtime tool call. Only a full-duplex provider delegates; on any other provider the backend never runs.

None
reasoning_timeout_s float

Bound on one delegation's run. A backend that exceeds it is abandoned and the model is told so. Defaults to 120 seconds.

120.0

provider property

provider

The underlying realtime voice provider.

transport property

transport

The transport backend carrying this channel's audio.

session_rooms property

session_rooms

Mapping of session_id to room_id.

tool_handler property writable

tool_handler

The current tool handler for realtime tool calls.

get_room_sessions

get_room_sessions(room_id)

Get all active sessions for a room.

wait_idle async

wait_idle(room_id, timeout=15.0, *, session_ids=None)

Wait until the selected sessions in the room are idle (not speaking).

session_ids=None includes every session. A proactive delivery can restrict the wait to its pinned destinations without waiting on others.

An idle session has submitted its tool results, finished the provider response that follows them, and all audio has been forwarded to the transport. A queued transport such as SIP may still be playing that audio.

set_framework

set_framework(framework)

Set the framework reference for event routing.

Called automatically when the channel is registered with RoomKit.

on_trace

on_trace(callback, *, protocols=None)

Register a trace observer and bridge to the transport.

resolve_trace_room

resolve_trace_room(session_id)

Resolve room_id from realtime session mappings.

configure

configure(*, system_prompt=None, voice=None, tools=None)

Update channel defaults for future sessions.

Active sessions are not affected — use reconfigure_session for those. A tool under a name the channel, its reasoning backend or orchestration serves, or given twice, is refused (RFC §21.1, §12.4.1), and so is a name no vendor accepts (RFC §6.7).

start_audio_stream async

start_audio_stream(session)

Open the realtime audio path on the provider.

Low-level escape hatch for opening the audio stream without injecting any text. Most callers should use inject_text(..., start_audio_stream=True) instead — that composes the open + inject in a single call. No-op on providers that don't need it.

on_room_attached async

on_room_attached(room_id, binding)

Compile the patterns that find a copy of a runtime mark as the channel joins a room, off the event loop: the first injection of a session (a greeting) then does not wait for them (RFC §6.4).

inject_text async

inject_text(session, text, *, role='user', silent=False, start_audio_stream=False, chain_depth=0)

Inject a text turn into the provider session.

Parameters:

Name Type Description Default
session VoiceSession

The active voice session.

required
text str

Text to inject.

required
role str

The intent (RFC §12.4) — "system" for an instruction, "user" for content. Anything that directs the model, an opening greeting included, is "system": a full-duplex provider voices "user" text as its own.

'user'
silent bool

If True, add to conversation context without requesting a response. The agent sees the text on its next turn but does not react immediately.

False
start_audio_stream bool

If True, open the realtime audio path on the provider before sending the text. Set this on the first inject in outbound flows where the app speaks first (e.g. SIP dial greetings); no-op on providers that don't need priming (OpenAI, xAI).

False
chain_depth int

The chain depth of what the text stands for; the model's answer to it is one deeper (RFC §12.4). 0, the default, for text that opens a chain.

0

A copy of a runtime mark in text is replaced before the provider takes it (RFC §6.4).

A session the channel no longer serves is not sent to: not_sent / realtime_session_gone, as on a conference.

inject_image async

inject_image(session, image_data, mime_type='image/png', *, prompt='', silent=False)

Inject an image into the provider session for multimodal analysis.

Parameters:

Name Type Description Default
session VoiceSession

The active voice session.

required
image_data bytes

Raw image bytes.

required
mime_type str

MIME type of the image.

'image/png'
prompt str

Optional text prompt accompanying the image, a copy of a runtime mark in it replaced (RFC §6.4).

''
silent bool

If True, add to context without requesting a response.

False

start_session async

start_session(room_id, participant_id, connection, *, metadata=None, organization_id=None)

Start a new realtime voice session.

Connects both the transport (client audio) and the provider (AI service), then fires a framework event.

Parameters:

Name Type Description Default
room_id str

The room to join.

required
participant_id str

The participant's ID.

required
connection Any

Protocol-specific connection (e.g. WebSocket), or an awaitable resolving to it. With an awaitable, provider setup starts while the connection is pending (e.g. SIP ringing). Cancel the start task if the connection will never arrive. Both branches belong to this session and are rolled back on failure. The channel publishes the session only when both are ready. Provider callbacks wait for publication in a bounded startup queue; exceeding 2 MiB of audio or 500 events aborts startup. Monotonic timestamps are written to metadata's connection_timing for measuring setup and pre-answer cost.

required
metadata dict[str, Any] | None

Optional session metadata. May include overrides for system_prompt, voice, tools, temperature.

None
organization_id str | None

The organization the caller acts for (RFC §17.2). The room is read scoped to it before the session exists: another organization's room is not found, and none of its recordings is told of the session. Left unset, the room is not read.

None

Returns:

Type Description
VoiceSession

The created VoiceSession.

Raises:

Type Description
RoomNotFoundError

organization_id is set and the room is missing, another organization's, or unreadable because the channel is not registered with a framework.

end_session async

end_session(session)

End a realtime voice session.

Disconnects both provider and transport, fires framework event.

Concurrent callers share one teardown: the first owns it, and a caller that arrives while it runs (close(), the transport's disconnect callback, a hangup tool) waits for it instead of tearing the session down a second time. A joiner learns that the teardown ended, not how: the owner's exception is the owner's. The work stays in the owner's task, so a tool that is the first to end its own session is still the current task the tool sweep spares; a joiner cancelled while waiting leaves the owner's teardown untouched; and a call re-entered from the teardown itself (a handler of the ended event ending the session it is told about) returns at once instead of waiting on its own task. A subclass's own teardown rides _before_session_teardown and is covered by the same arbitration.

Parameters:

Name Type Description Default
session VoiceSession

The session to end.

required

reconfigure_session async

reconfigure_session(session, *, system_prompt=None, voice=None, tools=None, temperature=None, provider_config=None)

Reconfigure an active session with new agent parameters.

Used during agent handoff to switch the AI personality, voice, and tools. Providers with session resumption (e.g. Gemini Live) preserve conversation history across the reconfiguration.

Parameters:

Name Type Description Default
session VoiceSession

The active session to reconfigure.

required
system_prompt str | None

New system instructions for the AI.

None
voice str | None

New voice ID for audio output.

None
tools list[dict[str, Any]] | None

New tool/function definitions; one under a name no vendor accepts raises ValueError, the session left as it was.

None
temperature float | None

New sampling temperature.

None
provider_config dict[str, Any] | None

Provider-specific configuration overrides.

None

connect_session async

connect_session(session, room_id, binding)

Accept a realtime voice session via process_inbound.

Delegates to :meth:start_session which handles provider/transport connection, resampling, and framework events.

disconnect_session async

disconnect_session(session, room_id)

Clean up realtime sessions on remote disconnect.

update_binding

update_binding(room_id, binding)

Update cached bindings for all sessions in a room.

Called by the framework after mute/unmute/set_access so the audio gate in _pipeline_on_audio_received (pipeline path) or _forward_client_audio (direct path) sees the new state.

handle_inbound async

handle_inbound(message, context)

Not used directly — audio flows via start_session.

on_event async

on_event(event, binding, context)

React to events from other channels — TEXT INJECTION.

A text another channel broadcast enters each session of the room as content its author wrote, quoted after their name, never as the application's instruction, whatever the event carries (RFC §12.4). The application directs the model with inject_text and the system intent, or kit.deliver(..., instruction=True). Skips events from this channel (self-loop prevention).

deliver async

deliver(event, binding, context)

No-op for realtime voice — content is injected via kit.deliver()'s _deliver_to_realtime_voice path, which calls inject_text directly. Events broadcast through channel.deliver() would double- feed the session.

close async

close()

End owned sessions, unsubscribe callbacks, and close owned resources.

WhatsAppChannel

WhatsAppChannel(channel_id, *, provider=None, default_country_code=None)

Create a WhatsApp transport channel.

Numbers are compared and stored in E.164 (see :func:SMSChannel).

MessengerChannel

MessengerChannel(channel_id, *, provider=None)

Create a Facebook Messenger transport channel.

TeamsChannel

TeamsChannel(channel_id, *, provider=None)

Create a Microsoft Teams transport channel.

HTTPChannel

HTTPChannel(channel_id, *, provider=None)

Create an HTTP webhook transport channel.

TelegramChannel

TelegramChannel(channel_id, *, provider=None)

Create a Telegram Bot transport channel.

WhatsAppPersonalChannel

WhatsAppPersonalChannel(channel_id, *, provider=None, default_country_code=None)

Create a WhatsApp Personal transport channel (neonize).

Numbers are compared and stored in E.164 (see :func:SMSChannel). A private chat is its sender's conversation; a group is the group's, answered in the group (its JID under phone_number).

TransportChannel

TransportChannel(channel_id, channel_type, *, provider=None, capabilities=None, recipient_key='recipient_id', defaults=None, address_normalizer=None, replies_to_sender=False, reply_metadata_key=None, requires_recipient=True)

Bases: Channel

Generic transport channel driven by configuration rather than subclassing.

All transport channels (SMS, Email, WhatsApp, Messenger, HTTP) share the same inbound/deliver logic. The only differences are data: which ChannelType, which ChannelCapabilities, which metadata key holds the recipient address, and which extra kwargs to pass to the provider's send() method.

Use the factory functions (SMSChannel, EmailChannel, …) in roomkit.channels for convenient construction.

Initialise a transport channel.

Parameters:

Name Type Description Default
channel_id str

Unique identifier for this channel instance.

required
channel_type ChannelType

The channel type (SMS, email, etc.).

required
provider Any

Provider that handles external delivery (e.g. ElasticEmailProvider).

None
capabilities ChannelCapabilities | None

Media and feature capabilities for this channel.

None
recipient_key str

Binding metadata key that holds the recipient address.

'recipient_id'
defaults dict[str, Any] | None

Default kwargs passed to provider.send(). If a default value is None, the actual value is read from the binding metadata at delivery time.

None
address_normalizer Callable[[str], str] | None

How this channel writes an address (E.164 for the channels addressed by phone number); unchanged if omitted.

None
replies_to_sender bool

Whether a reply goes to the address the correspondent writes from (a phone number, an email address), rather than to a chat or a URL. Then the framework records an inbound sender as the binding's recipient, and a binding's recipient names its correspondent: the router admits no one else through it (RFC §10.4).

False
reply_metadata_key str | None

On a channel that replies to a chat or a conversation rather than to the sender, the inbound message's metadata key naming it (chat_id on Telegram): a room opened for a message replies there, and the chat is the message's conversation (:meth:conversation_address): its room is found by the chat, not by the sender, and a binding's recipient names its chat as a phone channel's names its correspondent. With replies_to_sender too (WhatsApp Personal), a message carrying the key is its chat's (a group), any other its sender's (a private chat).

None
requires_recipient bool

Whether a delivery needs a recipient in the binding's metadata. A channel whose provider delivers to a destination of its own (a webhook's configured URL) passes False: its provider is called without one (RFC §10.2 step 3d).

True

info property

info

Return non-None default values as channel info metadata.

capabilities

capabilities()

Return the channel's media and feature capabilities.

recipient_metadata

recipient_metadata(address)

{recipient_key: address} when the recipient is a conversation's address.

handle_inbound async

handle_inbound(message, context)

Convert an inbound message into a room event.

deliver async

deliver(event, binding, context)

Deliver an event to the external recipient via the provider.

The recipient address is read from binding.metadata[recipient_key]. Extra kwargs are built from defaults: fixed values are passed as-is, None defaults are resolved from binding metadata at delivery time.

WebSocket Streaming

StreamStart

Bases: BaseModel

Sent when a streaming response begins.

StreamChunk

Bases: BaseModel

Sent for each text delta during streaming.

StreamEnd

Bases: BaseModel

Sent when a streaming response completes.

StreamMessage module-attribute

StreamMessage = StreamStart | StreamChunk | StreamEnd | StreamError

StreamSendFn module-attribute

StreamSendFn = Callable[[str, StreamMessage], Coroutine[Any, Any, None]]