creamy-window-79763
04/28/2026, 12:07 PMtall-airline-16756
04/28/2026, 12:07 PMtall-airline-16756
04/28/2026, 12:08 PMagent.say() to play the second (predefined) message immediately, bypassing the LLM, while keeping end-of-turn detection. Below is a minimal Python snippet and brief notes.
from livekit.agents import AgentSession, JobContext
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
async def entrypoint(ctx: JobContext):
session = AgentSession(
stt="deepgram/nova-3:multi",
llm="openai/gpt-4.1-mini",
tts="cartesia/sonic-3:9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
vad=silero.VAD.load(),
turn_detection=MultilingualModel(), # enforces end-of-turn detection
)
await session.start(ctx.room)
# send a predefined second message immediately without invoking the LLM
await session.say("Got it — I can handle that now.")
Key points:
• session.say() bypasses LLM generation and triggers TTS/playback directly.
• With VAD or a turn_detection model configured, the platform enforces end-of-turn detection automatically, so calling session.say() will only play after the user’s turn has ended.
• For even lower latency, you can pass pre-synthesized audio via the audio parameter to session.say() (instead of text) so no TTS step runs at call time.
If you want, tell me whether you prefer text-to-speech or pre-synthesized audio and I’ll give the exact session.say() call for that case.
Sources: Agent speech and audio | LiveKit Documentation | Build Your First AI Voice Agent in Python: Complete Tutorial | LiveKit | Turns overview | LiveKit Documentation | How does end-of-utterance detection work in conversations? | Audio customization | LiveKit Documentation, with interactive elementscreamy-window-79763
04/28/2026, 12:09 PMtall-airline-16756
04/28/2026, 12:10 PMagent.say() (no LLM generation), all while respecting end-of-turn detection.
Minimal Python example (uses an event wait for the user turn end):
from livekit.agents import AgentSession, JobContext
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
async def entrypoint(ctx: JobContext):
session = AgentSession(
stt="deepgram/nova-3:multi",
llm="openai/gpt-4.1-mini",
tts="cartesia/sonic-3:9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
)
await session.start(ctx.room)
# 1) First AI message (deterministic, bypasses LLM)
await session.say("Hello — how can I help you today?")
# 2) Wait for the user to finish speaking (turn_detection/VAD handles end-of-turn)
await session.turn_end.wait()
# 3) Second AI message (deterministic, bypasses LLM)
await session.say("Thanks — I heard you. I'll take care of that now.")
Notes:
• session.say() bypasses LLM and triggers TTS/playback directly.
• With turn_detection or VAD configured, LiveKit enforces end-of-turn; awaiting session.turn_end.wait() ensures you only call the second say() after the user finished speaking.
• For lower latency, pass pre-synthesized audio to session.say(audio=...) to avoid TTS at runtime.
Would you like the snippet adapted to use pre-synthesized audio or to handle multiple user turns?
Sources: Agent speech and audio | LiveKit Documentation | Turns overview | LiveKit Documentation | Audio customization | LiveKit Documentation, with interactive elementscreamy-window-79763
04/28/2026, 1:39 PMtall-airline-16756
04/28/2026, 1:40 PMAgentSession has no turn_end attribute. Use the lifecycle hook on_user_turn_completed (or the turn_end event) to run code after the user finishes speaking. Below is a minimal, correct Python example showing: 1) deterministic first message via session.say(), 2) wait for user turn (handled by turn detection), 3) deterministic second session.say().
from livekit.agents import AgentSession, agents, chat
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
class MyAgent(agents.Agent):
async def on_enter(self, ctx: agents.RunContext):
# 1) First deterministic message (bypasses LLM)
await ctx.session.say("Hello — how can I help you today?")
async def on_user_turn_completed(self, ctx: agents.RunContext, chat_ctx: chat.ChatContext):
# 3) Runs after the user's turn ends (VAD / STT endpointing / model-based)
await ctx.session.say("Thanks — I heard you. I'll take care of that now.")
async def entrypoint(job: agents.JobContext):
session = AgentSession(
stt="deepgram/nova-3:multi",
llm="openai/gpt-4.1-mini",
tts="cartesia/sonic-3:9626c31c-...",
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
)
# Start session with your agent; hooks handle the flow
await session.start(job.room, agent=MyAgent())
Notes:
• Do not use session.turn_end — it doesn't exist.
• on_user_turn_completed is invoked automatically once turn detection decides the user finished speaking.
• For lower latency, you can pass pre-synthesized audio to session.say(audio=...).
• If you want manual control, set turn_detection="manual" and call session.commit_user_turn() yourself.
Would you like the snippet adapted to use pre-synthesized audio or manual turn control?
Sources: Turns overview | LiveKit Documentation | Pipeline nodes and hooks | LiveKit Documentation | Turn Detection for Voice Agents: VAD, Endpointing, and Model-Based Detectio... | Agent speech and audio | LiveKit Documentation, with interactive elementscreamy-window-79763
04/28/2026, 1:46 PMtall-airline-16756
04/28/2026, 1:47 PMsession.say() (which bypasses the LLM), then continue with LLM calls if needed.
Minimal pattern:
from livekit.agents import AgentSession, agents, chat
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
class MyAgent(agents.Agent):
async def on_enter(self, ctx: agents.RunContext):
# 1) First AI message (bypass LLM)
await ctx.session.say("Hello! How can I help you today?")
async def on_user_turn_completed(self, ctx: agents.RunContext, chat_ctx: chat.ChatContext):
# 2) Runs after user's first response ends (VAD/STT/turn model)
# Second deterministic message — still bypasses LLM
await ctx.session.say("Got it — I'll handle that now.")
# 3) Optionally resume LLM-driven replies
await ctx.session.generate_reply()
async def entrypoint(job: agents.JobContext):
session = AgentSession(
stt="deepgram/nova-3:multi",
llm="openai/gpt-4.1-mini",
tts="cartesia/sonic-3:...",
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
)
await session.start(job.room, agent=MyAgent())
Notes:
• Do not use session.turn_end — it doesn't exist.
• session.say() always bypasses the LLM; use audio=... to play pre-synthesized audio for lower latency.
• on_user_turn_completed is the correct place to emit the second deterministic message after end-of-turn detection.
Want the example adapted to pre-synthesized audio or manual turn control?
, with interactive elementscreamy-window-79763
04/28/2026, 1:48 PMtall-airline-16756
04/28/2026, 1:48 PMon_user_turn_completed that ensures only the second message (after the user's first response) is sent via session.say() (bypassing the LLM). It uses a simple flag to guarantee .say() runs only once; later turns use the LLM via generate_reply().
from livekit.agents import AgentSession, agents, chat
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
class MyAgent(agents.Agent):
def __init__(self):
super().__init__(instructions="You are a helpful voice assistant.")
self.second_sent = False
async def on_enter(self, ctx: agents.RunContext):
# 1) First deterministic message (bypasses LLM)
await ctx.session.say("Hello! How can I help you today?")
async def on_user_turn_completed(self, ctx: agents.RunContext, chat_ctx: chat.ChatContext):
# runs after each user turn ends (VAD/STT/turn model)
if not self.second_sent:
# 2) After the first user response, send second deterministic message via .say()
await ctx.session.say("Got it — I'll take care of that now.")
self.second_sent = True
return # skip LLM for this turn
# 3) For subsequent turns, use the LLM
await ctx.session.generate_reply()
async def entrypoint(job: agents.JobContext):
session = AgentSession(
stt="deepgram/nova-3:multi",
llm="openai/gpt-4.1-mini",
tts="cartesia/sonic-3:...",
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
)
await session.start(job.room, agent=MyAgent())
Key points:
• session.say() always bypasses the LLM.
• on_user_turn_completed fires after the user finishes speaking; the second_sent flag ensures only the first user turn triggers the .say() second message.
• Returning after the .say() prevents an LLM reply for that user turn; later turns use generate_reply().
Want this adapted to a different lifecycle hook or to stop the conversation after the second message?
, with interactive elementscreamy-window-79763
04/28/2026, 2:08 PMtall-airline-16756
04/28/2026, 2:08 PMchat_ctx automatically in the default pipeline — you don't need to add it manually.
Details:
• With the built-in STT → chat pipeline, the framework transcribes user audio and appends a message item with role="user" to the ChatContext automatically.
• on_user_turn_completed receives the current chat_ctx (including that user message) so you can inspect or act on it directly.
• Only if you use a custom STT pipeline or want to inject/replace content should you modify the context yourself. To do that, append a chat.Message to chat_ctx.items and call await ctx.session.update_chat_ctx(chat_ctx) before generating a reply.
If you want, I can show a tiny example that reads the auto-added user message from chat_ctx inside on_user_turn_completed. Which would you prefer?
Sources: Chat context | LiveKit Documentation | How to Build a Background Observer for Voice AI Guardrails | LiveKit | Agent session | LiveKit Documentation | Pipeline nodes and hooks | LiveKit Documentation, with interactive elementscreamy-window-79763
04/28/2026, 2:10 PMtall-airline-16756
04/28/2026, 2:10 PMagent.say() (or session.say()) does not automatically add its spoken text to the chat_ctx. It only triggers TTS/playback and bypasses LLM generation.
If you want that spoken message included in conversation history, add it yourself and update the session. Example:
from livekit.agents import agents, chat
class MyAgent(agents.Agent):
async def on_user_turn_completed(self, ctx: agents.RunContext, chat_ctx: chat.ChatContext):
# speak without LLM
text = "Sure — I'll take care of that."
await ctx.session.say(text)
# manually append to chat context as assistant message
chat_ctx.items.append(chat.Message(role="assistant", content=text))
await ctx.session.update_chat_ctx(chat_ctx)
# optionally continue with LLM-driven flow
# await ctx.session.generate_reply()
Would you like an example that records metadata (timestamps, tool tags) with the appended message?
, with interactive elementscreamy-window-79763
04/28/2026, 3:34 PMtall-airline-16756
04/28/2026, 3:34 PMsession.say() only plays audio and does not trigger LLM generation.
• To ensure the LLM does NOT generate after your second deterministic message, call session.say() inside on_user_turn_completed and then return immediately (do not call generate_reply() or commit a user turn). Use add_to_chat_ctx=False if you don't want the spoken text added to history.
Minimal example:
from livekit.agents import AgentSession, agents, chat
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
class MyAgent(agents.Agent):
def __init__(self):
super().__init__(instructions="You are a helpful voice assistant.")
self.second_sent = False
async def on_enter(self, ctx: agents.RunContext):
await ctx.session.say("Hello! How can I help you today?")
async def on_user_turn_completed(self, ctx: agents.RunContext, chat_ctx: chat.ChatContext):
if not self.second_sent:
# speak deterministically and prevent LLM reply
await ctx.session.say("Got it — I'll take care of that now.", add_to_chat_ctx=False)
self.second_sent = True
return # do NOT call generate_reply(); this prevents LLM generation
# later turns can use the LLM
await ctx.session.generate_reply()
async def entrypoint(job: agents.JobContext):
session = AgentSession(
stt="deepgram/nova-3:multi",
llm="openai/gpt-4.1-mini",
tts="cartesia/sonic-3:...",
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
)
await session.start(job.room, agent=MyAgent())
If you want the spoken text added to chat history instead, omit add_to_chat_ctx=False or append a chat.Message and call await ctx.session.update_chat_ctx(chat_ctx). Want an example of that?
Sources: Agent speech and audio | LiveKit Documentation | Turns overview | LiveKit Documentation, with interactive elementscreamy-window-79763
04/28/2026, 3:36 PMtall-airline-16756
04/28/2026, 3:36 PMsession.say(), explicitly cancel the agent's reply processing inside the turn hook. Raise StopResponse() right after session.say() so the framework does not invoke the LLM.
Minimal example (only the second message is via `say()`; no LLM reply):
from livekit.agents import AgentSession, agents, chat, StopResponse
from livekit.plugins import silero
from livekit.plugins.turn_detector.multilingual import MultilingualModel
class MyAgent(agents.Agent):
def __init__(self):
super().__init__(instructions="You are a helpful voice assistant.")
self.second_sent = False
async def on_enter(self, ctx: agents.RunContext):
await ctx.session.say("Hello! How can I help you today?")
async def on_user_turn_completed(self, ctx: agents.RunContext, chat_ctx: chat.ChatContext):
if not self.second_sent:
await ctx.session.say("Got it — I'll take care of that now.", add_to_chat_ctx=False)
self.second_sent = True
raise StopResponse() # Prevents the LLM from generating a reply
await ctx.session.generate_reply() # Subsequent turns use the LLM
async def entrypoint(job: agents.JobContext):
session = AgentSession(
stt="deepgram/nova-3:multi",
llm="openai/gpt-4.1-mini",
tts="cartesia/sonic-3:...",
vad=silero.VAD.load(),
turn_detection=MultilingualModel(),
)
await session.start(job.room, agent=MyAgent())
If you still get duplicates, tell me:
• whether any other hooks call generate_reply() or session.say(), and
• if you’re using manual turn detection — I’ll help debug further. Sources: Agent speech and audio | LiveKit Documentation | Turn Detection for Voice Agents: VAD, Endpointing, and Model-Based Detectio... | Pipeline nodes and hooks | LiveKit Documentation | External data and RAG | LiveKit Documentation | Turns overview | LiveKit Documentation, with interactive elementscreamy-window-79763
04/28/2026, 3:37 PMtall-airline-16756
04/28/2026, 3:38 PMgenerate_reply() yourself.
• session.say() only plays audio and does not cancel the queued LLM reply — hence the duplicate.
How to fix it
• In your on_user_turn_completed hook, call session.say(...) for the deterministic second message and then raise StopResponse() to cancel the automatic LLM reply.
Minimal example (Soniox turn detection):
from livekit.agents import AgentSession, agents, chat, StopResponse
from livekit.plugins.stt.soniox import SonioxSTT
from livekit.plugins import silero
class MyAgent(agents.Agent):
def __init__(self):
super().__init__(instructions="You are a helpful voice assistant.")
self.second_sent = False
async def on_enter(self, ctx: agents.RunContext):
await ctx.session.say("Hello! How can I help you today?")
async def on_user_turn_completed(self, ctx: agents.RunContext, chat_ctx: chat.ChatContext):
if not self.second_sent:
await ctx.session.say("Got it — I'll take care of that now.", add_to_chat_ctx=False)
self.second_sent = True
raise StopResponse() # prevents the automatic LLM reply
await ctx.session.generate_reply() # subsequent turns use the LLM
async def entrypoint(job: agents.JobContext):
session = AgentSession(
stt=SonioxSTT(api_key="YOUR_API_KEY"),
llm="openai/gpt-4.1-mini",
tts="cartesia/sonic-3:...",
vad=silero.VAD.load(),
)
await session.start(job.room, agent=MyAgent())
If you still see duplicates after this, tell me:
• whether the LLM reply appears before or after your say() output, and
• whether any background process or other agent instance could be generating replies. I'll help debug further., with interactive elementscreamy-window-79763
04/28/2026, 5:19 PMtall-airline-16756
04/28/2026, 5:19 PMon_user_turn_completed is invoked and what to expect.
• VAD (Voice Activity Detection)
◦ Triggers on silences/pauses in audio.
◦ Fastest latency for detecting short stops, but can falsely split or cut off speech if the speaker pauses.
◦ on_user_turn_completed fires as soon as VAD signals the end; may be earlier than semantic end-of-utterance.
• STT endpointing (STT provider endpointing, e.g., Soniox)
◦ Uses the speech-to-text provider’s endpointing (end-of-utterance) signal.
◦ Generally more accurate than raw VAD because it uses ASR confidence/linguistic cues.
◦ Latency depends on provider behavior; typically slightly higher than VAD but fewer false splits. Hook fires when STT signals completion.
• Model‑based turn detection
◦ Uses a semantic/ML model to predict when the user is done (considers content, prosody).
◦ Best tradeoff of accuracy vs. latency for conversational flows; avoids premature cuts and long waits.
◦ on_user_turn_completed fires when the model decides the turn ended; timing optimized for natural turns.
Practical effects on your hook:
• The hook is always called after whichever mechanism you configured signals the end-of-turn.
• Faster modes (VAD) => earlier hooks but more false positives; slower/more semantic modes => fewer duplicates and better UX.
• If you need absolute control, use manual turn detection and call commit_user_turn() yourself.
Recommendation:
• Use VAD for ultra-low-latency scenarios where brief pauses reliably end turns.
• Use STT endpointing (Soniox) if you want ASR-informed endpoints.
• Use model-based detection for natural conversation and fewer mistakes.
• Use manual mode when you must deterministically control exactly when on_user_turn_completed runs.
Want a quick example of switching between these in AgentSession config?
, with interactive elementscreamy-window-79763
04/28/2026, 7:18 PMtall-airline-16756
04/28/2026, 7:19 PMimport asyncio
from livekit.agents import agents, chat, StopResponse
from livekit.errors import LiveKitError
from livekit.plugins.llm import Chat # small-LLM plugin
class MyAgent(agents.Agent):
def __init__(self, small_cfg):
super().__init__(instructions="You are a helpful assistant.")
self.small_llm = Chat(**small_cfg)
self.pre_sent = False
async def on_user_turn_completed(self, ctx: agents.RunContext, chat_ctx: chat.ChatContext):
if not self.pre_sent:
try:
# short deadline for the pre-response
res = await asyncio.wait_for(
self.small_llm.chat(messages=chat_ctx.items, max_tokens=128),
timeout=2.0
)
text = res.text if hasattr(res, "text") else str(res)
await ctx.session.say(text, add_to_chat_ctx=False)
self.pre_sent = True
raise StopResponse() # prevent automatic main-LLM reply
except (LiveKitError, asyncio.TimeoutError, Exception) as e:
# log and silently continue to allow main LLM to run
print("small LLM failed:", e)
# fallback: allow main LLM path (either implicit or call generate_reply)
await ctx.session.generate_reply()
Notes:
• The plugin Chat() can fail (network, auth, quota, malformed responses); catch LiveKitError and asyncio.TimeoutError.
• Use asyncio.wait_for (or the plugin’s timeout option) to bound latency.
• If the small LLM succeeds and you want to stop the main LLM, raise StopResponse() after session.say().
• If it fails, do nothing special so the main LLM handles the turn.
If you want, I can adapt the snippet to your exact plugin response shape or add structured logging.
, with interactive elementscreamy-window-79763
04/28/2026, 7:26 PMtall-airline-16756
04/28/2026, 7:27 PMsession.say().
Summary:
• llm.chat(...).to_str_iterable() yields an AsyncIterable[str] of chunks.
• session.say() accepts an AsyncIterable[str], so passing that iterable streams text to TTS and plays as chunks arrive.
• This lets a fast filler reply play immediately while the main pipeline continues.
Minimal pattern (accumulate full text if you also need to add it to chat_ctx):
import asyncio
# create a future to capture full filler text
filler_future = asyncio.Future()
async def filler_stream():
text = ""
async for chunk in fast_llm.chat(chat_ctx=fast_llm_ctx).to_str_iterable():
text += chunk
yield chunk
filler_future.set_result(text)
# stream to TTS/playback without auto-adding to chat context
await ctx.session.say(filler_stream(), add_to_chat_ctx=False)
# later, when stream completes
filler_text = await filler_future
turn_ctx.add_message(role="assistant", content=filler_text, interrupted=False)
await ctx.session.update_chat_ctx(turn_ctx)
Notes:
• If the plugin/LLM does not support streaming, to_str_iterable() will yield the full string once — still compatible.
• Use asyncio.wait_for or the plugin’s timeout options to bound latency.
• Keep add_to_chat_ctx=False if you don’t want the main LLM to see the filler; append it manually only if you want it included.
Want this adapted to your exact LLM plugin (Soniox/STT + your small LLM plugin) with timeout handling?
, with interactive elementscreamy-window-79763
04/29/2026, 7:08 AMtall-airline-16756
04/29/2026, 7:08 AM