RealtimeAgent, which supports:
- Turn detection: let the provider API decide when the user has finished, or plug in a local VAD and decide yourself
- Barge-in: the user speaking cuts off the current reply, and the context keeps only what the user actually heard; your code can interrupt as well
- Tool calling with user confirmation: works with
Toolkitand the permission system, and the audio stream keeps running while the user decides - Text input: send text during a voice conversation, on models that accept text
- Automatic reconnection: after the provider API closes a session on idle or timeout, the next input reconnects and restores the conversation
- Turn aggregation: merge sentences split by a pause, and drop acknowledgements that carry no content
Core Concepts
A speech-to-speech agent is built from three components:- Audio transport (
TransportBase): where sound comes from and goes to, such as a local sound card or a browser - Realtime speech model (
RealtimeModelBase): the session with the provider API, translating protocol messages into uniform model events RealtimeAgent: sits between the two, tracking conversation turns and handling barge-in, tool calls, and event output
Both the model and the transport extend from a base class, so you can adapt a new provider API or connect a different client such as a browser.
Quick Start
Start by installing the realtime extra, which brings in the WebSocket client and the local sound card library:Install the realtime dependencies
The sound card library
sounddevice depends on PortAudio. macOS and Windows ship it with the package; on Debian/Ubuntu, run apt install libportaudio2 first.1
Create the realtime model
A model class takes a model name and the credential of the API it belongs to. The model card is matched by name, which fixes the sample rates and context limits, while the voice, turn detection method, and other tuneables go through
Parameters. The tabs below create the model on each provider API; the three steps that follow are identical whichever you pick:2
Create the agent
The agent owns the model session, and the system prompt is sent once when it connects:
Create the agent
3
Create the audio transport
The transport decides where sound comes from and goes to, and
LocalAudioTransport uses this machine’s microphone and speaker. Its sample rates must match the model’s, so build it from the model’s properties instead of hardcoding the numbers:Create the audio transport
4
Run the conversation
reply_stream() borrows the transport to pump audio continuously and emits events as an async iterator. The user speaking is reported as a reply too, with role set to "user", so the loop below tells the two sides apart by reply_id and prints both to the terminal:Run the conversation and print both sides
Keeping the three apart means a client reconnecting does not lose the model session, and a model session timing out does not affect the transport. The same agent can call
reply_stream() again with a different transport, with the conversation history still in agent.state.
Use the Agent
RealtimeAgent takes the following constructor arguments:
str
required
The agent’s name, written into agent messages and events.
str
required
The system prompt, sent once when connecting to the model, with the toolkit’s skill descriptions appended.
RealtimeModelBase
required
The realtime speech model, see the model table above.
Toolkit | None
default:"None"
The toolkit the model can call. Tools run on the agent side and go through permission checks.
AgentState | None
default:"None"
Conversation history, permission rules, and tool context. A new state is created when omitted.
VADBase | None
default:"None"
Local voice activity detection. When provided, it decides the turn boundaries and the provider API’s own turn detection is disabled, see Turn Detection.
TurnAggregator | None
default:"None"
The turn aggregator that merges split sentences and drops empty acknowledgements. Uses the default configuration when omitted.
Run and Handle Events
reply_stream() takes a transport that is already started, keeps feeding its audio to the model, and emits events as an async iterator until the transport’s input ends. You own the transport, so reply_stream() does not close it when it returns, and the same agent can run again with a different one.
reply_stream() emits the same agent events as Agent.reply_stream, so event handling written for a text agent works unchanged. The user speaking is reported as a reply as well: a ReplyStartEvent with role set to "user" when they start, a ReplyEndEvent when they stop, and text block events once the transcript is final, which lets the outer loop assemble user and agent messages with one set of logic. The model’s audio arrives as data block events:
Send Input
On models that accept text input,send() delivers text during a voice conversation. The text first interrupts the current reply, then reaches the model as one user turn, and the reply still comes back as speech:
Send text input
send() accepts line up with Agent.reply:
The Qwen-Omni API behind
DashScopeRealtimeModel accepts no text turns. Every other model class supports text input, which the supports_text_input class attribute reports.Barge-In
When the user speaks while a reply is playing, the agent stops playback immediately and cancels the reply on the model side. The transport reports how far playback actually got, and the agent truncates the agent message in the context to the part the user really heard, so the model does not assume the whole sentence landed. You can also interrupt from code, for example in response to a stop button:Interrupt the current reply
ReplyEndEvent whose finished_reason is interrupted. Because text deltas arrive ahead of audio, the frontend has already received more text than the user heard, so that text block’s TextBlockEndEvent carries a text field with the final text. Msg.append_event applies it automatically; a frontend assembling messages itself needs to replace the block’s content with it.
The agent-side context is always truncated. What happens on the model side depends on the provider’s protocol, which a model class reports through its truncation attribute:
Call Tools
With atoolkit provided, the model can call tools during the conversation. Tools run on the agent side, and permission checks and user confirmation work as they do for a regular agent. The difference is that a realtime agent never pauses: after emitting RequireUserConfirmEvent, reply_stream() keeps emitting other events, and you send the result back through send() whenever it is ready, decoupled from the event stream itself:
Attach tools and receive confirmation requests
UserConfirmResultEvent back to the agent. The call can live in a UI callback, a WebSocket message handler, or terminal input. The two tabs below show both:
- Only models whose model card sets
supports_toolsreceive the tool list. Among DashScope’s Qwen-Omni models, that is the qwen3.5 series only; every model on the other provider APIs supports tools. - A confirmation request that goes unanswered for five minutes is treated as a rejection.
Turn Detection
Turn detection decides when the user has finished speaking, and only one side can own it. By default the provider API does, and the agent just reacts to the speech start and stop it reports. Passing avad argument moves the decision to the agent and disables turn detection on the provider API side. The two modes compare as follows:
Each provider API accepts its own
turn_detection values, with sensitivity and silence duration configured through Parameters as well:
Local detection means implementing
VADBase: push() receives every PCM16 chunk the transport delivers and returns a SpeechTransition only on the chunk where speech starts or ends, and None otherwise; reset() clears the internal state when the audio stream breaks, such as on a reconnection. Passing vad sets turn_detection to none for you:
Plug in a local VAD
TurnAggregator before it is written to the context:
Configure turn aggregation
Automatic Reconnection
Every provider API closes sessions on its own, only the trigger differs: DashScope times out after around three minutes of silence, a Gemini Live audio session is capped at around 15 minutes, and OpenAI Realtime at around an hour. The agent treats this as normal: it logs an INFO line, keeps the transport open, and reconnects on the user’s next utterance, replaying the audio recorded in the meantime. The conversation history lives inagent.state, and on reconnection the agent appends the earlier transcript to the system prompt, so the model picks the topic back up.
Audio Transport
The audio transport decides where sound comes from and goes to, and the agent does not care whether it is a local sound card or a browser. AgentScope currently provides:Local Sound Card
LocalAudioTransport takes the following arguments:
int
default:"16000"
The capture sample rate, which must equal the model’s
input_sample_rate.int
default:"24000"
The playback sample rate, which must equal the model’s
output_sample_rate.int | str | None
default:"None"
The input device’s index or name. Uses the system default when omitted.
int | str | None
default:"None"
The output device’s index or name. Uses the system default when omitted.
int
default:"100"
The duration of each uplink audio chunk.
int
default:"30"
The fade-out applied to audio still playing when an interruption happens, which avoids a pop.
sounddevice and pick one by index:
List audio devices
- Wear headphones. On speakers, the microphone picks up the agent’s own voice, turn detection reads it as the user speaking, and the agent interrupts itself.
LocalAudioTransportdoes no echo cancellation. - Do not let one Bluetooth headset handle both input and output. macOS switches devices such as AirPods into hands-free mode, which often ends up silent. Pair the headset microphone with the built-in speaker instead, for example
LocalAudioTransport(input_device=3, output_device=2).
Custom Transport
To connect a browser or another audio source, subclassTransportBase and implement the following:
The position returned by
clear_audio() is what context truncation relies on, so track playout progress as close to the speaker as possible: in a browser, inside the AudioWorklet.