adorable-bear-14321
03/16/2026, 4:14 AMtall-airline-16756
03/16/2026, 4:15 AMtall-airline-16756
03/16/2026, 4:15 AMllm_node to intercept the streaming LLM output. Inside llm_node you can read, modify, log, or forward each chunk before yielding it onward to the rest of the pipeline (including TTS).
What this looks like (conceptual steps):
1. Create a custom Agent class that inherits from Agent.
2. Override the llm_node method. Inside it, stream the LLM results and inspect each chunk’s content.
3. Take whatever action you need (store the text, modify it, emit a session event, etc.), then yield the chunk so the pipeline continues to TTS.
4. Use your custom agent when creating the AgentSession.
Example pattern (Python-like pseudocode you can adapt):
from livekit.agents import Agent, ModelSettings, llm
class MyAgent(Agent):
async def llm_node(self, chat_ctx: llm.ChatContext, tools: list[llm.FunctionTool], model_settings: ModelSettings) -> llm.ChatChunk:
# Stream LLM output
async for chunk in self.llm.chat(chat_ctx=chat_ctx, tools=tools, tool_choice=None):
# Extract text from the streaming chunk
content = None
if hasattr(chunk, "delta"):
content = getattr(chunk.delta, "content", None)
else:
content = str(chunk)
if content:
# Example actions:
# - log or save the content
# - emit a session event (session.on handler) with the content
# - modify content before TTS (if desired)
print("LLM generated content:", content)
# If you want to notify the session layer, call whatever session/event API you have here
# Yield the chunk so TTS receives the (possibly modified) output
yield chunk
Notes and tips
• The llm_node gets the stream as it’s produced; this is the single best place to intercept text before TTS.
• If you need to trigger a session.on style event, emit that event from inside llm_node when you see content (how to emit depends on your session/event API — if you share which SDK or session object you’re using, I can show a concrete example).
• If you want to modify the text before TTS, change the chunk contents before yielding. Be careful to preserve chunk format expected downstream.
If you’d like, tell me:
• Which SDK/language you’re using (Python, TypeScript, etc.).
• The exact session/event API you want to call (session.on(...) handler shape or method).
I’ll provide a concrete code example showing how to emit a session event with the LLM text before it reaches TTS.
Sources: Modifying LLM output before TTS | LiveKit Documentation | Pipeline nodes and hooks | LiveKit Documentationadorable-bear-14321
03/16/2026, 4:16 AMtall-airline-16756
03/16/2026, 4:16 AMsession.on events directly to capture the LLM response before it goes to TTS.
Short answer: you cannot rely on session.on alone to intercept the raw LLM stream before TTS. The point in the pipeline where the LLM produces text is inside the agent flow (the llm_node). session.on events are useful for observing higher‑level session state and events, but they don’t provide a guaranteed hook into the streaming LLM chunks before TTS consumes them.
What you should do instead
• Use a custom Agent and override llm_node to read the streaming chunks from the LLM. That is the place where you can inspect, modify, log, or emit your own events with the generated text before yielding chunks onward to TTS.
• From inside llm_node you can emit a session-level event (call whatever session.emit / event API your SDK exposes) so your existing session.on handlers receive the LLM text in real time. This keeps session.on for handling the event while llm_node is the interceptor.
Why this approach
• llm_node is the earliest reliable point that receives the text stream from the LLM.
• session.on handlers run at the session/event layer; without an explicit emit from the agent layer they won’t receive the raw streaming chunks before TTS consumes them.
If you want, I can give a concrete example that:
1. Overrides llm_node in a custom Agent,
2. Emits a session event with each chunk’s text (so your session.on("llm_chunk", ...) handler receives it), and
3. Optionally modifies the chunk before yielding so TTS gets the altered text.
Tell me which SDK/language you’re using (Python or TypeScript) and the shape of your session event API (e.g., how you emit events), and I’ll provide the exact code you can drop into your project.adorable-bear-14321
03/16/2026, 4:29 AMtall-airline-16756
03/16/2026, 4:29 AMself.session.tts.update_options(emotion=...)) so the next TTS utterance uses the chosen emotion. You cannot rely on session.on alone to catch the raw streaming chunks before TTS — llm_node is the reliable interception point.
Concrete plan
1. Override llm_node in a custom Agent subclass.
2. Stream LLM chunks, buffer until a sensible utterance boundary (sentence end or newline).
3. Run simple keyword/regex matching or an emotion classifier on the buffered utterance to pick one of [angry, excited, happy].
4. Call the session’s TTS update (e.g., self.session.tts.update_options(emotion=detected)) so the next utterance is synthesized with that emotion.
5. Yield the original (or modified) chunks so the pipeline continues to TTS.
Important notes
• Emotion changes take effect for the next TTS utterance; they don’t reliably change mid-sentence. Design your buffer/utterance detection accordingly.
• No function tools are required — all work happens inside llm_node.
• Use a conservative sentence boundary strategy (punctuation/newline) to avoid switching emotion too often.
• If you prefer ML-based emotion classification, you can call a local classifier inside llm_node instead of keyword matching.
If you want, I can provide a ready-to-drop example in either Python or TypeScript:
• Python example will show buffering, keyword detection, and self.session.tts.update_options(...).
• TypeScript example will show the same pattern for a JS Agent and how to emit session events if you want session.on to also receive the detected emotion.
Which language would you like the example in (Python or TypeScript)? Also tell me how you’d like to detect emotion (simple keywords or a classifier), and I’ll give the exact code you can use.
Sources: Cartesia TTS | LiveKit Documentation | Hume TTS plugin guide | LiveKit Documentation | Pipeline nodes and hooks | LiveKit Documentation | LLM Output Replacement | LiveKit Documentationadorable-bear-14321
03/16/2026, 4:41 AMtall-airline-16756
03/16/2026, 4:41 AMllm_node to inspect streaming LLM text, detects emotion with keyword matching (angry, excited, happy), updates the session TTS options via self.session.tts.update_options(...), and yields chunks so the pipeline continues to Cartesia TTS.
Notes:
• Emotion changes apply to the next utterance (not mid-sentence).
• This is a minimal, illustrative example — adapt buffering, detection logic, and TTS option names to your codebase and Cartesia settings.
Example (Python)
import re
from typing import AsyncIterable
from livekit.agents import Agent, AgentSession, ModelSettings, llm
class DummyEmotiveAgent(Agent):
async def llm_node(
self,
chat_ctx: llm.ChatContext,
tools: list[llm.FunctionTool],
model_settings: ModelSettings,
) -> AsyncIterable[llm.ChatChunk]:
# Simple keyword lists for emotions
emotion_map = {
"angry": ["angry", "furious", "outraged", "annoyed", "frustrated"],
"excited": ["excited", "amazing", "incredible", "wow", "thrilled", "awesome"],
"happy": ["happy", "glad", "great", "wonderful", "pleased", "fantastic"],
}
def detect_emotion(text: str) -> str | None:
t = text.lower()
for emotion, kws in emotion_map.items():
for kw in kws:
if re.search(rf"\b{re.escape(kw)}\b", t):
return emotion
return None
# Stream the LLM as usual
stream = self.llm.chat(chat_ctx=chat_ctx, tools=tools)
buffer = ""
async for chunk in stream:
# If the chunk has streaming content, append to buffer
content = None
if hasattr(chunk, "delta") and getattr(chunk.delta, "content", None):
content = chunk.delta.content
elif hasattr(chunk, "content"):
content = chunk.content
if content:
buffer += content
# Check for a sentence boundary (simple heuristic)
if any(p in buffer for p in (".", "?", "!", "\n")):
# Extract up to last end punctuation
last_end = max((buffer.rfind(c) for c in ".?!\n" if c in buffer))
utterance = buffer[: last_end + 1].strip()
# Detect emotion for this utterance
detected = detect_emotion(utterance)
if detected:
# Update session TTS options so the NEXT utterance uses this emotion
# (adjust the method name / params if your SDK differs)
try:
self.session.tts.update_options(emotion=detected)
print(f"Set TTS emotion -> {detected} for: {utterance[:60]}...")
except Exception as e:
print("Failed to update TTS options:", e)
# Clear processed part of buffer
buffer = buffer[last_end + 1 :]
# Yield the chunk onward to TTS (unaltered here)
yield chunk
# After stream ends, check remaining buffer
if buffer.strip():
detected = detect_emotion(buffer)
if detected:
try:
self.session.tts.update_options(emotion=detected)
print(f"Set TTS emotion -> {detected} for trailing text...")
except Exception as e:
print("Failed to update TTS options for trailing text:", e)
# Optionally yield a final synthetic chunk if required by your pipeline
# Entrypoint: create your AgentSession with the custom agent
session = AgentSession(
agent=DummyEmotiveAgent(),
# stt=..., (if needed)
llm=llm.InferenceLLM(model="openai/gpt-4o"), # example
tts=llm.InferenceTTS(
model="cartesia/sonic-3",
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
extra_kwargs={"emotion": "neutral"}, # initial emotion
),
# other session configs...
)
# Start the session in your app as you normally would
# session.run() or equivalent per your framework
If you want:
• A TypeScript/Node.js version of this example.
• A slightly more robust utterance detector (e.g., pause-based VAD or shorter buffer thresholds).
• Replacement of keyword matching with a small classifier (I can show a lightweight approach).
Which would help you next?adorable-bear-14321
03/16/2026, 6:03 AMasync def llm_node(self,chat_ctx: llm.ChatContext,tools: list[llm.FunctionTool],model_settings: ModelSettings,) -> AsyncIterable[llm.ChatChunk]:
<http://logger.info|logger.info>(f"LLM Node :::")
emotion_map = {
"angry": ["can not", "warning"],
"excited": ["excited", "amazing", "incredible", "wow", "thrilled", "awesome"],
"happy": ["happy", "glad", "great", "wonderful", "pleased", "fantastic"],
}
def detect_emotion(text: str) -> str | None:
<http://logger.info|logger.info>(f"detecting emotion for : {text}")
t = text.lower()
for emotion, kws in emotion_map.items():
for kw in kws:
if re.search(rf"\b{re.escape(kw)}\b", t):
<http://logger.info|logger.info>(f"detected emotion: {emotion}")
return emotion
return None
stream = self.llm.chat(chat_ctx=chat_ctx, tools=tools)
buffer = ""
async for chunk in stream:
# If the chunk has streaming content, append to buffer
content = None
if hasattr(chunk, "delta") and getattr(chunk.delta, "content", None):
content = chunk.delta.content
elif hasattr(chunk, "content"):
content = chunk.content
if content:
buffer += content
# Check for a sentence boundary (simple heuristic)
if any(p in buffer for p in (".", "?", "!", "\n")):
# Extract up to last end punctuation
last_end = max((buffer.rfind(c) for c in ".?!\n" if c in buffer))
utterance = buffer[: last_end + 1].strip()
# Detect emotion for this utterance
detected = detect_emotion(utterance)
if detected:
# Update session TTS options so the NEXT utterance uses this emotion
# (adjust the method name / params if your SDK differs)
try:
self.session.tts.update_options(emotion=detected)
print(f"Set TTS emotion -> {detected} for: {utterance[:60]}...")
except Exception as e:
print("Failed to update TTS options:", e)
# Clear processed part of buffer
buffer = buffer[last_end + 1:]
# Yield the chunk onward to TTS (unaltered here)
yield chunktall-airline-16756
03/16/2026, 6:03 AMllm_node in your entrypoint but it’s not running. I’ll help you troubleshoot and give a minimal reproducible pattern so you can confirm where the problem is.
Quick checklist (most common causes)
• Confirm your custom Agent class is actually being used when creating the session (the session must be constructed with your agent instance).
• Verify your Agent class correctly inherits from the Agent base class in the same SDK version you’re running.
• Make sure the method signature exactly matches the expected one for your SDK (async, same parameter names/types, and return type).
• Ensure the pipeline path actually routes through llm_node (some configurations or shortcuts may bypass agent nodes).
• Add logging at Agent construction and the entrypoint that creates the AgentSession so you can confirm the runtime is instantiating your class.
Targeted questions to narrow this down
1. How are you creating the AgentSession / entrypoint? Please paste the code where you instantiate your Agent and create the session.
2. Which SDK and version are you running (Python package name and version)?
3. Are there any errors or warnings in logs when the session starts?
4. Are you using a custom entrypoint or any middleware that might bypass agent nodes?
Minimal reproducible example (pattern to follow)
1. Define a custom Agent subclass with an obvious log in __init__ and llm_node.
2. Instantiate that agent and pass it to AgentSession.
3. Start the session and exercise the pipeline so the LLM produces output.
Example skeleton (adapt to your imports / SDK):
import asyncio
import logging
from livekit.agents import Agent, AgentSession, ModelSettings, llm # adjust imports if needed
logging.basicConfig(level=<http://logging.INFO|logging.INFO>)
logger = logging.getLogger("test")
class MyAgent(Agent):
def __init__(self):
super().__init__(instructions="Hello")
<http://logger.info|logger.info>("MyAgent constructed")
async def llm_node(self, chat_ctx: llm.ChatContext, tools: list[llm.FunctionTool], model_settings: ModelSettings):
<http://logger.info|logger.info>("llm_node STARTED")
stream = self.llm.chat(chat_ctx=chat_ctx, tools=tools)
async for chunk in stream:
# simple log to prove we got chunks
content = getattr(chunk.delta, "content", None) if hasattr(chunk, "delta") else getattr(chunk, "content", None)
if content:
<http://logger.info|logger.info>("llm chunk content: %s", content[:80])
yield chunk
<http://logger.info|logger.info>("llm_node FINISHED")
async def run_session():
agent = MyAgent()
session = AgentSession(
agent=agent,
llm=llm.InferenceLLM(model="openai/gpt-4o"), # example
tts=llm.InferenceTTS(model="cartesia/sonic-3", voice="...", extra_kwargs={"emotion":"neutral"}),
)
<http://logger.info|logger.info>("Starting session run")
await session.run() # or whatever entry method your framework uses
<http://logger.info|logger.info>("Session run complete")
if __name__ == "__main__":
asyncio.run(run_session())
What to inspect after running this:
• Do you see "MyAgent constructed" in logs? If not, your agent object wasn’t instantiated.
• Do you see "llm_node STARTED"? If yes, llm_node is running and the issue is in your content handling logic.
• If you never see "llm_node STARTED" but you do see session start logs, then the agent nodes are being bypassed — share the session config and I can check for reasons.
Additional debugging tips
• Temporarily remove buffering logic and simply log and yield every chunk to confirm it runs.
• If your SDK offers a “pipeline” or graph visualization/debug mode, enable it to confirm llm_node is included.
• Confirm there aren’t multiple Agent classes and you’re editing a different class than the one used at runtime.
• If you use dependency injection or an entrypoint factory, ensure it returns your Agent subclass instance.
If you share:
• The code where you create AgentSession (the instantiation),
• Any startup logs you have,
I’ll review it and point out exactly what to change. If you prefer, paste a small snippet of your entrypoint and I’ll produce a corrected version you can run immediately.adorable-bear-14321
03/16/2026, 6:47 AMtall-airline-16756
03/16/2026, 6:47 AMself.llm inside your llm_node is not an LLM instance; it’s a placeholder value (NotGiven) used by the SDK when no LLM was supplied to the agent/session. In short: your Agent doesn’t have a real llm assigned at runtime, so calling self.llm.chat(...) fails.
What to check and how to fix it
1. Ensure your AgentSession is constructed with an actual LLM object (not omitted).
◦ When creating the session, pass a proper LLM instance (e.g., llm.InferenceLLM(...)), and ensure that instance is attached to the session that uses your Agent.
◦ Example:
session = AgentSession(
agent=MyAgent(),
llm=llm.InferenceLLM(model="openai/gpt-4o"),
tts=llm.InferenceTTS(...),
...
)
• If you omit llm=..., the SDK will leave self.llm as NotGiven.
1. Confirm you’re using the same Agent instance in the session
◦ Make sure the Agent you subclass and edit is the one you pass into AgentSession(agent=...).
◦ Add a constructor log in your Agent to confirm instantiation (helps rule out multiple classes/instances).
1. Verify SDK usage pattern / signatures
◦ Ensure the SDK version you’re running expects llm passed to AgentSession (API may differ across versions).
◦ If your project uses dependency injection or a factory to create sessions, confirm the factory is supplying the LLM field.
1. Quick minimal test to validate LLM presence
◦ Add a small check at the top of your `llm_node`:
if getattr(self, "llm", None) is None or getattr(self, "llm", None).__class__.__name__ == "NotGiven":
logger.error("llm not set on agent — check AgentSession creation")
return
• This will surface the problem early and avoid the AttributeError stack trace.
Corrected minimal pattern (complete flow)
• Agent subclass with llm_node that uses self.llm.chat
• AgentSession that includes llm=llm.InferenceLLM(...)
class MyAgent(Agent):
async def llm_node(self, chat_ctx: llm.ChatContext, tools: list[llm.FunctionTool], model_settings: ModelSettings):
<http://logger.info|logger.info>("llm_node started")
# quick guard
if getattr(self, "llm", None) is None or getattr(self, "llm").__class__.__name__ == "NotGiven":
logger.error("Agent has no llm configured")
return
stream = self.llm.chat(chat_ctx=chat_ctx, tools=tools)
async for chunk in stream:
# process and yield chunk
yield chunk
# Session creation — ensure llm is provided
session = AgentSession(
agent=MyAgent(),
llm=llm.InferenceLLM(model="openai/gpt-4o"), # required
tts=llm.InferenceTTS(model="cartesia/sonic-3", voice="...", extra_kwargs={"emotion":"neutral"}),
)
await session.run()
If you still see NotGiven after passing llm=..., tell me:
• The code you use to instantiate AgentSession (paste the snippet).
• The version of the LiveKit agents package (pip show livekit-agents or pyproject/requirements).
• Any factories or wrappers used to build the session (they may override/omit fields).
If you paste your AgentSession creation snippet, I’ll check it and give an exact corrected example you can run.adorable-bear-14321
05/12/2026, 6:03 AMtall-airline-16756
05/12/2026, 6:03 AMChatMessage.metrics?
Sources: Understand and Improve Agent Latency | LiveKit | Build Your First AI Voice Agent in Python: Complete Tutorial | LiveKit | Build Your First AI Voice Agent in Python: Complete Tutorial | LiveKit, with interactive elements