Realtime Voice (Speech-to-Speech)¶
RoomKit's RealtimeVoiceChannel enables speech-to-speech AI conversations using providers like OpenAI Realtime API, Google Gemini Live, xAI Grok Realtime, and ElevenLabs Conversational AI. Audio flows directly between the client and the AI provider — no separate STT/TTS stages.
RealtimeVoiceChannel is the 1:1 surface — one human, one session. The same providers also join multi-party meetings as the conference's intelligence, mixed N→1 onto one session per room: see Speech-to-speech in a conference (RFC §12.10.12).
How It Differs from VoiceChannel¶
| Aspect | VoiceChannel | RealtimeVoiceChannel |
|---|---|---|
| Audio flow | Client → STT → AI → TTS → Client | Client ↔ Provider (direct) |
| VAD | Client-side (AudioPipeline) | Server-side (provider) |
| Transcription | Generated by STT provider | Provided by realtime provider |
| Tool calling | Via AIChannel + hooks | Direct provider callbacks |
| Latency | Higher (STT + AI + TTS) | Lower (single round-trip) |
Quick Start¶
from __future__ import annotations
from roomkit import RealtimeVoiceChannel, RoomKit
from roomkit.voice.backends.mock import MockVoiceBackend
from roomkit.providers.openai.realtime import OpenAIRealtimeProvider
provider = OpenAIRealtimeProvider(
api_key="sk-...",
) # model defaults to gpt-realtime-2.1
transport = MockVoiceBackend()
channel = RealtimeVoiceChannel(
"voice-realtime",
provider=provider,
transport=transport,
system_prompt="You are a helpful voice assistant.",
voice="alloy",
input_sample_rate=16000,
output_sample_rate=24000,
emit_transcription_events=True,
)
kit = RoomKit()
kit.register_channel(channel)
RealtimeVoiceChannel Configuration¶
channel = RealtimeVoiceChannel(
channel_id="voice-realtime",
provider=provider, # RealtimeVoiceProvider
transport=transport, # VoiceBackend
system_prompt="...", # AI instructions
voice="alloy", # Provider voice preset
tools=[my_tool], # Tool objects — definitions + handlers extracted automatically
temperature=0.7, # Generation temperature
input_sample_rate=16000, # Audio from client
output_sample_rate=24000, # Audio to provider
transport_sample_rate=None, # Transport rate (auto-resamples if different)
emit_transcription_events=True, # Emit transcriptions as RoomEvents
tool_handler=my_tool_handler, # Optional — for MCP, auditing, or custom dispatch
mute_on_tool_call=False, # Mute mic during tool execution
tool_result_max_length=16384, # Truncate large tool results
)
| Parameter | Default | Description |
|---|---|---|
provider |
required | Realtime AI provider (OpenAI, Gemini, xAI Grok, ElevenLabs, Deepgram) |
transport |
required | Audio transport backend |
system_prompt |
None |
AI system instructions |
voice |
None |
Voice preset name |
tools |
None |
Tool objects or JSON definitions — when Tool objects are passed, definitions and handlers are extracted automatically |
temperature |
None |
Sampling temperature |
input_sample_rate |
16000 |
Client → provider sample rate |
output_sample_rate |
24000 |
Provider → client sample rate |
transport_sample_rate |
None |
Transport rate; auto-resamples if mismatched |
emit_transcription_events |
True |
Create RoomEvents from transcriptions |
tool_handler |
None |
async (name: str, args: dict) -> str — optional when Tool objects are passed via tools |
mute_on_tool_call |
False |
Mute mic while a tool runs: it stays muted until the last call in flight ends |
tool_result_max_length |
16384 |
Max chars of a tool result the model reads: a handler's, a refusal, a Tool Search result, a skill reference. An activated skill's instructions and a complete schema read with list_tools(name=...) are never cut |
Every tool call, whatever door it came through (the provider's function call, a
call recovered from speech, a reasoning backend's call), is served the same way:
the pre-execution gate, the handler inside the tool call context, ON_TOOL_CALL,
the bound above, then its one result and its one report. An id names its call
until the call's result goes out: a second call under it before then is
refused and reported, and sends nothing, since the id's result is the first
call's; a call under it after then is a new call and gets its own answer, even
while the first is still finishing its report. Every provider frees the id as
it sends the result, or as it reports the call abandoned, and the channel frees
it at the same step: nothing is sent for an abandoned call, its observers told
or not, and a call issued under its id is a new one. So does a call a reconnect
its own handler caused, which runs on: the new connection never issued its id. A call that came without an id, or under an id in flight,
takes the path of any call: on an ended session it is reported cancelled, a
call_tool transport is unwrapped so its report names the tool, and it waits
behind the transcription that precedes it. A call that named no tool reaches
the channel too, which refuses it before the gate and answers under its id.
Ending the session reports each call it interrupted as cancelled.
What the provider owes in return is the same everywhere. A refused, failed or
unserved call goes back through submit_tool_error: ElevenLabs marks it as a
tool error and Gemini Live sends it under the function response's error key
(a served result goes under result, whatever its text), the others send it
like any result, its {"error": ...} body saying what happened. Every call the provider abandons is reported through
on_tool_call_cancelled, whatever the cause: Gemini's cancellation, a
reconnect (a handoff included), an ElevenLabs tool_timeout_s, a connection
lost or closed. The channel cancels its handler and reports it once,
cancelled, and frees its id at once, as the provider does when it reports
the abandonment: a call the vendor issues under that id while the handler
cleans up is a new call, answered, and nothing is sent for the abandoned
one. A provider whose model calls no tool (Anam, PersonaPlex) has
supports_tools set to False: the channel declares it no tool, advertises
no skill or Tool Search in its prompt, and warns once at construction.
A provider reads a call's arguments with the shared rule,
readable_arguments: a mapping when they read as an object, the text the
model wrote when they do not (invalid JSON, an array, a cut fragment). The
channel, and a conference, refuse a call that arrives as text before the gate:
no handler runs on a mapping that only passes for arguments, the model reads
Tool call arguments unreadable, and the observers receive the refusal, as in
the AI channel's tool loop. A wire that says whether the response cut a call
(OpenAI Realtime's and GPT-Live's item status: "incomplete", after the
output cap or a cancel) is read with realtime_call_arguments(raw, cut=...),
exported from roomkit.providers.ai beside readable_arguments: a cut call
runs only when its argument text arrived and reads, and otherwise reaches the
channel marked cut (CutArguments), which the model reads as
Tool call cut off. OpenAI Realtime and xAI hand a call on once its item is
done (response.output_item.done), the first event that carries that status. A provider also checks its endpoint's tool-name
rule when it declares the session's tools, the way an AI provider checks a
turn's (see Tool Calling): an endpoint that accepts the
declaration can still fail the call later, opaquely, once the model reaches
for it. A tool given to the channel or a conference under a name no vendor
accepts is refused at construction, as an AITool is.
A provider keeps the calls it issued on the book RealtimeVoiceProvider
holds per session: _book_tool_call(session, call_id, payload) when the call
arrives (False for a call without an id or under an id in flight, which is
handed on all the same for the channel to refuse), _answerable_tool_call
first in submit_tool_result and submit_tool_error (it returns the payload,
a name or a pending future, and drops with a log a result for an id
abandoned, never issued, or issued by a connection that is gone), and
_abandon_open_tool_calls(session) when a connection ends, which reports
each abandoned call once. A session connected again under the same id
abandons its previous connection's calls first.
from roomkit.providers.ai import readable_arguments, realtime_call_arguments
class MyProvider(RealtimeVoiceProvider):
@property
def supports_tools(self) -> bool:
return True # the default; False declares no tool to the model
async def _on_function_call(self, session, event) -> None:
# A mapping, or the model's text when it does not read as one.
arguments = readable_arguments(event["arguments"])
# A wire that tells a cut reads the call through the cut rule instead:
# realtime_call_arguments(event["arguments"], cut=event["status"] == "incomplete")
# Booked with what answering it takes (here its name); a call that
# cannot be booked is handed on all the same: the channel refuses it.
self._book_tool_call(session, event["id"], event["name"])
await self._fire(
self._tool_call_callbacks, session, event["id"], event["name"], arguments,
label="tool_call",
)
async def submit_tool_result(self, session, call_id, result) -> None:
held, name = self._answerable_tool_call(session, call_id)
if not held:
return # abandoned or never issued: logged, nothing sent
await self._send(session, {"call_id": call_id, "name": name, "output": result})
async def submit_tool_error(self, session, call_id, result) -> None:
# Only when the protocol has an error marker; the default sends
# the result through submit_tool_result.
held, name = self._answerable_tool_call(session, call_id)
if held:
await self._send(
session, {"call_id": call_id, "name": name, "output": result, "is_error": True}
)
async def _start(self, session) -> None:
# A session's own tasks run in a fresh context, not the caller's
# (which may be a tool handler reconnecting the session).
self._receiver = self._session_task(self._receive(session), name="receive")
async def _on_connection_lost(self, session) -> None:
# The calls still open die with the connection: report them, once.
await self._abandon_open_tool_calls(session)
The transport is accepted before the AI provider finishes its handshake, so a caller on a phone line can already be speaking. That audio is buffered in order and flushed the moment the session goes live, up to a bound of roughly thirty seconds of 16 kHz mono PCM; past it, input is dropped with a single warning rather than growing without limit behind a dead handshake. A failure anywhere in that startup — transport, provider, a malformed negotiated sample rate, or the client-ready notification — tears the whole session back down instead of leaving a connection nobody owns.
Tool Search with fixed declarations¶
Tool Search activates automatically when a session's catalogue exceeds
tool_search_threshold (20 by default), decided per session on the tools it
declares: a pipeline's active agent, the tools a session was opened with or a
room's orchestration tools may make one session's catalogue larger than the
channel's. Set tool_search=True to enable it for a smaller catalogue or
False to expose the catalogue directly. Unless tool_search=False, the
channel serves find_tools and list_tools itself, and call_tool on a
provider whose declarations are fixed, so a host tool under one of those names
is refused at construction. A session whose catalogue Tool Search does not
hide declares none of them, and a call to one is refused as undeclared, as on
a text turn.
tool_search_pinned keeps selected tools directly callable alongside discovery.
The tools orchestration adds to the channel stay declared the same way: a voice
supervisor's delegate_workers, a voice loop's delegate_loop,
setup_realtime_delegation's delegate_task, and a voice pipeline's
handoff_conversation. The agent is told to call them, not to find them.
Providers that support mid-session reconfiguration receive native declarations
for the tools matched by find_tools, once the call is served and its result
went out. ON_TOOL_CALL's SYNC hooks judge find_tools and list_tools
before the model reads them, as any other call: a hook that blocks the search
reveals nothing and reconfigures nothing, and a hook's replacement is what the
model reads. Providers with fixed declarations, such as Gemini 3.1 Live, use
three stable functions instead:
| Function | Result |
|---|---|
find_tools(query) |
Matching tool names and short descriptions; queries use English keywords |
list_tools(name="calendar") |
One tool's complete description and parameter schema |
call_tool(name="calendar", arguments_json='{"action":"list"}') |
The result of calling the named tool with the decoded arguments |
Without name, list_tools(category=...) returns the compact inventory of
every tool the session can call, declared or hidden (the orchestration and
skill tools included, the policy's refusals and skill-gated tools left out), as
on a text turn; on a fixed-declaration provider, list_tools(name=...) and
call_tool reach every tool it lists. A schema lookup returns one complete JSON document and is not cut by
the business tool-result length limit. The tool name and an operation such as
list are separate: the operation belongs in the tool's arguments when its
schema declares one.
This transport does not reconnect the session after a search and does not add
permissions. Supply only the session's authorized catalogue. Unknown and
excluded names both return an unavailable-in-this-session refusal. Arguments,
skill gates, and BEFORE_TOOL_USE are checked before the same handler used by
native calls. ON_TOOL_CALL observes the real tool name and arguments with the
provider's original call ID. Invalid arguments, explicit gate refusals, and
execution failures remain distinguishable.
call_tool is reserved when fixed-declaration Tool Search is active; a caller
catalogue containing that name is rejected. Infrastructure functions are called
directly rather than recursively through call_tool.
Ending a session cancels its in-flight tool tasks and rejects late calls. It does not cancel another session's tasks or undo external effects that have already occurred.
The realtime Tool Search example
runs Gemini Live against 112 fictional tools, captures tool-call traces and
speech, and verifies calendar and project operations in one connection. Its
input is injected text; it checks the Live tool protocol rather than speech
recognition. It requires GEMINI_API_KEY and performs no business mutations.
Passing provider_config¶
Everything provider-specific — VAD tuning, transcription model, Gemini's
sensitivities, an ElevenLabs first message — travels in one dict called
provider_config. It is not a channel constructor argument: it reaches the
provider through the session metadata, so two sessions on the same channel can
run different settings.
provider_config = {
"turn_detection_type": "semantic_vad", # keys are provider-specific
}
session = await channel.start_session(
room_id="room-1",
participant_id="caller-1",
connection=None,
metadata={"provider_config": provider_config},
)
Every provider_config snippet below shows only the dict — pass it as
metadata={"provider_config": ...} exactly as above. Unknown keys are ignored
silently, so a typo produces default behaviour rather than an error: check the
key against the provider reference.
server_vad is not a provider_config key
Whether the provider does its own endpointing is derived from the channel's
pipeline, not from provider_config: a pipeline carrying a VAD stage puts
OpenAI, Gemini and Grok in manual mode (the channel sends the activity
signals from the local VAD), and without one their server-side VAD runs.
ElevenLabs and Deepgram always do their own turn-taking — Deepgram warns
that it ignored the request, ElevenLabs takes it silently.
What inject_text's role means¶
The role is an intent, not a wire field: each provider maps it onto what its API offers.
role="system"is an instruction — how to behave, or what to do now.role="user"is content: a user turn the model answers on a turn-based provider, words the model says aloud on a full-duplex one.role="assistant"is a line the agent says now (a configured greeting). A provider with a speech primitive uses it (Deepgram'sInjectAgentMessage, Anam'stalk); the others ask the model to say it. It never reaches the model as the user's words, which the model would answer.silent=Trueadds any of them as context without asking for a response.
| Intent | OpenAI Realtime | Gemini Live | OpenAI GPT-Live |
|---|---|---|---|
system |
system message, then a response |
a user turn (no system role in turns) |
instructions append |
user |
user message, then a response |
user turn |
commentary: said aloud, paraphrased |
assistant |
system message asking to say the line, then a response |
user turn asking to say the line |
instructions append asking to say the line |
silent |
the message, no response | turn left incomplete; a marked context text once audio flows | thinking append |
So anything that directs the model is role="system", an instruction to
greet included; a greeting the application wrote itself is role="assistant". Sent as role="user", a full-duplex model says it — or improvises
around it — instead of following it:
# Right on every provider
await channel.inject_text(session, "Greet the user by first name, then listen.", role="system")
# Wrong on GPT-Live: the model voices the instruction as its own words
await channel.inject_text(session, "Greet the user by first name, then listen.")
Which model is speaking¶
provider.model_name names the model behind a session, for a log line, a span
attribute or a diagnostic. Read it as the best identifier this provider can
give, not as a guaranteed model id: a service that exposes no end-to-end model
falls back to the provider name.
| Provider | model_name |
|---|---|
OpenAIRealtimeProvider |
the realtime model it connects to (e.g. gpt-realtime-2.1) |
GeminiLiveProvider |
the Live model it connects to |
XAIRealtimeProvider |
the Grok realtime model it connects to |
DeepgramAgentProvider |
its think model — the stage that decides what the agent says, not its listen or speak stage |
AnamRealtimeProvider |
the persona's llm_id when the persona is inline; the provider name when the persona lives in Anam Lab |
ElevenLabsRealtimeProvider |
the provider name — the agent is configured in the dashboard |
PersonaPlexRealtimeProvider |
the provider name — one self-hosted model, no id to give |
A provider written outside RoomKit inherits the default and needs no change;
override the property when your service names a model. MockRealtimeProvider()
reports the default, and MockRealtimeProvider(model="…") stands in for a
provider that names one — so a test can exercise either shape.
What a session reports as usage¶
What a spoken turn consumed is known to the service that billed it, so the provider reports it and RoomKit relays it unaltered (RFC §12.4.2). Two public surfaces carry that report.
on_usage(callback) — fired on every report. This is the one to bill a
call from:
def record(session, usage):
cached = usage.get("cached_content_token_count", 0)
ledger.add(session.id, fresh=usage["input_tokens"] - cached, cached=cached)
provider.on_usage(record)
The callback takes (session, usage), may be sync or async, and runs on a task
of its own — a slow listener never holds up the provider's event loop, and one
that raises is logged without reaching the others or the session.
session.last_usage — the snapshot the last report left behind. Empty until
the first report, and a copy: writing to it changes nothing.
async def turn_done(session):
logger.info("turn cost %s", session.last_usage)
provider.on_response_end(turn_done)
Read it at the end of a turn, and only there. The next report replaces it, and
the realtime channel clears it once it has closed the turn's telemetry span, so
a ledger built by polling comes out short — that is what on_usage is for.
What the keys mean¶
input_tokens and output_tokens are the only two keys the framework fixes.
Everything beside them is the provider's own, under the names its API uses, and
an absent key means unreported, not zero.
| Provider | Keys beside the two totals |
|---|---|
OpenAIRealtimeProvider |
input_token_details, output_token_details — the API's own dicts (cached, text and audio tokens) |
GeminiLiveProvider |
cached_content_token_count, thoughts_token_count, tool_use_prompt_token_count, total_token_count, plus the modality maps prompt_tokens_details, cache_tokens_details, response_tokens_details and tool_use_prompt_tokens_details, each {"AUDIO": n, "TEXT": n} |
OpenAILiveProvider (GPT-Live) |
live_seconds — this model bills session duration, never restated as tokens — and, in hosted delegation, backend with the backend model's own tokens |
| Other providers | none: the two totals, when the service reports them at all |
A spoken turn is mostly audio, the modalities are priced apart and the cached share is priced apart again, so the two totals alone cannot say what a session spent its context on. A host that prices a call reads the breakdown; one that only logs a number does not have to.
examples/realtime_usage_accounting.py runs the whole thing on the mock
provider — no key, no microphone — including what a poller misses.
OpenAI Realtime API¶
WebSocket-based speech-to-speech with server-side VAD.
from __future__ import annotations
from roomkit.providers.openai.realtime import OpenAIRealtimeProvider
provider = OpenAIRealtimeProvider(
api_key="sk-...",
model="gpt-realtime-2.1", # default; -2.1-mini is cheaper
base_url=None, # Custom endpoint (optional)
)
Reasoning-capable models (gpt-realtime-2 and later) accept a reasoning
effort through provider_config:
Omit the key and the field never reaches the session, which is what
non-reasoning models need. reasoning_effort and image_detail are validated
against their allowed values, so a typo raises ValueError at connect or
reconfigure() time instead of travelling to the API verbatim.
Images¶
gpt-realtime-2.1 and later accept image input, so a picture can be put in
front of the model inside the live conversation:
await channel.inject_image(
session,
image_bytes,
"image/png", # PNG and JPEG only
prompt="What do you see here?", # optional, travels in the same item
)
An "image_detail": "low" in provider_config trades fidelity for tokens. Left
unset, the API's own default applies, which resolves to high detail — worth
setting explicitly on a session that injects frames repeatedly. It is local
input policy rather than a session.update field, so a reconfigure() that
changes only image_detail is applied to the live session without sending
anything on the wire.
Pass silent=True to add the image as context without asking for a spoken
answer. Providers without image support (xAI Grok) raise NotImplementedError,
which RealtimeVoiceChannel catches and logs.
VAD Configuration¶
Turn detection is configured with flat provider_config keys — the provider
assembles the API's nested turn_detection object itself:
# Semantic VAD — the default
provider_config = {
"turn_detection_type": "semantic_vad",
"eagerness": "high", # low, medium, high, auto
"interrupt_response": True,
"create_response": True,
}
# Server VAD (energy-based)
provider_config = {
"turn_detection_type": "server_vad",
"threshold": 0.5,
"silence_duration_ms": 800,
"prefix_padding_ms": 300,
"idle_timeout_ms": 8000, # optional; turn times out on silence
"interrupt_response": True,
"create_response": True,
}
# Manual turn management (no automatic VAD)
provider_config = {"turn_detection_type": None}
Left unset, turn_detection_type defaults to semantic_vad on a channel whose
pipeline has no VAD stage (and to no turn detection at all when it has one — see
the note above). RoomKit sends an explicit turn_detection: null when local VAD
is selected; merely omitting the field would leave OpenAI's default server VAD
active. semantic_vad improves semantic end-of-turn timing, but its
speech_started event is not an acoustic echo filter. Speaker leakage must
still be removed by AEC or gated before the audio reaches OpenAI.
threshold, silence_duration_ms, prefix_padding_ms and idle_timeout_ms
apply to server_vad only; eagerness applies to semantic_vad only.
interrupt_response and create_response work with both.
Local speaker and microphone example¶
The local OpenAI example uses WebRTC AEC and continuous WebRTC noise suppression by default:
| Variable | Default | Description |
|---|---|---|
AEC |
webrtc |
webrtc, speex, or 0 to disable echo cancellation |
DENOISE |
webrtc |
webrtc, rnnoise, sherpa, or 0 to disable continuous noise suppression |
AEC_DELAY_MS |
0 |
Fixed speaker-to-microphone delay in milliseconds; 0 lets the local backend give AEC3 the delay its devices' clocks report, on every captured frame |
BARGE_IN_GUARD_MS |
2000 |
Silence sent to provider-side VAD after physical playback starts while local AEC converges; 0 disables the guard |
MUTE_MIC |
1 |
1 mutes capture during assistant playback (stable half-duplex); 0 opts into full-duplex/barge-in |
OPENAI_LANGUAGE |
automatic | Optional input transcription language such as fr; useful when ambiguous noise is decoded in another script |
Keep DENOISE=webrtc when using open speakers. OpenAI's server VAD cancels an
active response when it detects new speech; without continuous local noise
suppression, residual echo at the beginning of a playback turn can therefore
produce a false interruption. This stage complements OpenAI's far_field
input noise reduction and runs on every captured frame, whether the AEC is
active or not.
OpenAI may finish generating (response.done) several seconds before the local
speaker drains its buffered audio. Its WebSocket VAD can otherwise hear that
still-playing response, commit it as a new user turn, and start a reply loop.
The example therefore defaults to MUTE_MIC=1: stable half-duplex operation,
with no barge-in while the assistant speaks. Use MUTE_MIC=0 only with
headphones or a calibrated full-duplex AEC setup. AEC_DELAY_MS should include
the speaker and microphone device latency when a fixed value is used. With
AEC_DELAY_MS=0, LocalAudioBackend seeds AEC3 with the sum of PortAudio's
two stream latencies before playback, then, from the first captured frame,
tells it the delay the devices' clocks give (outputBufferDacTime,
inputBufferAdcTime), as browsers do: on a MacBook the latencies added up to
296 ms where the clocks put the delay at 42 ms. A host API that reports no
clock keeps the seed.
In full-duplex mode, BARGE_IN_GUARD_MS=2000 protects only playback onset.
During that interval, RoomKit still processes and records the real microphone
signal, so AEC and noise suppression continue converging, but it forwards
equal-duration PCM silence to OpenAI. After two seconds, normal barge-in resumes.
Set the value to 0 for an echo-free path if interruptions must work from the
first sample.
Fragments such as はい, Cyrillic words, or other unexpected scripts in a user
transcription are not byte-decoding corruption: they are the transcription
model interpreting ambiguous residual audio while language detection is
automatic. Removing the false VAD turn fixes the source. For a monolingual
session, OPENAI_LANGUAGE=fr additionally constrains input transcription and
improves recognition accuracy.
WebSocket interruption context¶
OpenAI generates audio faster than real time, while RealtimeVoiceChannel
controls physical playback. On a true barge-in, RoomKit now measures elapsed
playback from the transport callback and sends conversation.item.truncate for
the latest assistant audio item. The duration is capped at the amount of audio
actually received, preventing an API error after a playback underrun. This
keeps OpenAI's conversation context aligned with what the user heard even when
response.done arrived before the speaker finished.
Tool results and the caller's turn¶
The model is asked to go on (response.create) once per response, when every
call it made has its result. That request waits while the caller is
speaking: between the pipeline VAD's speech start and end in manual mode, between
the server VAD's speech_started and speech_stopped otherwise. Results that
land during a barge-in are then answered together with what the caller said,
by the request that closes the caller's turn (the server's own, under server
VAD, unless OpenAI's create_response is false). No request goes out while
another is in progress, active or sent and not yet begun, whichever path sends
it: inject_text, the end of the caller's turn, or a continuation. A caller's
turn whose request met a response in progress is answered once that response
ends, unless a request sent since already covers it, and a request the server
rejects with an error no longer counts as in progress. xAI Grok speaks the same
wire and behaves the same way; it has no create_response setting, its server
VAD always answers the turn.
Available Voices¶
alloy, echo, shimmer, breeze, cinnamon, juniper, sage (varies by model)
Input Transcription¶
# Configure which model transcribes user input
provider_config = {
"stt_model": "gpt-4o-transcribe",
}
OpenAI GPT-Live (full-duplex)¶
GPT-Live (gpt-live-1) is a different API from OpenAI Realtime. The model
listens and speaks at the same time, handles being interrupted by itself, and
holds no tools: it delegates reasoning and tool use to a backend model while
the conversation continues (RFC §12.4.1).
from __future__ import annotations
from roomkit.providers.openai.live import HostedReasoning, OpenAILiveProvider
provider = OpenAILiveProvider(
api_key="sk-...",
model="gpt-live-1", # default
delegation=HostedReasoning( # OpenAI runs the backend model
model="gpt-5.6-terra",
instructions="You are the backend of a voice assistant. ...",
reasoning_effort="low",
),
turn_gap_ms=800, # quiet that closes a synthesized turn
)
Install with pip install roomkit[realtime-openai] (the websockets extra).
What changes on a full-duplex session¶
- No barge-in path.
RealtimeVoiceChannelreadsprovider.full_duplexand leaves interruption to the model: no playback flush, nointerrupt()ortruncate_audio(), no gating of the model's audio while the user speaks. Keep an AEC (or headphones) so the model does not hear itself; the microphone must stay open during playback for the model to be talked over. - Boundaries are synthesized. The wire carries no response or speech
events. The provider groups transcript deltas per speaker and closes a turn
after
turn_gap_msof quiet; that is when the final transcription andon_response_endfire. Partial transcriptions carry deltas for both roles. - A pipeline VAD is observation only. Its events still reach
ON_SPEECH_START/ON_SPEECH_END, but they never interrupt the model, andInterruptionConfighas no effect. - The session is fixed at start. Model, instructions, voice, audio format
and delegation mode are set once by
session.start.reconfigure()appends a changed system prompt to the instructions and, in hosted mode, updates the backend's tools without replacing the session; a voice or codec change reconnects, and the hosted calls still open on the old connection are reported cancelled.supports_mid_session_reconfigureisFalse, so skills default toinline_full. - Text injection is paraphrased.
inject_text(role="system")appends instructions;role="user"adds spoken context the model relays in its own words, or silent context withsilent=True. There is no user turn once the session started, so an instruction — the opening greeting included — isrole="system", which is how OpenAI documents having the model speak first (see Whatinject_text's role means). Long texts are split at the API's 500-token per-append bound, and a text RoomKit set apart in a frame keeps it in each append: a<worker_output>block is closed and opened again around every piece, a quote such asMarie: “…”too, after its author (RFC §12.4.1).inject_image()is not available. - One audio format for both directions, chosen from the channel's
output_sample_rate: PCM at 16 or 24 kHz, or G.711 at 8 kHz withprovider_config={"codec": "pcmu"}or"pcma". A differentinput_sample_rateis resampled by the provider. - Usage is seconds, not tokens.
session.last_usage["live_seconds"]carries the cumulative live duration; a hosted backend's tokens land undersession.last_usage["backend"]with the backend model's name. Both are reported throughon_usageas they land — see What a session reports as usage.
Two delegation modes¶
| Mode | Who runs the backend | Tools | Results |
|---|---|---|---|
HostedReasoning(model=...) |
OpenAI (Responses API) | the channel's tools, served by tool_handler / ON_TOOL_CALL as usual |
function results via submit_tool_result; the backend resumes once every call of a response is answered |
IntegratorReasoning() (default) |
your ReasoningBackend on the channel |
the channel's tools, executed by the backend through the channel gate |
text, relayed as spoken or silent context |
See the Reasoning Delegation guide for the
integrator mode. ON_REALTIME_DELEGATION fires for both modes with the
delegation id and its target ("hosted" or "integrator").
provider_config keys¶
codec (pcm, pcmu, pcma) and history (a list of {"role", "text"}
text messages seeding the session, at most 128).
Voices, pace and tone¶
The voice is fixed for the session (voice= on the channel or in the
session metadata); changing it means a new session, which is what
reconfigure(voice=...) does. Fourteen voices: marin (default) and cedar,
plus regional ones — quartz, ripple (Australian), vesper (British), willow,
stone (Irish), gleam, meridian (North American), delta, cinder (Southern US),
beacon (Filipino), bossa, tempo (Brazilian Portuguese). available_voices()
lists them with language and gender. Custom voices from your own recording
exist on the API but need OpenAI's approval.
There is no speed parameter. Delivery is steered through the instructions, and GPT-Live follows them closely — OpenAI's own prompting guide suggests:
Speak warmly and naturally, at an unhurried pace. Be clear and direct, not
overly cheerful. For routine questions, give one or two short sentences.
Use moderate backchannels. Stop speaking when the user interrupts.
Pace and tone can also be adjusted mid-session, since instructions are appended rather than replaced:
Pronunciation follows the same route ("Say Rosalia as roh-sah-LEE-ah"), and instructions written in the target language steer the spoken language.
Local speaker and microphone example¶
examples/realtime_voice_local_openai_live.py (hosted backend, weather tools)
and examples/realtime_voice_local_openai_live_backend.py (Claude backend,
three-step rebooking).
Google Gemini Live¶
Persistent streaming connection with session resumption and advanced features.
from __future__ import annotations
from roomkit.providers.gemini.realtime import GeminiLiveProvider
provider = GeminiLiveProvider(
api_key="your-gemini-key",
model="gemini-3.8-live",
)
gemini-3.8-live is the default. gemini-3.8-live-extended-thinking is the
same model with configurable reasoning; both replaced
gemini-3.1-flash-live-preview, which stays in the catalog flagged deprecated
so an existing deployment keeps resolving.
What changed with the 3.8 generation¶
Google changed four contracts. RoomKit absorbs all four, so a configuration written for 3.1 keeps working on 3.8 — but knowing which is which explains why some settings silently stop applying.
| Contract | Through 3.1 | From 3.8 |
|---|---|---|
| End of response | turn_complete |
the model speaks several times per request; only interaction_status IDLE ends it |
| Affective dialog | enable_affective_dialog |
removed from the API |
| Proactive audio | proactive_audio toggled it |
permanently on, stating it is an error |
| Tool calls | blocking by default | run in the background while the model keeps talking |
A field the target model no longer accepts is dropped with a WARNING naming
the field and the model, never forwarded — a refused setup takes the whole
session down, and an inherited config is not a reason to lose the session.
Advanced Configuration¶
Gemini supports several unique features via provider_config (the voice itself
stays a channel parameter — voice="Aoede"):
provider_config = {
# VAD sensitivity — LOW or HIGH; the API has no MEDIUM
"start_of_speech_sensitivity": "HIGH",
"end_of_speech_sensitivity": "LOW",
"silence_duration_ms": 500,
# Reasoning — gemini-3.8-live-extended-thinking only, where it is
# REQUIRED: that model answers a missing level with 1007 and closes the
# socket. RoomKit falls back to `low` when you name none.
# low | medium | high. `minimal` is refused before the round trip.
"thinking_level": "medium",
# Which input the server folds into a turn. 3.8 defaults to
# TURN_INCLUDES_AUDIO_ACTIVITY_AND_ALL_VIDEO, which bills every frame
# on a video session.
"turn_coverage": "TURN_INCLUDES_ONLY_ACTIVITY",
# When a background tool result is delivered. Opt-in, and nothing is
# sent by default: gemini-3.8-live-extended-thinking closes the session
# with 1007 over this field, and the models that take it deliver a
# background result sensibly without it.
# WHEN_IDLE | INTERRUPT | SILENT
"tool_response_scheduling": "WHEN_IDLE",
# Inbound transcription
"transcription": {
"language_auto": True, # switch language mid-conversation
"language_hints": ["fr-FR", "en-US"],
"custom_vocabulary": ["RoomKit", "Aubervilliers"],
"diarization": True,
},
# Generation parameters
"top_p": 0.8,
"top_k": 40,
"max_output_tokens": 2048,
# Non-interruptible mode
"no_interruption": True,
# Language
"language": "en-US",
# Pre-3.8 only — dropped with a warning on the 3.8 family
"proactive_audio": True,
"enable_affective_dialog": True,
"thinking_budget": 1024,
}
| Feature | Description |
|---|---|
| Background tool calls | 3.8 runs tools while the model keeps speaking |
| Thinking level | Reasoning depth on extended-thinking, where it is required (RoomKit defaults it to low) |
| Automatic language switch | language_auto follows the caller mid-call |
| Custom vocabulary | Bias transcription towards your own terms |
| Turn coverage | Which input the server bills into a turn |
| Session resumption | Preserves context across reconfiguration |
| Non-interruptible | Prevent user barge-in during responses |
| Proactive audio | Pre-3.8 toggle; permanently on from 3.8 |
| Affective dialog | Pre-3.8 only; removed from the API |
| Thinking budget | Pre-3.8 only; replaced by thinking_level |
Background tool calls¶
From 3.8 a tool call no longer freezes the conversation. RoomKit declares the
tools NON_BLOCKING, so the model can say it is checking and carry on while
the work runs. The result's delivery is left to the model: scheduling is
sent only if you ask for it, because gemini-3.8-live-extended-thinking
refuses the field outright and closes the session over it.
A single tool can opt back into the old behaviour where the model allows it,
by carrying behavior in its declaration:
tools = [{"name": "charge_card", "description": "...", "parameters": {...},
"behavior": "BLOCKING"}]
gemini-3.8-live-extended-thinking answers a hard error to BLOCKING, so
RoomKit downgrades the request there and says so in a warning rather than
losing the session.
examples/realtime_background_tools_mic.py is the one that makes it obvious:
ask for a stock level out loud and you hear the agent keep talking through a
six-second lookup, with every turn that overlapped the call marked in the
terminal. examples/realtime_background_tools.py proves the same thing without
a microphone, for CI and for a machine with no audio device.
A background call can be overtaken by the caller. Talk over the model while
the lookup runs and Gemini sends tool_call_cancellation: it will not read
the result. RoomKit cancels the handler's task (your handler sees
asyncio.CancelledError at its next await), drops any result that still
arrives, and reports the call to ON_TOOL_CALL's async observers with
cancelled=True beside is_error=True. A call orphaned by a reconnect takes
the same path, blocking or not: call ids are connection-scoped, and the new
socket never issued them. Driving the provider directly, register
provider.on_tool_call_cancelled(callback), called as (session, call_ids).
examples/realtime_tool_call_cancelled.py walks the whole path on the mock
provider, with no key needed. The other providers have no such event, but
fire the callback for the calls they drop themselves: ElevenLabs for a call
its tool_timeout_s cuts, GPT-Live for the calls a restart drops, and every
provider for the calls still open when its connection is lost or closed.
A call the model could not write¶
Gemini Live can end a turn on a function call it failed to produce, with
turn_complete_reason set to MALFORMED_FUNCTION_CALL: no call arrives, and
the turn would end in silence. RoomKit tells the model, as a system
injection, that its call did not run and that it should call the tool again
with valid arguments or answer without it. It does so once, until the user
speaks again, so a model that keeps failing does not loop on the reminder.
Available Voices¶
Aoede, Fenrir, Kore, Pax, Breeze, Charon, Ember, Orion, Stella, and more.
Session Resumption¶
Gemini preserves conversation context when reconfigured: reconfigure()
reconnects with the session's resumption handle. That is what an agent handoff
relies on:
# Start with general assistant
session = await channel.start_session(room_id, participant_id, connection)
# Hand off to specialist — context is preserved
await channel.reconfigure_session(
session,
system_prompt="You are a billing specialist.",
voice="Kore",
tools=billing_tools,
)
gemini-3.8-live keeps the original instruction
A session resumed on gemini-3.8-live runs under the system instruction
it started with: the one a reconfiguration sends is ignored. Measured: an
agent started as "Tina" and reconfigured as "Bill" still answers "Tina"
when asked its name (gemini-3.1-flash-live-preview and 2.5 take the new
instruction). RoomKit covers two cases:
- a session with no conversation yet (nothing sent, nothing heard) reconnects without its handle, so it starts under the new instruction with nothing lost;
- a session with a conversation resumes with its context, and the new
instruction rides its next non-silent
inject_text, which the model follows at once: the greeting of aConversationPipelinehandoff withgreet_on_handoff=True, for one.
After a direct reconfigure_session(system_prompt=...) on a 3.8 session
that has a conversation, follow it with a non-silent
inject_text(..., role="system") (a greeting, an announcement): until
then the model still runs under the old instruction.
xAI Grok Realtime¶
WebSocket-based speech-to-speech using xAI's Grok models with server-side VAD, built-in transcription, and native web/X search tools.
from __future__ import annotations
from roomkit.providers.xai.config import XAIRealtimeConfig
from roomkit.providers.xai.realtime import XAIRealtimeProvider
provider = XAIRealtimeProvider(
XAIRealtimeConfig(
api_key="xai-...",
model="grok-3-fast", # Default model
voice="eve", # Default voice
transcription_model="grok-2-audio", # Input transcription model
)
)
# Or with keyword arguments:
provider = XAIRealtimeProvider(
api_key="xai-...",
model="grok-3-fast",
)
VAD Configuration¶
provider_config = {
"turn_detection_type": "server_vad", # server_vad (default)
"threshold": 0.5,
"silence_duration_ms": 800,
"prefix_padding_ms": 300,
}
Flat keys as on OpenAI, but a narrower set — the four above are all Grok reads,
with no eagerness, idle_timeout_ms, interrupt_response or
create_response — and a different default: server_vad here, semantic_vad
on OpenAI.
Available Voices¶
eve, ara, rex, sal, leo
Native Tools¶
xAI supports native web_search and x_search tools alongside standard function tools:
channel = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
tools=[
{"type": "web_search"}, # Search the web
{"type": "x_search"}, # Search X (Twitter)
weather_tool, # Standard function tool
],
)
Input Transcription¶
# Override transcription model via provider_config
provider_config = {
"transcription_model": "grok-2-audio",
}
Environment Variables (Example)¶
XAI_API_KEY=xai-... uv run python examples/realtime_voice_local_xai.py
XAI_MODEL=grok-3-fast # Model override
XAI_VOICE=ara # Voice override
Mid-session reconfiguration¶
Tool Search, a skill activation and a handoff reconfigure a live session. xAI
does it in band, as OpenAI does, with a partial session.update carrying only
what changes (instructions, voice, tools, temperature): the
conversation survives. A provider_config key the live session cannot take
(turn detection, transcription, audio format) is named in a warning and
applies from the next session.
PersonaPlex and Anam take their prompt and persona only when a session opens,
so they declare supports_mid_session_reconfigure = False: the channel does
not reconnect them for Tool Search or a skill, and their skills are delivered
inline.
ElevenLabs Conversational AI¶
Server-orchestrated speech-to-speech using ElevenLabs agents. STT, LLM, TTS, VAD, and turn-taking are all handled server-side — the provider sends and receives audio, and bridges the agent's client tool calls into RoomKit's tool path. A call keeps the service's id: the SDK folds it into the call's parameters, where a parameter the model writes under tool_call_id would replace it, so the provider keeps the model's parameters apart and the id stands; a tool_call_id the model wrote reaches your handler as an argument, judged against the tool's schema as any.
from __future__ import annotations
from roomkit.providers.elevenlabs.config import ElevenLabsRealtimeConfig
from roomkit.providers.elevenlabs.realtime import ElevenLabsRealtimeProvider
config = ElevenLabsRealtimeConfig(
api_key="xi-...",
agent_id="agent_abc123", # From ElevenLabs dashboard
)
provider = ElevenLabsRealtimeProvider(config)
Agent Setup¶
ElevenLabs agents are pre-configured on the ElevenLabs dashboard with an LLM, voice, knowledge base, and tools. The agent_id identifies which agent to connect to. Runtime overrides for system prompt, voice, and temperature are applied at connection time. The agent's TTS model, Eleven v4 Turbo included, is chosen in its dashboard configuration; RoomKit does not override it.
Audio Format¶
The ElevenLabs SDK reads and writes 16 kHz mono 16-bit PCM, and neither side of
that is negotiable. The provider therefore rejects a channel configured for
anything else at connect() time rather than accepting a clock it cannot honour:
channel = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
input_sample_rate=16000, # required
output_sample_rate=16000, # required — the 24000 default raises ValueError
)
A transport running at another rate is still fine: set transport_sample_rate
and the channel resamples around the provider.
connect() also waits until the SDK has installed its audio-input callback
before reporting the session active, so speech arriving in the first moments of
a call is buffered and delivered instead of dropped on the floor.
Configuration Overrides¶
Override agent defaults via channel parameters. Provider-specific settings (language, first message, dynamic variables) are passed via session metadata["provider_config"]:
channel = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
system_prompt="You are a helpful assistant.", # Override agent prompt
voice="voice-id-from-elevenlabs", # Override agent voice
temperature=0.7, # Override LLM temperature
)
# Provider-specific overrides via session metadata
session = await channel.start_session(
room_id="room-1",
participant_id="user-1",
metadata={
"provider_config": {
"language": "fr", # Language code
"first_message": "Bonjour!", # Agent's opening message
"dynamic_variables": { # Template variables for the prompt
"user_name": "Alice",
"account_id": "12345",
},
},
},
)
| Parameter | Where | Description |
|---|---|---|
system_prompt |
Channel | Override the agent's system prompt |
voice |
Channel | ElevenLabs voice ID (overrides agent default) |
temperature |
Channel | LLM sampling temperature |
language |
metadata["provider_config"] |
Language code (e.g. en, fr, ja, es) |
first_message |
metadata["provider_config"] |
Agent greeting message |
dynamic_variables |
metadata["provider_config"] |
Dict of variables for prompt templates |
Authentication¶
Two authentication modes:
# Direct API key (server-to-server) — default
config = ElevenLabsRealtimeConfig(
api_key="xi-...",
agent_id="agent_abc123",
requires_auth=False, # API key sent as header
)
# Signed URL (client-facing deployments)
config = ElevenLabsRealtimeConfig(
api_key="xi-...",
agent_id="agent_abc123",
requires_auth=True, # Fetches signed URL via SDK
)
Regional Endpoints¶
# EU (GDPR)
config = ElevenLabsRealtimeConfig(
api_key="xi-...",
agent_id="agent_abc123",
base_url="wss://api.eu.residency.elevenlabs.io",
)
Tool Calling¶
Tool calling takes a different route than on OpenAI or Gemini, and it takes both halves to work:
- The agent declares the tool as a client tool (ElevenLabs dashboard, or the Agents API), with its name, description and JSON schema. This is what the LLM sees, and no schema ever travels over the WebSocket.
- The channel declares the same names in
tools, so the provider registers a handler for each one on the SDK'sClientToolsregistry.
The agent then invokes the tool, the provider dispatches through the standard on_tool_call callback, and the value your tool_handler returns is sent back as the tool result — hooks, gates and result truncation all apply exactly as on the other providers.
async def handle_tool(name: str, arguments: dict) -> str:
if name == "check_order":
return json.dumps({"status": "shipped", "eta": "Tomorrow"})
return json.dumps({"error": f"Unknown tool: {name}"})
channel = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
# Names must match the client tools declared on the agent.
tools=[{"name": "check_order", "description": "Order status", "parameters": {}}],
tool_handler=handle_tool,
)
Names must match on both sides
A tool the agent knows but the channel did not declare reaches the channel all the same, as on every other provider. A channel that declares tools refuses it as undeclared: the agent reads the error, and ON_TOOL_CALL hears of it. A channel that declares none has no list to hold it against, so its gate judges the call alone and its tool_handler runs it, under any name the agent sends. A tool the channel declared but the agent does not know is never called. The names are case-sensitive.
A call is bounded by the channel (tool_timeout_seconds, 10 s by default on RealtimeVoiceChannel, and tool_timeouts per tool): past it the handler is cancelled and the agent reads the failure rather than its turn hanging. A refused, failed, timed-out or unserved call reaches the agent as a tool error (is_error), not as a result it could take for a success. tool_timeout_s is off by default; set it only to cap the channel's bound for this provider: a call cut there is reported cancelled, and the channel cancels its handler. A call still waiting when the conversation ends is reported the same way.
No mid-session reconfigure
supports_mid_session_reconfigure is False: ConvAI takes its overrides once, in the initiation message, and reconnecting would start a different conversation server-side — losing the transcript and every pending tool_call_id. Tool and skill surfaces are therefore fixed for the life of the session. Per-conversation tool sets are possible the ElevenLabs way, by creating the tools through the Agents API and passing their tool_ids in the prompt override (which the agent's Security settings must allow).
Session Failures¶
start_session only spawns the task that opens the WebSocket, so a rejected key, an unknown agent_id or a dropped connection surfaces asynchronously. The provider supervises the session and reports these through on_error (connection_failed, session_ended) with the session marked ENDED, instead of leaving a silent session that looks active.
Audio Format¶
ElevenLabs uses 16-bit PCM mono at 16 kHz by default.
Supported formats: pcm_8000, pcm_16000, pcm_22050, pcm_24000, pcm_44100, pcm_48000, ulaw_8000.
The format lives on the agent, not on the session
Input and output formats are part of the agent's configuration and cannot be overridden per conversation, so input_sample_rate / output_sample_rate on the channel must match what the agent is configured for. A mismatch is not an error — it is pitched-up or pitched-down audio.
Turn Boundaries¶
ConvAI sends its agent text before the synthesis and marks no end of audio, so the provider opens a response on the first audio chunk and closes it once the audio stream has been quiet for response_idle_ms (default 800 ms). A tool call in flight holds the turn open, since the agent resumes speaking on the same turn once it has the result.
Environment Variables (Example)¶
ELEVENLABS_API_KEY=xi-... ELEVENLABS_AGENT_ID=agent_abc123 \
uv run python examples/realtime_voice_local_elevenlabs.py
# Optional overrides
ELEVENLABS_VOICE_ID=voice-id # Voice override
SYSTEM_PROMPT="Be concise." # System prompt override
LANGUAGE=fr # Language code
Deepgram Voice Agent¶
Deepgram assembles an agent from three stages you choose independently — listen (Nova/Flux transcription), think (the LLM) and speak (an Aura voice, or another vendor's — see Non-Deepgram voices) — instead of one end-to-end model. That is the reason to reach for it: you can put a self-hosted LLM behind a hosted STT/TTS pair, or an ElevenLabs voice behind Deepgram's turn-taking, without touching the rest.
from __future__ import annotations
from roomkit.providers.deepgram import DeepgramAgentConfig, DeepgramAgentProvider
config = DeepgramAgentConfig(
api_key="...",
listen_model="nova-3", # STT
think_model="gpt-4o-mini", # LLM (served by Deepgram)
speak_model="aura-2-thalia-en", # TTS voice
greeting="Hi! What can I do for you?",
)
provider = DeepgramAgentProvider(config)
The provider first waits for Deepgram's server-side Welcome, then sends one
Settings message and waits for SettingsApplied before the session goes
ACTIVE, so a rejected key or a bad model fails in connect() rather than
silently later.
Configuration¶
| Field | Default | Description |
|---|---|---|
api_key |
required | Sent as Authorization: Token <key> |
base_url |
wss://agent.deepgram.com/v1/agent/converse |
Use wss://api.eu.deepgram.com/v1/agent/converse for EU processing |
listen_model |
nova-3 |
Transcription model |
listen_version |
None |
Required by Flux ("v2"); leave unset for Nova |
think_provider / think_model |
open_ai / gpt-4o-mini |
LLM stage |
speak_model |
aura-2-thalia-en |
Aura voice id |
speak_provider |
None |
Full agent.speak.provider dict, sent verbatim — selects a non-Deepgram TTS vendor with that vendor's own fields. Takes precedence over speak_model/speak_language |
speak_endpoint |
None |
agent.speak.endpoint (URL + auth headers) — Deepgram requires one for every non-Deepgram speak_provider |
greeting |
None |
Line the agent speaks as soon as the session opens |
keepalive_interval |
8.0 |
Seconds between KeepAlive messages — Deepgram closes silent sockets |
max_prompt_chars |
25_000 |
Warn when the system prompt exceeds this — Deepgram's documented cap for managed LLMs, past which it truncates (PROMPT_TOO_LONG). None disables; never fires with a think_endpoint (no cap there) |
Everything above can be overridden per session through provider_config, alongside a few keys with no config equivalent:
| Key | Description |
|---|---|
listen_model, listen_version, listen_language, keyterms, smart_format |
STT stage |
think_provider, think_model, think_endpoint, context_length |
LLM stage — think_endpoint points at your own OpenAI-compatible server |
speak_model, speak_language, speak_provider, speak_endpoint |
TTS stage — speak_provider/speak_endpoint swap in a non-Deepgram vendor (see below) |
greeting, tags |
Session greeting; dashboard labels |
max_prompt_chars |
Per-session override of the prompt-size warning threshold |
input_encoding, output_encoding, output_container, output_bitrate |
Audio codecs (see below) |
settings |
Deep-merged into the final Settings payload, last — escape hatch for fields the provider does not model. A nested dict naming a different provider type replaces the built one instead of merging into it — fields from two vendors never blend |
Non-Deepgram Voices (ElevenLabs, Cartesia…)¶
The speak stage accepts five provider types — deepgram, eleven_labs, cartesia, open_ai and aws_polly — and each has its own field shape (model vs model_id/voice_id vs voice objects). speak_provider therefore carries the full agent.speak.provider dict verbatim rather than modelling every vendor:
config = DeepgramAgentConfig(
api_key="...",
speak_provider={
"type": "eleven_labs",
"model_id": "eleven_turbo_v2_5",
},
speak_endpoint={
"url": "wss://api.elevenlabs.io/v1/text-to-speech/<voice-id>/multi-stream-input",
"headers": {"xi-api-key": "..."},
},
)
Or per session — including a mid-session vendor swap through reconfigure():
provider_config = {
"speak_provider": {
"type": "cartesia",
"model_id": "sonic-2",
"voice": {"mode": "id", "id": "a167e0f3-df7e-4d52-a9c3-f949145efdab"},
},
}
BYO-key vendors need an endpoint
ElevenLabs runs on your own key: speak_endpoint carries the vendor URL (the ElevenLabs voice id rides in the URL, not the provider dict) plus your API key in its headers. Deepgram-managed vendors — Cartesia above — need no endpoint. Check Deepgram's TTS provider docs for each vendor's exact shape; RoomKit passes both dicts through verbatim.
speak_provider takes precedence over voice and speak_model, which name Aura voices: a voice argument passed while another vendor holds the stage is ignored with a warning, since that vendor's voice belongs in its own fields.
Telephony¶
Deepgram accepts mulaw at 8 kHz in both directions, so a SIP or Twilio transport needs no resampling on either leg:
session = await channel.start_session(
room_id="room-1",
participant_id="caller-1",
metadata={
"provider_config": {
"input_encoding": "mulaw",
"output_encoding": "mulaw",
},
},
)
Set input_sample_rate=8000 and output_sample_rate=8000 on the channel to match.
Tool Calling¶
Tools declared on the channel are sent as think.functions and come back through the standard on_tool_call path — no dashboard setup, unlike ElevenLabs:
channel = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
tools=[{"name": "check_order", "description": "Order status", "parameters": {...}}],
tool_handler=handle_tool,
)
A function carrying an endpoint key is called by Deepgram server-side and never reaches RoomKit; without one it arrives as a client-side call for your tool_handler to answer.
When a Gemini thinking model attaches a thought_signature to a function call,
RoomKit preserves it and returns it unchanged with the function result. No
special handling is required in the tool handler.
Runnable end to end in examples/realtime_deepgram_tools.py.
Mid-Session Reconfigure¶
reconfigure() is overridden to patch the live session instead of reconnecting: UpdateThink carries the new prompt, model and functions in one message, UpdateSpeak swaps the voice. An agent handoff therefore keeps the WebSocket and the conversation context.
Both messages replace the whole Think/Speak block on Deepgram's side, so the patch is computed from the live session state rather than from provider defaults: a prompt-only handoff preserves the functions, the per-session model, endpoint, temperature and context length already in force. A provider_config-only change (think_model, think_endpoint, context_length, speak_model, speak_language) is applied on its own, without also needing a new prompt or voice.
Barge-In¶
Deepgram signals barge-in the other way round from most providers: there is no client-side interrupt message. When the caller speaks over the agent, Deepgram sends UserStartedSpeaking, which the provider surfaces as on_speech_start — and that is what makes the channel flush playback, reset the outbound resampler and send clear_audio to the client. interrupt() therefore only clears local state; it sends nothing.
Deepgram also has no "user stopped speaking" event: the user's ConversationText transcript is the end of their turn, and the provider fires on_speech_end on it so idle detection keeps working.
Known Limitations¶
- Turn detection is always Deepgram's.
server_vad=Falseis not supported; the provider logs a warning and ignores it. - Transcriptions are final-only.
ConversationTextcarries no interim results. - Sessions are capped at two hours. A
MAXIMUM_SESSION_LENGTH_APPROACHINGwarning arrives at 1 h 55 and a terminal error at 2 h, both surfaced throughon_error. - Silent injection rewrites the prompt. Deepgram has no message that adds to the conversation without a reply, so
inject_text(..., silent=True)appends the text to the system prompt viaUpdatePrompt— additive, and only arole="system"text joins the instructions: any other intent is content, appended in a<context>block of its own so it never reads as more of them.UpdatePromptnever starts a turn, so a non-silentrole="system"instruction travels asInjectUserMessage, which the agent acts on at once; a standing instruction ("speak more slowly from now on") isrole="system", silent=True.role="assistant"isInjectAgentMessage: the agent says the line verbatim. - Managed-LLM prompts are capped at 25,000 characters. Past the cap Deepgram truncates the prompt and keeps the session alive (a non-fatal
PROMPT_TOO_LONGwarning). RoomKit warns client-side before sending — at connect, onreconfigure(), and as silent injections grow the prompt — governed bymax_prompt_chars. Bring-your-ownthink_endpointsessions have no cap and are never warned.
Local speaker and microphone example¶
The local Deepgram example enables WebRTC AEC by default so the microphone can stay open while the agent speaks:
Its audio-specific environment variables are:
| Variable | Default | Description |
|---|---|---|
AEC |
webrtc |
webrtc, speex, or 0 to disable echo cancellation |
DENOISE |
webrtc |
webrtc, rnnoise, sherpa, or 0 to disable continuous noise suppression |
AEC_DELAY_MS |
0 |
Fixed speaker-to-microphone delay in milliseconds; 0 lets the local backend give AEC3 the delay its devices' clocks report, on every captured frame |
AUDIO_PREBUFFER_MS |
240 |
Local speaker jitter buffer used by this Deepgram example |
MUTE_MIC |
automatic | 0 keeps capture open; 1 mutes capture during playback. Automatic mode mutes only when AEC is unavailable |
These variables are conveniences implemented by the example, not global
RoomKit settings. In application code, their equivalents are
WebRTCAECProvider(stream_delay_ms=...) and
WebRTCNoiseSuppressorProvider(...), plus
LocalAudioBackend(rt_prebuffer_ms=...).
Start with AEC_DELAY_MS=0. If the speaker stream reports underruns, increase
AUDIO_PREBUFFER_MS; lowering it reduces startup latency but gives bursty
network audio less protection. The library default for rt_prebuffer_ms is
120 ms, while this Deepgram example uses 240 ms because Aura audio commonly
arrives in network bursts.
Voices¶
DeepgramAgentProvider.available_voices() returns the full Aura-2 catalog — English, Spanish, Dutch, French, German, Italian and Japanese — plus the twelve Aura-1 voices flagged deprecated=True. There is no live voices endpoint, so list_voices() returns the same curated list.
Audio Transports¶
Realtime channels need a transport to carry audio between the client and server.
WebSocket Transport¶
from __future__ import annotations
from roomkit.voice.realtime.ws_transport import WebSocketRealtimeTransport
transport = WebSocketRealtimeTransport(
authenticate=my_auth_callback, # Optional auth
audio_format="binary", # default; "base64_json" for legacy clients
)
Audio is PCM16 little-endian mono: the client sends it at the channel's
input rate and receives it at the output rate (16 kHz in, 24 kHz out with
Gemini Live). By default both directions carry it as raw binary frames; with
audio_format="base64_json" the server wraps it in JSON, and a client may
always send the JSON form.
Client messages: binary audio frames, or
Server messages: binary audio frames (or {"type": "audio", "data": "<base64>"}
with base64_json), and
{"type": "session_started"}
{"type": "transcription", "text": "Hello", "role": "user", "is_final": true}
{"type": "speaking", "speaking": true, "who": "assistant"}
{"type": "clear_audio"}
{"type": "session_ended"}
The transport iterates a websockets server connection; see
examples/realtime_voice_gemini.py for a minimal server and browser page.
FastRTC WebRTC Transport¶
Browser-based WebRTC with low latency:
from __future__ import annotations
from roomkit.voice.realtime.fastrtc_transport import (
FastRTCRealtimeTransport,
mount_fastrtc_realtime,
)
transport = FastRTCRealtimeTransport(
input_sample_rate=16000,
output_sample_rate=24000,
)
# Mount on FastAPI app
mount_fastrtc_realtime(
app,
transport,
path="/rtc-realtime",
auth=my_auth_callback, # Optional
)
Audio codec: mu-law (8-bit) over WebRTC DataChannel.
Auto-session: Clients connect via WebRTC, transport fires on_client_connected callback.
SIP Transport¶
Bridge SIP calls to realtime AI:
from __future__ import annotations
from roomkit.voice.realtime.sip_transport import SIPRealtimeTransport
transport = SIPRealtimeTransport(backend=sip_backend)
Sample rate: Negotiated via SIP codec (G.711 @ 8kHz, G.722 @ 16kHz). The channel auto-resamples.
Audio pacing: Built-in OutboundAudioPacer with ~80ms pre-buffer and jitter absorption.
Local Audio (Development)¶
Use system mic/speakers for testing:
from __future__ import annotations
from roomkit.voice.backends.local import LocalAudioBackend
transport = LocalAudioBackend(
input_sample_rate=24000, # match the provider output rate for AEC
output_sample_rate=24000,
rt_prebuffer_ms=120,
aec=aec_provider,
)
Audio pacing: rt_prebuffer_ms (default 120ms) primes the speaker buffer
before playback starts and re-primes after an underrun — the local-speaker
analogue of the SIP pacer's pre-buffer. It paces streamed TTS the same way:
every response plays through the backend's one speaker stream. Set it to 0 to play from the first
byte. The rt_underruns property counts mid-response starvations.
When aec is supplied, the local backend uses the blocks actually written to
the speaker as the reference, including silence inserted after playback has
started. This keeps the render and microphone timelines aligned across an
underrun and avoids the agent's output being transcribed back as user speech.
Tool Calling¶
Via Tool Objects (Recommended)¶
Pass Tool objects directly — the channel extracts definitions and handlers automatically:
from __future__ import annotations
import json
from roomkit import RealtimeVoiceChannel, Tool
async def get_weather(city: str) -> str:
return json.dumps({"temperature": 72, "condition": "sunny", "city": city})
weather_tool = Tool(
name="get_weather",
description="Get current weather for a city",
parameters={
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
},
handler=get_weather,
)
channel = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
tools=[weather_tool],
mute_on_tool_call=True, # Prevent barge-in during tool execution
)
Via tool_handler Callback (Advanced)¶
For MCP integration, auditing wrappers, or custom dispatch logic, use tool_handler directly:
async def handle_tool(name, arguments):
if name == "get_weather":
city = arguments.get("city", "Unknown")
return json.dumps({"temperature": 72, "condition": "sunny", "city": city})
return json.dumps({"error": f"Unknown tool: {name}"})
channel = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
tools=[
{
"type": "function",
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
},
},
],
tool_handler=handle_tool,
mute_on_tool_call=True,
)
Via Hooks¶
The unified ON_TOOL_CALL hook fires from both AIChannel and RealtimeVoiceChannel. Use event.channel_type to distinguish the source. Return the result via HookResult.metadata["result"].
from __future__ import annotations
from roomkit import HookTrigger, RoomKit
from roomkit.models.hook import HookResult
kit = RoomKit()
@kit.hook(HookTrigger.ON_TOOL_CALL)
async def on_tool_call(event, ctx):
if event.name == "get_weather":
result = await fetch_weather(event.arguments["city"])
return HookResult(action="allow", metadata={"result": result})
return HookResult.allow()
Mute on Tool Call
Set mute_on_tool_call=True when using Gemini Live — it prevents the user from speaking during tool execution, which can cause Gemini to drop tool results.
When the Model Speaks the Call Instead of Issuing It¶
Realtime models sometimes say a tool call rather than emit it through the function calling API — Gemini Live most often, usually under load or after a long turn:
tool_recovery (on by default) recognises that shape in an assistant
transcription, parses the arguments, runs the tool, and suppresses the text so
the caller never hears it. Speech before the call is kept and still reaches the
room; a transcription that is only a call produces no room event.
Only a call said as a sentence of its own that ends the utterance is one: at
the start of the transcription or of a line, or after a sentence's end
(Let me check. call:get_weather{city:Montreal}), with nothing after its
closing brace but a final stop. A sentence that mentions the form
(You can type call:get_weather{city:Montreal} to ask me.) is speech, and so is
a call followed by more speech.
The recovered call is a tool call like any other:
- it passes the same pre-execution gate — the declared catalogue, the tool
policy, skill gating, the argument schema and
BEFORE_TOOL_USE, so a hook that denies it prevents the side effect rather than reporting it afterwards; - it fires
ON_TOOL_CALLthrough the same dispatch, so a serving hook or a handler answers it normally; - its outcome — result or refusal — returns as injected context, never as
a tool result, because the model issued no call and has no
FunctionResponsewaiting on it. It reads the outcome on its next turn. A call that nothing served (no handler, no hook) reads as a failure, never as a success.
Two limits follow from the text being text:
| Case | Outcome |
|---|---|
call:lookup{city:Paris,limit:3} on a tool declaring limit as an integer |
3 is coerced to an integer |
call:lookup{city:Paris,limit:many} |
refused — many is not an integer, and guessing would be worse |
a tool declaring an array or object parameter |
not invocable this way — only boolean/integer/number are coerced |
call:lookup{city:Paris,country:FR} on a closed schema |
refused by name — country is undeclared |
call:note{text:see you at 3:30} |
intact — a colon inside a value is not a key boundary |
Set tool_recovery=False to disable the whole path and let a spoken call stay
speech.
channel = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
tools=[...],
tool_recovery=False,
)
Tool policy¶
tool_policy= takes the same ToolPolicy as an AIChannel. A tool it denies
is never declared to the session (at connection, at a reconfiguration, in a
Tool Search reveal or a skill activation), is not in the catalogue a reasoning
backend receives, is never named by find_tools, and is refused if a call
names it anyway, whichever way the call arrived: from the provider, spoken as
call:name{args}, or from a reasoning backend. The refusal reaches
ON_TOOL_CALL's observers with is_error. On a provider whose declarations
are fixed for the session, Tool Search's call_tool stays declared under an
allow list, and the policy applies to the tool it names.
Role overrides apply to the session's participant. Its role is read from the
store when the session starts and again at each call, so demoting a caller
during the call takes effect at their next tool call; the tools already
declared to the session change at its next reconfiguration. Add the
participant to the store before start_session(): one the store does not
hold has no role, and only the base rules apply to it.
from roomkit import RealtimeVoiceChannel, RoleOverride, ToolPolicy
voice = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
tools=[lookup_account, close_account],
tool_handler=accounts,
# Everyone may use every tool, but an observer may not close anything.
tool_policy=ToolPolicy(role_overrides={"observer": RoleOverride(deny=["close_*"])}),
)
A conference's provider takes one too, as ConferenceRealtimeConfig(tool_policy=...);
the mix names no participant, so only the base rules apply there, and the
channel logs the role overrides it ignores. See
examples/realtime_tool_policy.py.
Session Lifecycle¶
from __future__ import annotations
from roomkit import RealtimeVoiceChannel
# Start a session
session = await channel.start_session(
room_id="room-1",
participant_id="user-1",
connection=webrtc_id, # Transport-specific connection handle
metadata={
"system_prompt": "Override prompt", # Per-session override
"voice": "echo", # Per-session voice
},
)
# Reconfigure mid-conversation
await channel.reconfigure_session(
session,
system_prompt="New prompt",
voice="shimmer",
tools=new_tools,
temperature=0.5,
)
# End session
await channel.end_session(session.id)
State machine: CONNECTING → ACTIVE → ENDED
Auto-resampling: If transport_sample_rate differs from input_sample_rate or output_sample_rate, per-session resamplers are created automatically.
Hooks¶
Realtime-specific hooks fired during voice sessions:
| Hook | Type | Description |
|---|---|---|
ON_TRANSCRIPTION |
Sync | Transcription received (can block/modify) |
ON_TOOL_CALL |
Sync | Tool call from any channel (return result via metadata) |
ON_TOOL_CALL |
Async | Audit of every call: served, refused, failed, or abandoned by the model (is_error, cancelled) |
ON_SPEECH_START |
Async | User started speaking |
ON_SPEECH_END |
Async | User stopped speaking |
ON_SESSION_STARTED |
Async | Voice session activated |
ON_INPUT_AUDIO_LEVEL |
Async | Input audio level (~10/sec) |
ON_OUTPUT_AUDIO_LEVEL |
Async | Output audio level (~10/sec) |
from __future__ import annotations
from roomkit import HookTrigger, RoomKit
kit = RoomKit()
@kit.hook(HookTrigger.ON_TRANSCRIPTION)
async def on_transcription(event, ctx):
print(f"[{event.metadata['role']}] {event.content.body}")
@kit.hook(HookTrigger.ON_SPEECH_START)
async def on_speech_start(event, ctx):
print("User started speaking")
Access Control¶
The channel enforces ChannelBinding permissions (RFC Section 7.5):
- Access revoked: Audio from the client is silently dropped
- Muted: Audio from the client is silently dropped
- Active: Audio flows normally
Permissions are checked on every audio frame — changes take effect immediately.
Complete Example: FastRTC + Gemini¶
from __future__ import annotations
import json
from fastapi import FastAPI
from roomkit import RealtimeVoiceChannel, RoomKit, Tool
from roomkit.providers.gemini.realtime import GeminiLiveProvider
from roomkit.voice.realtime.fastrtc_transport import (
FastRTCRealtimeTransport,
mount_fastrtc_realtime,
)
app = FastAPI()
kit = RoomKit()
provider = GeminiLiveProvider(
api_key="your-gemini-key",
model="gemini-3.8-live",
)
transport = FastRTCRealtimeTransport(
input_sample_rate=16000,
output_sample_rate=24000,
)
async def lookup_order(order_id: str) -> str:
return json.dumps({"status": "shipped", "eta": "Tomorrow"})
order_tool = Tool(
name="lookup_order",
description="Look up an order by ID",
parameters={
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
},
handler=lookup_order,
)
channel = RealtimeVoiceChannel(
"voice",
provider=provider,
transport=transport,
system_prompt="You are a customer service agent. Be helpful and concise.",
voice="Aoede",
tools=[order_tool],
mute_on_tool_call=True,
emit_transcription_events=True,
)
kit.register_channel(channel)
# Auto-create session on WebRTC connect
transport.on_client_connected = lambda webrtc_id: channel.start_session(
room_id="room-1",
participant_id="caller",
connection=webrtc_id,
)
mount_fastrtc_realtime(app, transport, path="/rtc")
Testing with Mocks¶
from __future__ import annotations
from roomkit import RealtimeVoiceChannel
from roomkit.voice.realtime.mock import MockRealtimeProvider, MockRealtimeTransport
provider = MockRealtimeProvider()
transport = MockRealtimeTransport()
channel = RealtimeVoiceChannel("voice-test", provider=provider, transport=transport)
session = await channel.start_session("room-1", "user-1")
# Simulate provider events
await provider.simulate_transcription(session, "Hello", role="user", is_final=True)
await provider.simulate_audio(session, b"\x00\x01" * 100)
await provider.simulate_tool_call(session, "call-1", "get_weather", {"city": "NYC"})
# Assert transport received audio
assert len(transport.sent_audio) > 0
Sharing a transport between channels¶
When each caller needs a separate provider or prompt, channels can share one
FastRTCRealtimeTransport. Pass owns_transport=False to each channel:
channel = RealtimeVoiceChannel(
"voice-user-123",
provider=provider,
transport=shared_transport,
owns_transport=False,
)
await channel.close() ends that channel's sessions, closes its provider and
removes its transport callbacks. Other channels remain connected. The service
that created the shared transport calls await shared_transport.close() once
at shutdown. The default owns_transport=True preserves exclusive ownership.
FastRTC and SIP realtime audio/disconnect registrations return an idempotent
unsubscribe function. Invoke it when disposing a custom subscriber. SIP adapters
use SIPVoiceBackend.subscribe_audio_received() to listen alongside the primary
pipeline callback; await adapter.close() detaches the adapter without closing
the SIP listener. Custom shared backends must likewise support unsubscribe
functions for their audio, playback and disconnect registrations.
FastRTC declares capture and playback rates independently in session metadata
(transport_sample_rate and transport_output_sample_rate). The channel
resamples each direction to its provider's configured format. SIP declares the
negotiated codec rate. Configure provider rates using the provider's accepted
formats; for OpenAI PCM, use 24 kHz rather than Gemini's usual 16 kHz input.
await channel.wait_idle(room_id) waits for generation to finish and audio to
reach the transport. A tool call holds it until the response that continues its
result; a call that owes no result (cancelled by the model, or spared by the
reconnect its own handler caused) stops holding it when it ends. A queued SIP transport may still be playing: before a
conversational hangup, also wait until backend.is_playing(carrier_session) is
false. Bound this wait and stop it when the call disconnects. SIP reports RTP
emission and an estimated playback boundary; remote speaker playback cannot be
observed directly.