I'd like to use agent.say() to say the second mess...
# ask-ai
c
I'd like to use agent.say() to say the second message of the conversation faster, bypassing the llm generation. How can I do that to make it work with end of turn detection. Give a python snippet minimal example
t
Thanks for reaching out, Yousri. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — you're asking how to use
agent.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.
Copy code
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 elements
c
I want the second generation to work like that. So 1st AI message: agent.say User answers 2nd message is agent.say
t
Thanks — I understand you want this flow: 1) AI plays a first deterministic message with `agent.say()`; 2) user answers; 3) AI plays a second deterministic message with
agent.say()
(no LLM generation), all while respecting end-of-turn detection. Minimal Python example (uses an event wait for the user turn end):
Copy code
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 elements
c
'AgentSession' object has no attribute 'turn_end'
t
Thanks — got it. The error happens because
AgentSession
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()
.
Copy code
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 elements
c
No I want to by pass the LLM generation but only after the first user response
t
Perfect — use the lifecycle hook that runs after the user finishes their first turn, send your deterministic second message with
session.say()
(which bypasses the LLM), then continue with LLM calls if needed. Minimal pattern:
Copy code
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 elements
c
No I don't care about presynhtesize for now. Just write the example with on_user_utnr_completed. How do you make sure that only the second message is output through .say?
t
Perfect — below is a minimal, clean example using
on_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()
.
Copy code
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 elements
c
I'd like to add what was said to the chat ctx, is this done automatically or should I add it manually?
t
Short answer: the user's transcribed speech is added to
chat_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 elements
c
agent.say() does not add the message generated to the chat_ctx, can you confirm?
t
Correct —
agent.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:
Copy code
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 elements
c
I'd like after the session.say() is done to manually mark the turn as done, so that the LLM doesn't generate a response
t
You're already on the right track. Confirming and a minimal recipe: • Confirmed:
session.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:
Copy code
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 elements
c
Even when I return, the agent says what the LLM has generated, and I have a duplicate message output
t
Thanks — that matches what you're seeing. To stop the duplicate LLM reply after you call
session.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):
Copy code
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 elements
c
I'm using stt turn detection - soniox. No hook calls generate_reply
t
Thanks — that setup with Soniox explains the behavior. Short checklist and a minimal fix: What’s happening • Soniox signals end-of-turn and the framework automatically queues an LLM reply after each user turn, even if you don't call
generate_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):
Copy code
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 elements
c
What's the impact of mode of end of turn (vad, stt) on reaching the hook on_user_completed?
t
Good question — here’s how the end-of-turn mode affects when
on_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 elements
c
In the hook on user_turn_completed, I'm using a small LLM for a pre-response before the one from my pipeline. If it fails or timeout, I want to have it no effec ton the conversation, that is just get the main response from the main LLM. Should I try catch the code inside the hook on_user_completed or it is already handlede by Livekit. Also my LLM call is using livekit Plugin. Chat() can this fail? How do you hadnle timeout?
t
Short answer: yes — you should catch errors/timeouts yourself. LiveKit does not automatically swallow exceptions from plugin LLM calls inside your hook. Wrap the small-LLM call in try/except (and use a timeout) so failures simply fall back to the main LLM. Minimal pattern:
Copy code
import 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 elements
c
Is this using streaming: I saw a pythoin example doing this: async def on_user_turn_completed(self, turn_ctx: ChatContext, new_message: ChatMessage): # Create a short "silence filler" response to quickly acknowledge the user's input fast_llm_ctx = turn_ctx.copy( exclude_instructions=True, exclude_function_call=True, exclude_handoff=True, exclude_config_update=True, ).truncate(max_items=3) fast_llm_ctx.items.insert(0, self._fast_llm_prompt) fast_llm_ctx.items.append(new_message) # # Intentionally not awaiting SpeechHandle to allow the main response generation to # # run concurrently # self.session.say( # self._fast_llm.chat(chat_ctx=fast_llm_ctx).to_str_iterable(), # add_to_chat_ctx=False, # ) # Alternatively, if you want the reply to be aware of this "silence filler" response, # you can await the fast llm done and add the message to the turn context. But note # that not all llm supports completing from an existing assistant message. fast_llm_fut = asyncio.Future[str]() async def _fast_llm_reply() -> AsyncIterable[str]: filler_response: str = "" async for chunk in self._fast_llm.chat(chat_ctx=fast_llm_ctx).to_str_iterable(): filler_response += chunk yield chunk fast_llm_fut.set_result(filler_response) self.session.say(_fast_llm_reply(), add_to_chat_ctx=False) filler_response = await fast_llm_fut logger.info(f"Fast response: {filler_response}") turn_ctx.add_message(role="assistant", content=filler_response, interrupted=False) Can we just get the stream from llm.chat and then pass it to session.chat()? Is it the same,
t
Yes — you can stream the LLM output into
session.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):
Copy code
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 elements
c
I don't want the fast LLM response to exceeed a short timeout, should I wrap it manually? or does chat() method from livekit handle that?
t
I don't have the answer you're looking for. You could also try asking your question: • in one of the other Slack channels or • to https://deepwiki.com/livekit/livekit_composite which is trained on all LiveKit source code If you find the answer, please post it here to help others!