🎯 High-Level Overview

The LiveKit Agents framework is designed to build conversational AI agents that can:

  • Hear (STT - Speech-to-Text)
  • Understand (LLM - Language Model)
  • Speak (TTS - Text-to-Speech)
  • React (Function tools, handoffs, interruptions)

πŸ—οΈ Core Architecture Components

1. AgentSession (The Brain)

Location: livekit/agents/voice/agent_session.py

The AgentSession is the main runtime orchestrator that glues everything together.

What it does:

  • Manages the entire conversation lifecycle
  • Connects audio/video I/O with STT, TTS, LLM
  • Handles turn detection and interruptions
  • Maintains chat history
  • Manages agent switching (multi-agent scenarios)

Key properties:

session = AgentSession(
    stt=STT_ENGINE,           # Speech recognition
    llm=LLM_ENGINE,           # Intelligence
    tts=TTS_ENGINE,           # Voice synthesis
    vad=VAD_ENGINE,           # Voice activity detection
    tools=[...],              # Function tools
    turn_handling={...},      # Interrupt & endpointing config
)

2. Agent (The Personality)

Location: livekit/agents/voice/agent.py

An Agent is a persona with instructions and behaviors.

What it does:

  • Defines instructions/system prompt
  • Holds tools specific to this agent
  • Implements lifecycle hooks (on_enter, on_user_turn_completed)
  • Can be swapped during a session (multi-agent)

Simple example:

class MyAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="You are a helpful assistant",
            tools=[my_tool_func],
        )
    
    async def on_enter(self):
        # Called when agent starts
        self.session.generate_reply(
            instructions="Say hello"
        )

3. AgentActivity (The Worker)

Location: livekit/agents/voice/agent_activity.py

The AgentActivity is the active runtime for an Agent - it does the actual work.

What it does:

  • Processes audio streams
  • Runs STT, LLM, TTS pipelines
  • Handles function tool execution
  • Manages interruptions and turn-taking

Internal tasks:

  • _audio_recognition: Processes incoming user audio
  • _realtime_reply_task: Handles realtime API responses
  • _pipeline_reply_task: Handles standard STTβ†’LLMβ†’TTS pipeline

πŸ”„ Simple Request-Response Flow

Here’s how a basic conversation flows through the system:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€οΏ½οΏ½οΏ½β”€β”€β”
β”‚                       USER SPEAKS                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  VAD (Voice Activity Detector) - Silero                         β”‚
β”‚  β†’ "Is the user speaking?"                                      β”‚
β”‚  β†’ YES: Mark as "speaking" state, start recording              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  STT (Speech-to-Text) - Deepgram                               β”‚
β”‚  β†’ "What did they say?"                                        β”‚
β”‚  β†’ Returns: "What's the weather?"                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Turn Endpointing                                               β”‚
β”‚  β†’ Waits for silence (0.5-3 seconds configurable)              β”‚
β”‚  β†’ User turn is "complete"                                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LLM (Language Model) - OpenAI GPT-4                           β”‚
β”‚  β†’ Chat history + "What's the weather?"                        β”‚
β”‚  β†’ Thinks... β†’ "The weather is sunny"                          β”‚
β”‚  β†’ May call tools if needed                                    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  TTS (Text-to-Speech) - Cartesia                               β”‚
β”‚  β†’ "The weather is sunny"                                      β”‚
β”‚  β†’ Returns: Audio frames                                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚               AGENT SPEAKS TO USER                              β”‚
β”‚  Audio frames sent to speakers/network                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ“Š Key Component Interactions

Component Matrix

ComponentRoleProvided ByExample
STTConverts audio β†’ textPluginDeepgram, OpenAI
LLMProcesses text β†’ responsePluginGPT-4, Claude
TTSConverts text β†’ audioPluginCartesia, OpenAI
VADDetects voice activityPluginSilero, built-in
Interruption DetectionDetects user cutoffsBuilt-inAdaptive transformer
ToolsFunctions agent can callUser-defined@function_tool
Chat ContextConversation historyBuilt-inMaintained automatically

πŸ”Œ Inference Model

LiveKit Inference provides a unified API for model access:

# Before (plugin-specific):
from livekit.plugins import openai, deepgram
stt = deepgram.STT(model="nova-3")
llm = openai.LLM(model="gpt-4-mini")
 
# After (unified):
from livekit.agents import inference
stt = inference.STT("deepgram/nova-3")
llm = inference.LLM("openai/gpt-4-mini")

Benefits:

  • Switch providers easily
  • Use LiveKit Cloud for unified billing
  • Consistent error handling

🎯 Turn Detection & Interruptions

This is crucial for natural conversations.

Turn Handling Options:

session = AgentSession(
    turn_handling={
        "endpointing": {
            "mode": "vad",              # VAD or STT-based
            "min_delay": 0.5,           # Min silence before turn ends
            "max_delay": 3.0,           # Max wait time
        },
        "interruption": {
            "enabled": True,
            "mode": "adaptive",         # VAD or adaptive transformer
            "min_duration": 0.2,        # Min speech to interrupt
            "resume_after_interrupt": True,  # Resume if false interrupt
        },
    }
)

Interruption Flow:

  1. User starts speaking while agent is speaking
  2. Interruption detector triggers
  3. Agent’s audio playback pauses
  4. Agent shifts to β€œlistening” state
  5. Process user input
  6. Generate reply

πŸ› οΈ Function Tools Architecture

Simple Tool Definition:

from livekit.agents import function_tool, RunContext
 
@function_tool
async def lookup_weather(context: RunContext, location: str):
    """Get weather for a location."""
    return {"weather": "sunny", "temp": 70}
 
# Use it
agent = Agent(
    instructions="You can look up weather",
    tools=[lookup_weather],
)

Tool Execution Flow:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  LLM Response            β”‚
β”‚  "I'll check weather"    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Function Call Detection β”‚
β”‚  tool_name: "lookup_weather"
β”‚  args: {"location": "NYC"}
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Execute Tool            β”‚
β”‚  lookup_weather("NYC")   β”‚
β”‚  β†’ Returns weather data  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Add to Chat Context     β”‚
β”‚  FunctionCallOutput      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Generate Reply          β”‚
β”‚  "It's sunny in NYC!"    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Max tool steps: Configurable (default: 3 consecutive calls)


πŸ”„ Multi-Agent Handoff

Switch between agents dynamically:

class IntroAgent(Agent):
    @function_tool
    async def gather_info(self, context: RunContext, name: str):
        # Return new agent and transition message
        story_agent = StoryAgent(name)
        return story_agent, "Now let me tell you a story!"
 
# Agent switching happens automatically
# Session maintains chat history across handoffs

πŸ“ Session Lifecycle

Startup:

session = AgentSession(stt=..., llm=..., tts=...)
agent = MyAgent()
 
# Start session
await session.start(agent=agent, room=ctx.room)

During Session:

# Generate response
session.generate_reply(
    user_input="Hello",
    instructions="Be friendly"
)
 
# Agent can speak directly
session.say("Let me help you!")
 
# Interrupt current speech
session.interrupt()
 
# Commit user turn (flush STT buffer)
await session.commit_user_turn()

Shutdown:

await session.aclose()

πŸŽ›οΈ Configuration: Simple vs Complex

Simple (Works out-of-box):

session = AgentSession(
    stt="deepgram/nova-3",
    llm="openai/gpt-4-mini",
    tts="cartesia/sonic-3",
)

Advanced (Full control):

session = AgentSession(
    stt=deepgram.STT(model="nova-3", language="en"),
    llm=openai.LLM(model="gpt-4"),
    tts=cartesia.TTS(model="sonic-3", voice="id-123"),
    vad=silero.VAD.load(),
    turn_handling={
        "endpointing": {"mode": "vad", "min_delay": 0.3, "max_delay": 2.5},
        "interruption": {"enabled": True, "mode": "adaptive"},
    },
    max_tool_steps=5,
    min_consecutive_speech_delay=0.1,
    aec_warmup_duration=2.0,  # Echo cancellation warmup
)

πŸ“Š Agent States

The agent cycles through states:

initializing β†’ listening β†’ speaking β†’ thinking β†’ listening β†’ ...
     ↑                                                      ↓
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

User States:

  • listening: User is quiet
  • speaking: User is talking
  • away: No activity for timeout duration

πŸŽͺ Real-time vs Pipeline Modes

Pipeline Mode (Standard):

Audio β†’ STT β†’ LLM β†’ TTS β†’ Audio
  • More control, better for complex logic
  • Slightly higher latency
  • Default choice

Realtime Mode:

Audio ⇄ Realtime API (STT + LLM + TTS in one)
  • Lower latency (150ms range)
  • Streaming input/output
  • Used by OpenAI Realtime API
from livekit.plugins import openai
 
session = AgentSession(
    llm=openai.realtime.RealtimeModel(voice="echo")
)

πŸ“š Example: Complete Flow

from livekit.agents import AgentSession, Agent, JobContext
from livekit.plugins import openai, silero
 
class CustomerServiceAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="You're a helpful customer service rep. Keep responses concise.",
            tools=[lookup_order, process_refund],
        )
    
    async def on_enter(self):
        self.session.generate_reply(
            instructions="Greet the user professionally"
        )
    
    async def on_user_turn_completed(self):
        # Called after user finishes speaking
        pass
 
@server.rtc_session()
async def entrypoint(ctx: JobContext):
    session = AgentSession(
        stt="deepgram/nova-3",
        llm="openai/gpt-4-mini",
        tts="cartesia/sonic-3",
        vad=silero.VAD.load(),
    )
    
    agent = CustomerServiceAgent()
    await session.start(agent=agent, room=ctx.room)
    # Session runs until shutdown

πŸ” Events & Monitoring

@session.on("user_state_changed")
def on_user_state(event):
    print(f"User: {event.old_state} β†’ {event.new_state}")
 
@session.on("agent_state_changed")
def on_agent_state(event):
    print(f"Agent: {event.old_state} β†’ {event.new_state}")
 
@session.on("user_input_transcribed")
def on_transcript(event):
    print(f"User said: {event.transcript}")
 
@session.on("session_usage_updated")
def on_usage(event):
    print(f"Used: {event.usage}")  # Token/billing info

🎁 Key Takeaways

ConceptPurpose
AgentSessionOrchestrates everything - the runtime
AgentDefines personality, instructions, tools
AgentActivityActive execution of an Agent
STT/LLM/TTSThe modular AI pipeline
ToolsGive agents abilities to take action
Turn HandlingNatural conversation flow (interruptions, endpointing)
Chat ContextPersistent conversation history
AgentServerManages job scheduling across agents

πŸ“– Architecture Diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      AgentServer                             β”‚
β”‚         (Job scheduling & dispatch)                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    AgentSession                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  Input                 Core            Output         β”‚  β”‚
β”‚  β”‚  ────────            ──────            ──────         β”‚  β”‚
β”‚  β”‚  Audio    ────→   AgentActivity  ────→  Audio        β”‚  β”‚
β”‚  β”‚  Video    ────→   [Agent Loop]   ────→  Video        β”‚  β”‚
β”‚  β”‚  Text     ────→   Turn Handling  ────→  Text         β”‚  β”‚
β”‚  β”‚                    Chat History                       β”‚  β”‚
β”‚  β”‚                                                       β”‚  β”‚
β”‚  β”‚  Components:                                         β”‚  β”‚
│  │  ‒ STT (Speech→Text)                                │  │
│  │  ‒ LLM (Text→Response)                              │  │
│  │  ‒ TTS (Response→Speech)                            │  │
β”‚  β”‚  β€’ VAD (Voice Detection)                            β”‚  β”‚
β”‚  β”‚  β€’ Tools (Function Execution)                       β”‚  β”‚
β”‚  β”‚  β€’ Turn Detector (Interruption/Endpointing)        β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     ↑
         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
         ↓                       ↓
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚  Agent  β”‚            β”‚  Agent  β”‚
    β”‚    1    β”‚            β”‚    2    β”‚
    β”‚ (Can    β”‚            β”‚(Swapped β”‚
    β”‚  switch)β”‚            β”‚ in)     β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

This framework makes building voice AI agents straightforward by handling complex real-time audio streaming, turn-taking logic, and LLM orchestration transparently! πŸš€

LiveKit Agents: Deep Dive - AgentSession, AgentActivity & STT→LLM→TTS Pipeline

This is an in-depth architectural guide covering how the framework processes audio through the complete pipeline.

πŸ“Š Complete Architecture Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                              AgentSession                                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚                        AgentActivity                               β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚  β”‚
β”‚  β”‚  β”‚                   Audio Recognition                           β”‚ β”‚  β”‚
β”‚  β”‚  β”‚  (STT β†’ VAD β†’ Turn Detection β†’ Chat Context)                β”‚ β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚
β”‚  β”‚                     β”‚                                               β”‚  β”‚
β”‚  β”‚                     ↓                                               β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚  β”‚
β”‚  β”‚  β”‚         LLM Generation (Agent.llm_node)                      β”‚ β”‚  β”‚
β”‚  β”‚  β”‚  Chat Context β†’ LLM Stream β†’ Text Chunks β†’ Tools             β”‚ β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚
β”‚  β”‚                     β”‚                                               β”‚  β”‚
β”‚  β”‚       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                β”‚  β”‚
β”‚  β”‚       ↓                           ↓                                β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                        β”‚  β”‚
β”‚  β”‚  β”‚ TTS Node    β”‚          β”‚ Transcriptionβ”‚                        β”‚  β”‚
β”‚  β”‚  β”‚ (Audio)     β”‚          β”‚ Node (Text)  β”‚                        β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                        β”‚  β”‚
β”‚  β”‚         β”‚                        β”‚                                β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β†“β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β†“β”€β”€β”€β”€β”€β”€β”                        β”‚  β”‚
β”‚  β”‚  β”‚   Audio Output       Text Output      β”‚                        β”‚  β”‚
β”‚  β”‚  β”‚   (Playback)         (Transcription)  β”‚                        β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                        β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚  β”‚
β”‚                                                                          β”‚  β”‚
β”‚  Input β†’ STT β†’ LLM β†’ TTS β†’ Output                                      β”‚  β”‚
β”‚  (Audio)  (Text) (Response) (Audio)                                    β”‚  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ”§ Core Classes & Their Responsibilities

1. AgentSession (The Orchestrator)

File: livekit/agents/voice/agent_session.py

Purpose: Manages the entire lifecycle and state of a conversation

class AgentSession:
    """The top-level runtime that:
    1. Connects audio/video I/O
    2. Manages STT, LLM, TTS instances
    3. Handles turn detection & interruptions
    4. Maintains chat history
    5. Manages agent switching
    """
    
    # Key properties
    stt: STT | None              # Speech recognition engine
    llm: LLM | RealtimeModel     # Language model
    tts: TTS | None              # Text-to-speech engine
    vad: VAD | None              # Voice activity detector
    
    # Key methods
    async def start(agent, room)           # Start session
    def generate_reply(user_input)         # Generate response
    def interrupt(force=False)             # Interrupt current speech
    async def aclose()                     # Cleanup

State Management:

  • _user_state: β€œlistening” | β€œspeaking” | β€œaway”
  • _agent_state: β€œinitializing” | β€œlistening” | β€œspeaking” | β€œthinking”
  • _activity: Current AgentActivity (the active agent’s worker)
  • _chat_ctx: Global conversation history

2. AgentActivity (The Worker)

File: livekit/agents/voice/agent_activity.py

Purpose: Executes the actual audio processing for one agent

class AgentActivity:
    """Handles:
    1. Audio input processing (push_audio)
    2. STT streaming
    3. LLM generation (with tool execution)
    4. TTS synthesis
    5. Audio/text output forwarding
    """
    
    # Key properties
    agent: Agent                 # The agent being executed
    session: AgentSession        # Parent session
    
    # Resolves to session if agent doesn't define
    @property
    def stt(self) -> STT:
        return self.agent.stt or self.session.stt
    
    @property
    def llm(self) -> LLM | RealtimeModel:
        return self.agent.llm or self.session.llm
    
    @property
    def tts(self) -> TTS:
        return self.agent.tts or self.session.tts
    
    # Key methods
    def push_audio(frame)                  # Push audio to be transcribed
    async def drain()                      # Wait for current turn to finish
    def interrupt(force=False)             # Interrupt agent speaking
    def say(text)                          # Agent says something directly
    def generate_reply(user_input)         # Generate response to user

Internal Tasks:

  • _audio_recognition: Receives and transcribes user audio (STT)
  • _pipeline_reply_task: Orchestrates LLM β†’ TTS pipeline
  • _realtime_reply_task: For Realtime API models
  • Speech scheduling and interruption handling

🎯 End-to-End Flow: STT β†’ LLM β†’ TTS

Complete Request-Response Cycle

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          USER SPEAKS (AUDIO)                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ STEP 1: AgentSession.input.audio receives frames                        β”‚
β”‚   β†’ _forward_audio_task routes frames to AgentActivity.push_audio()     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ STEP 2: AgentActivity._audio_recognition processes audio                β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚   β”‚ A. STT Pipeline (_AudioRecognition):                            β”‚  β”‚
β”‚   β”‚    β€’ Receives audio frames from AgentActivity                   β”‚  β”‚
β”‚   β”‚    β€’ Calls Agent.stt_node(audio) β†’ STT.stream()               β”‚  β”‚
β”‚   β”‚    β€’ STT receives audio and returns SpeechEvent(text=...)      β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ B. Emit "user_input_transcribed" event                         β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ C. Update user_state β†’ "speaking"                              β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ D. Turn Detection & Endpointing:                               β”‚  β”‚
β”‚   β”‚    β€’ VAD detects silence (min 0.5s β†’ max 3s)                  β”‚  β”‚
β”‚   β”‚    β€’ Emits turn_complete signal                                β”‚  β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ STEP 3: on_user_turn_completed triggered                                β”‚
β”‚   β†’ Calls _on_user_turn_completed() β†’ generate_reply()                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ STEP 4: LLM Generation (_pipeline_reply_task)                           β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚   β”‚ A. Create ChatContext with user message + history              β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ B. Call Agent.llm_node(chat_ctx, tools) β†’ LLM.chat()          β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ C. LLM streams chunks:                                         β”‚  β”‚
β”‚   β”‚    β€’ ChatChunk(delta=ChoiceDelta(content="Hello"))            β”‚  β”‚
β”‚   β”‚    β€’ ChatChunk(delta=ChoiceDelta(tool_calls=[...]))           β”‚  β”‚
β”‚   β”‚    β€’ ChatChunk(usage=CompletionUsage(...))                    β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ D. Collect text in buffer (or execute tools)                  β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ E. Emit llm_node_ttft metric (Time-to-first-token)           β”‚  β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ STEP 5: TTS Synthesis (_tts_task_impl or preemptive TTS)                β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚   β”‚ A. Text from LLM goes to Agent.tts_node()                      β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ B. Call TTS.stream() (for streaming) or .synthesize()          β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ C. TTS sends back audio frames (SynthesizedAudio)              β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ D. Emit tts_node_ttfb metric (Time-to-first-byte of audio)    β”‚  β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€οΏ½οΏ½β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ STEP 6: Output Forwarding                                               β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚   β”‚ A. Audio Output:                                               β”‚  β”‚
β”‚   β”‚    β€’ perform_audio_forwarding() pushes frames to output.audio  β”‚  β”‚
β”‚   β”‚    β€’ Plays to user via RTC track or custom handler            β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ B. Text Output:                                                β”‚  β”‚
β”‚   β”‚    β€’ perform_text_forwarding() to output.transcription        β”‚  β”‚
β”‚   β”‚    β€’ Sends transcript to client                                β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ C. Update agent_state β†’ "speaking"                            β”‚  β”‚
β”‚   β”‚    ↓                                                             β”‚  β”‚
β”‚   β”‚ D. Add to chat context (ChatMessage with role="assistant")    β”‚  β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ STEP 7: Wait for playout to finish                                      β”‚
β”‚   β€’ Emit e2e_latency metric                                             β”‚
β”‚   β€’ Update agent_state β†’ "listening"                                    β”‚
β”‚   β€’ Return to waiting for next user input                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ’» Deep Dive: Key Classes

STT (Speech-to-Text)

File: livekit/agents/stt/stt.py

@dataclass
class STTCapabilities:
    streaming: bool                              # Stream audio or batch?
    interim_results: bool                        # Send partial results?
    diarization: bool = False                    # Multi-speaker detection
    aligned_transcript: Literal["word", "chunk", False] = False  # Word-level timestamps
    offline_recognize: bool = True               # Supports batch mode
 
@dataclass
class SpeechEvent:
    type: SpeechEventType  # START_OF_SPEECH | INTERIM_TRANSCRIPT | FINAL_TRANSCRIPT
    request_id: str
    alternatives: list[SpeechData]  # Multiple recognition hypotheses
    speech_start_time: float | None  # Server-reported onset time
 
class STT(ABC, EventEmitter):
    """Abstract base for all STT implementations"""
    
    @property
    def model(self) -> str:
        """Override to return model name"""
        return "unknown"
    
    @property
    def provider(self) -> str:
        """Override to return provider name"""
        return "unknown"
    
    @property
    def capabilities(self) -> STTCapabilities:
        return self._capabilities
    
    @abstractmethod
    async def _recognize_impl(
        self,
        buffer: AudioBuffer,
        *,
        language: NotGivenOr[str] = NOT_GIVEN,
        conn_options: APIConnectOptions,
    ) -> SpeechEvent:
        """Batch recognition - process entire audio buffer at once"""
        ...
    
    async def recognize(self, buffer, *, language=NOT_GIVEN, conn_options=...):
        """Public API with retry logic"""
        for i in range(conn_options.max_retry + 1):
            try:
                event = await self._recognize_impl(buffer, ...)
                self.emit("metrics_collected", STTMetrics(...))
                return event
            except APIError as e:
                if i == conn_options.max_retry:
                    raise
                await asyncio.sleep(retry_interval)
    
    def stream(self, *, language=NOT_GIVEN, conn_options=...) -> RecognizeStream:
        """Return a streaming recognition stream"""
        raise NotImplementedError(...)

RecognizeStream (for streaming STT):

class RecognizeStream(ABC):
    """Async generator-like interface for streaming STT"""
    
    def push_frame(self, frame: rtc.AudioFrame):
        """Push audio frame to be recognized"""
        self._input_ch.send_nowait(frame)
    
    def flush(self):
        """Mark end of segment"""
        self._input_ch.send_nowait(self._FlushSentinel())
    
    def end_input(self):
        """Mark end of entire input"""
        self.flush()
        self._input_ch.close()
    
    async def __anext__(self) -> SpeechEvent:
        """Async iteration - get next recognition event"""
        return await self._event_aiter.__anext__()

LLM (Large Language Model)

File: livekit/agents/llm/llm.py

class CompletionUsage(BaseModel):
    completion_tokens: int       # Output tokens
    prompt_tokens: int          # Input tokens  
    prompt_cached_tokens: int = 0
    total_tokens: int
    service_tier: str | None = None
 
class FunctionToolCall(BaseModel):
    type: Literal["function"] = "function"
    name: str                   # Function name to call
    arguments: str              # JSON string of args
    call_id: str                # Unique ID for this call
 
class ChatChunk(BaseModel):
    id: str
    delta: ChoiceDelta | None   # Incremental change
    usage: CompletionUsage | None
 
class ChoiceDelta(BaseModel):
    role: ChatRole | None = None    # system | user | assistant
    content: str | None = None      # Text content
    tool_calls: list[FunctionToolCall] = []
 
class LLM(ABC, EventEmitter):
    """Abstract base for all LLM implementations"""
    
    @property
    def model(self) -> str:
        return "unknown"
    
    @property
    def provider(self) -> str:
        return "unknown"
    
    @abstractmethod
    def chat(
        self,
        *,
        chat_ctx: ChatContext,
        tools: list[Tool] | None = None,
        conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
        parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
        tool_choice: NotGivenOr[ToolChoice] = NOT_GIVEN,
        extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
    ) -> LLMStream:
        """Generate a chat completion stream"""
        ...
 
class LLMStream(ABC):
    """Async generator for LLM streaming"""
    
    async def __anext__(self) -> ChatChunk:
        """Get next chunk of generation"""
        ...
    
    async def collect(self) -> CollectedResponse:
        """Collect entire stream into single response"""
        text = ""
        tool_calls = []
        async for chunk in self:
            if chunk.delta:
                if chunk.delta.content:
                    text += chunk.delta.content
                if chunk.delta.tool_calls:
                    tool_calls.extend(chunk.delta.tool_calls)
        return CollectedResponse(text=text, tool_calls=tool_calls)

TTS (Text-to-Speech)

File: livekit/agents/tts/tts.py

@dataclass
class TTSCapabilities:
    streaming: bool              # WebSocket streaming or batch?
    aligned_transcript: bool = False  # Word-level timestamps in response?
 
@dataclass
class SynthesizedAudio:
    frame: rtc.AudioFrame        # Audio data
    request_id: str              # Provider request ID
    segment_id: str = ""         # Which segment (streaming only)
    is_final: bool = False       # Last frame of segment?
    delta_text: str = ""         # What text was synthesized
 
class TTS(ABC, EventEmitter):
    """Abstract base for all TTS implementations"""
    
    def __init__(
        self,
        *,
        capabilities: TTSCapabilities,
        sample_rate: int,
        num_channels: int,
    ):
        self._capabilities = capabilities
        self._sample_rate = sample_rate
        self._num_channels = num_channels
    
    @property
    def model(self) -> str:
        return "unknown"
    
    @property
    def provider(self) -> str:
        return "unknown"
    
    @abstractmethod
    def synthesize(
        self,
        text: str,
        *,
        conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
    ) -> ChunkedStream:
        """Synthesize single text chunk - returns async generator"""
        ...
    
    def stream(self, *, conn_options=...) -> SynthesizeStream:
        """Return a streaming synthesis stream"""
        raise NotImplementedError(...)

ChunkedStream (for non-streaming TTS):

class ChunkedStream(ABC):
    """For TTS providers that accept a text chunk and return audio"""
    
    async def __anext__(self) -> SynthesizedAudio:
        """Get next audio frame"""
        ...
    
    async def collect(self) -> rtc.AudioFrame:
        """Collect all frames into single audio"""
        frames = []
        async for ev in self:
            frames.append(ev.frame)
        return rtc.combine_audio_frames(frames)

SynthesizeStream (for streaming TTS):

class SynthesizeStream(ABC):
    """For TTS providers that accept streaming text and output audio"""
    
    def push_text(self, token: str):
        """Push text to be synthesized"""
        self._input_ch.send_nowait(token)
    
    def flush(self):
        """Mark end of current segment"""
        self._input_ch.send_nowait(self._FlushSentinel())
    
    def end_input(self):
        """Mark end of all input"""
        self.flush()
        self._input_ch.close()
    
    async def __anext__(self) -> SynthesizedAudio:
        """Get next audio frame"""
        ...

πŸ› οΈ Creating Custom STT, LLM, TTS

Custom STT Implementation

from livekit.agents import stt
from livekit import rtc
from typing import AsyncGenerator
 
class MyCustomSTT(stt.STT):
    def __init__(self):
        super().__init__(
            capabilities=stt.STTCapabilities(
                streaming=True,              # Supports real-time audio
                interim_results=True,        # Sends partial transcripts
                aligned_transcript="word",   # Word-level timestamps
            )
        )
        self._client = MySTTClient()  # Your API client
    
    @property
    def model(self) -> str:
        return "my-model-v1"
    
    @property
    def provider(self) -> str:
        return "my-company"
    
    # For batch recognition (non-streaming)
    async def _recognize_impl(
        self,
        buffer: AudioBuffer,
        *,
        language: NotGivenOr[str] = NOT_GIVEN,
        conn_options: APIConnectOptions,
    ) -> stt.SpeechEvent:
        """Process entire audio buffer at once"""
        audio_data = rtc.combine_audio_frames(buffer).to_wav_bytes()
        
        # Call your STT API
        response = await self._client.recognize(
            audio=audio_data,
            language=language or "en",
            timeout=conn_options.timeout
        )
        
        # Convert to SpeechEvent
        return stt.SpeechEvent(
            type=stt.SpeechEventType.FINAL_TRANSCRIPT,
            request_id=response.request_id,
            alternatives=[
                stt.SpeechData(
                    text=response.transcript,
                    language=LanguageCode("en"),
                    confidence=response.confidence,
                    words=[
                        stt.TimedString(
                            text=word.text,
                            start_time=word.start,
                            end_time=word.end
                        )
                        for word in response.words
                    ]
                )
            ]
        )
    
    # For streaming recognition
    def stream(
        self,
        *,
        language: NotGivenOr[str] = NOT_GIVEN,
        conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
    ) -> stt.RecognizeStream:
        return MySTTStream(
            stt=self,
            language=language,
            conn_options=conn_options,
            client=self._client
        )
 
class MySTTStream(stt.RecognizeStream):
    def __init__(self, stt, language, conn_options, client):
        super().__init__(stt=stt, conn_options=conn_options)
        self._language = language
        self._client = client
        self._ws = None
    
    async def _run(self) -> None:
        """Main loop - connect to server, push/receive data"""
        # Connect WebSocket
        self._ws = await self._client.connect_stream(
            language=self._language or "en"
        )
        
        try:
            # Send audio as it comes in
            async def _send_audio():
                async for item in self._input_ch:
                    if isinstance(item, self._FlushSentinel):
                        await self._ws.send_message({"type": "flush"})
                    else:
                        # item is rtc.AudioFrame
                        await self._ws.send_audio(item.data)
            
            send_task = asyncio.create_task(_send_audio())
            
            # Receive transcripts
            async for transcript in self._ws.receive_transcripts():
                event = stt.SpeechEvent(
                    type=stt.SpeechEventType.FINAL_TRANSCRIPT 
                        if transcript.is_final 
                        else stt.SpeechEventType.INTERIM_TRANSCRIPT,
                    request_id=transcript.id,
                    alternatives=[
                        stt.SpeechData(
                            text=transcript.text,
                            language=LanguageCode("en"),
                            confidence=transcript.confidence,
                        )
                    ]
                )
                self._event_ch.send_nowait(event)
        
        finally:
            await send_task
            await self._ws.close()

Custom LLM Implementation

from livekit.agents import llm
 
class MyCustomLLM(llm.LLM):
    def __init__(self, api_key: str, model: str = "my-model"):
        super().__init__()
        self._api_key = api_key
        self._model = model
        self._client = MyLLMClient(api_key=api_key)
    
    @property
    def model(self) -> str:
        return self._model
    
    @property
    def provider(self) -> str:
        return "my-company"
    
    def chat(
        self,
        *,
        chat_ctx: llm.ChatContext,
        tools: list[llm.Tool] | None = None,
        conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
        parallel_tool_calls: NotGivenOr[bool] = NOT_GIVEN,
        tool_choice: NotGivenOr[llm.ToolChoice] = NOT_GIVEN,
        extra_kwargs: NotGivenOr[dict[str, Any]] = NOT_GIVEN,
    ) -> llm.LLMStream:
        return MyLLMStream(
            llm=self,
            chat_ctx=chat_ctx,
            tools=tools or [],
            conn_options=conn_options,
            client=self._client,
            tool_choice=tool_choice,
        )
 
class MyLLMStream(llm.LLMStream):
    def __init__(self, llm, chat_ctx, tools, conn_options, client, tool_choice):
        super().__init__(llm=llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
        self._client = client
        self._tool_choice = tool_choice
    
    async def _run(self) -> None:
        """Stream LLM response"""
        # Build messages from chat context
        messages = []
        for msg in self._chat_ctx.messages:
            messages.append({
                "role": msg.role,
                "content": msg.text_content or ""
            })
        
        # Convert tools to provider format
        tools_payload = []
        for tool in self._tools:
            tools_payload.append({
                "name": tool.name,
                "description": tool.description,
                "parameters": tool.raw_schema
            })
        
        # Stream from API
        async for chunk in self._client.stream_completion(
            model=self._llm.model,
            messages=messages,
            tools=tools_payload if tools_payload else None,
            tool_choice=self._tool_choice,
            timeout=self._conn_options.timeout
        ):
            # Parse chunk
            if chunk.type == "text_delta":
                chat_chunk = llm.ChatChunk(
                    id=chunk.id,
                    delta=llm.ChoiceDelta(content=chunk.text)
                )
            elif chunk.type == "tool_call_delta":
                chat_chunk = llm.ChatChunk(
                    id=chunk.id,
                    delta=llm.ChoiceDelta(
                        tool_calls=[
                            llm.FunctionToolCall(
                                name=chunk.tool_name,
                                arguments=chunk.tool_args,
                                call_id=chunk.call_id
                            )
                        ]
                    )
                )
            elif chunk.type == "usage":
                chat_chunk = llm.ChatChunk(
                    id=chunk.id,
                    usage=llm.CompletionUsage(
                        completion_tokens=chunk.completion_tokens,
                        prompt_tokens=chunk.prompt_tokens,
                        total_tokens=chunk.total_tokens
                    )
                )
            
            self._event_ch.send_nowait(chat_chunk)

Custom TTS Implementation

from livekit.agents import tts
from livekit import rtc
 
class MyCustomTTS(tts.TTS):
    def __init__(self, api_key: str, voice: str = "default"):
        super().__init__(
            capabilities=tts.TTSCapabilities(
                streaming=True,           # Supports streaming
                aligned_transcript=True   # Can provide word timestamps
            ),
            sample_rate=24000,
            num_channels=1
        )
        self._api_key = api_key
        self._voice = voice
        self._client = MyTTSClient(api_key=api_key)
    
    @property
    def model(self) -> str:
        return "my-tts-v1"
    
    @property
    def provider(self) -> str:
        return "my-company"
    
    def synthesize(
        self,
        text: str,
        *,
        conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
    ) -> tts.ChunkedStream:
        # For non-streaming TTS, wrap stream implementation
        return self._synthesize_with_stream(text, conn_options=conn_options)
    
    def stream(
        self,
        *,
        conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
    ) -> tts.SynthesizeStream:
        return MyTTSStream(
            tts=self,
            conn_options=conn_options,
            client=self._client,
            voice=self._voice
        )
 
class MyTTSStream(tts.SynthesizeStream):
    def __init__(self, tts, conn_options, client, voice):
        super().__init__(tts=tts, conn_options=conn_options)
        self._client = client
        self._voice = voice
        self._ws = None
    
    async def _run(self, output_emitter: tts.AudioEmitter) -> None:
        """Stream TTS generation"""
        # Initialize output with audio properties
        output_emitter.initialize(
            request_id=shortuuid(),
            sample_rate=self._tts.sample_rate,
            num_channels=self._tts.num_channels,
            mime_type="audio/pcm",
            stream=True
        )
        
        # Connect to TTS API WebSocket
        self._ws = await self._client.connect_stream(
            voice=self._voice,
            sample_rate=self._tts.sample_rate
        )
        
        try:
            # Forward input text to server
            async def _send_text():
                async for item in self._input_ch:
                    if isinstance(item, self._FlushSentinel):
                        await self._ws.send({"type": "flush"})
                    else:
                        await self._ws.send({"type": "text", "content": item})
            
            send_task = asyncio.create_task(_send_text())
            
            # Receive audio frames from server
            segment_id = shortuuid()
            output_emitter.start_segment(segment_id=segment_id)
            
            async for audio_chunk in self._ws.receive_audio():
                # Decode/parse audio from provider
                if audio_chunk.format == "pcm":
                    output_emitter.push(audio_chunk.data)
                
                # Handle segment boundaries
                if audio_chunk.is_segment_end:
                    output_emitter.end_segment()
                    segment_id = shortuuid()
                    output_emitter.start_segment(segment_id=segment_id)
            
        finally:
            await send_task
            output_emitter.end_input()
            await self._ws.close()

πŸ”„ AgentSession β†’ AgentActivity Data Flow

Session Start: await session.start(agent, room)

# Inside AgentSession.start():
async def start(self, agent, room=None):
    self._agent = agent
    
    # Create AgentActivity to execute the agent
    self._activity = AgentActivity(agent, self)
    
    # Start the activity
    await self._activity.start()
    
    # Start forwarding audio/video
    self._forward_audio_atask = asyncio.create_task(
        self._forward_audio_task()
    )
 
async def _forward_audio_task(self):
    """Routes incoming audio to the activity"""
    async for frame in self.input.audio:
        if self._activity:
            self._activity.push_audio(frame)

User Speaks: Audio enters the pipeline

# Audio arrives from RTC room/input
# ↓
# _forward_audio_task receives it
# ↓
# AgentActivity.push_audio(frame)
#   ↓
#   _audio_recognition.push_audio(frame)
#     ↓
#     STT stream receives frame
#       ↓
#       STT API returns SpeechEvent
#         ↓
#         _on_user_turn_completed() if turn ended
#           ↓
#           generate_reply() called

πŸ“Š Configuration: Simple vs Advanced

Simple Setup:

session = AgentSession(
    stt="deepgram/nova-3:en",
    llm="openai/gpt-4-mini",
    tts="cartesia/sonic-3"
)

Advanced Setup with Custom Implementation:

session = AgentSession(
    stt=MyCustomSTT(api_key="..."),
    llm=MyCustomLLM(api_key="..."),
    tts=MyCustomTTS(api_key="..."),
    vad=silero.VAD.load(),
    turn_handling={
        "endpointing": {
            "mode": "vad",
            "min_delay": 0.3,
            "max_delay": 2.5
        },
        "interruption": {
            "enabled": True,
            "mode": "adaptive",
            "min_duration": 0.2
        }
    },
    max_tool_steps=5
)

🎯 Key Takeaways

ComponentPurposeKey Method
AgentSessionOrchestrates entire conversationasync def start()
AgentActivityExecutes one agent’s logicdef push_audio()
STTConverts audio β†’ textdef stream() or async def recognize()
LLMGenerates text responsedef chat() returns LLMStream
TTSConverts text β†’ audiodef stream() or def synthesize()
ChatContextMaintains historyAdded via session
AudioRecognitionManages STT pipelineInternal to AgentActivity
SpeechHandleTracks individual responsesManages interruptions, metrics

Flow Summary:

Audio (STT) β†’ Text (LLM) β†’ Response Text (TTS) β†’ Audio (Output)
     ↑                                                    ↓
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         (Turn Detection, Interruptions, Tools)

This architecture provides composability, streaming support, and real-time responsiveness! πŸš€