Building multi-agent architectures with LiveKit agents
Learn best practices for building multi-agent architectures including session state management, chat context handling, TaskGroup patterns, and dynamic per-client routing.
Last Updated:
When designing a multi-agent architecture with LiveKit, developers often ask:
- How do I transfer control between agents?
- How do I share context without making agents stateful?
- How should I manage session state vs LLM chat context?
- How do I build dynamic, per-client configurable flows?
Based on internal development and customer implementations, this guide covers recommended patterns for each of these challenges.
Transferring between agents with function_tool
The @function_tool decorator defines tools that an LLM can invoke. For multi-agent architectures, you can use tools to hand off control to another agent:
1from livekit.agents import Agent, function_tool, RunContext23class IntakeAgent(Agent):4def __init__(self):5super().__init__(6instructions="You gather initial information from callers."7)89@function_tool()10async def transfer_to_billing(self, context: RunContext) -> tuple[Agent, str]:11"""Transfer the caller to the billing specialist."""12return BillingAgent(), "Transferring you to our billing department."1314@function_tool()15async def transfer_to_scheduling(self, context: RunContext) -> tuple[Agent, str]:16"""Transfer the caller to scheduling."""17return SchedulingAgent(), "Let me connect you with scheduling."
When the LLM decides a handoff is appropriate, it calls the tool and returns both the new agent and a message to speak during the transition. The framework handles the orchestration automatically.
See the tools documentation for more on defining tools and the multi-agent handoff guide for handoff patterns.
Session state vs LLM chat context
A critical design decision in multi-agent systems is how to manage state. There are two complementary approaches:
- Session state (
userdata): Deterministic, structured data stored on the session - LLM chat context (
chat_ctx): The conversation history passed to the LLM
Recommended pattern: Session state as primary memory
For production systems, treat session.userdata as your primary source of truth for important data like:
- Caller identity (name, DOB, phone number)
- Account information
- Intent classification
- Collected form data
- Authentication status
1from dataclasses import dataclass, field2from typing import Optional, List3from livekit.agents import Agent, AgentSession, function_tool, RunContext45@dataclass6class SessionState:7# Deterministic memory - always reliable8caller_name: Optional[str] = None9date_of_birth: Optional[str] = None10phone_number: Optional[str] = None11account_id: Optional[str] = None12intent: Optional[str] = None13authenticated: bool = False14collected_data: dict = field(default_factory=dict)1516# Canonical conversation history - never truncated17full_history: List[dict] = field(default_factory=list)1819class IntakeAgent(Agent):20@function_tool()21async def save_caller_identity(22self,23context: RunContext,24name: str,25date_of_birth: str,26phone: str27):28"""Save verified caller identity information."""29state: SessionState = context.session.userdata30state.caller_name = name31state.date_of_birth = date_of_birth32state.phone_number = phone33return f"Thank you {name}, I've saved your information."
The LLM's chat context should be treated as working memory that can be truncated for performance, while userdata serves as persistent memory that survives across agents and context truncation.
Accessing conversation history
The SDK provides session.history to access the full conversation history:
1class SchedulingAgent(Agent):2async def check_context(self, context: RunContext):3# Access full conversation history from the session4full_history = context.session.history56# Access current agent's chat context7current_ctx = self.chat_ctx
Custom helper pattern: truncated context with summaries
Note: The following
ConversationManageris a custom helper pattern you can implement in your application. It is not part of the LiveKit SDK.
When you truncate chat_ctx for an agent (e.g., a specialized scheduling agent that doesn't need the full history), you risk losing information for subsequent agents. Here's a helper pattern you can use:
1from livekit.agents import ChatContext, ChatMessage23@dataclass4class SessionState:5# ... other fields ...6full_history: List[ChatMessage] = field(default_factory=list)78class ConversationManager:9"""Custom helper for managing chat context with safe truncation.10This is not part of the SDK - implement it in your application as needed.11"""1213@staticmethod14def create_truncated_context(15session: AgentSession,16max_messages: int = 10,17include_summary: bool = True18) -> ChatContext:19"""Create a truncated context for an agent while preserving key information."""20state: SessionState = session.userdata2122ctx = ChatContext()2324if include_summary and len(session.history) > max_messages:25# Add a summary message with key session state26summary = f"""Previous conversation summary:27- Caller: {state.caller_name or 'Unknown'}28- Account: {state.account_id or 'Not identified'}29- Intent: {state.intent or 'Not determined'}30- Key data collected: {state.collected_data}31"""32ctx.add_message(role="system", content=summary)3334# Add recent messages from session history35recent_messages = list(session.history)[-max_messages:]36for msg in recent_messages:37ctx.add_message(role=msg.role, content=msg.content)3839return ctx
Use this pattern in your handoff logic:
1class SchedulingAgent(Agent):2def __init__(self):3super().__init__(4instructions="You help schedule appointments."5)67@function_tool()8async def transfer_to_followup(self, context: RunContext) -> tuple[Agent, str]:9"""Transfer to follow-up agent with truncated context including summary."""10# Create truncated context with summary for the next agent11truncated_ctx = ConversationManager.create_truncated_context(12context.session,13max_messages=10,14include_summary=True15)16followup_agent = FollowUpAgent()17followup_agent.chat_ctx = truncated_ctx18return followup_agent, "Let me connect you with our follow-up team."
AgentTask and TaskGroup for structured data collection
AgentTask and TaskGroup are useful for structured data collection flows like authentication or surveys. To use them correctly, you need to create custom task classes that define their result types.
Creating custom task classes
The AgentTask class is generic and requires subclassing. Define your result type and call self.complete(result) when the task finishes:
1from livekit.agents import AgentTask2from dataclasses import dataclass34@dataclass5class NameResult:6first_name: str7last_name: str89class CollectNameTask(AgentTask[NameResult]):10def __init__(self):11super().__init__(12instructions="Ask for the caller's first and last name."13)1415@function_tool16async def set_name(self, context: RunContext, first_name: str, last_name: str):17"""Save the caller's name."""18self.complete(NameResult(first_name=first_name, last_name=last_name))19return "Got it, thank you."2021@dataclass22class DOBResult:23date_of_birth: str2425class CollectDOBTask(AgentTask[DOBResult]):26def __init__(self):27super().__init__(28instructions="Ask for the caller's date of birth for verification."29)3031@function_tool32async def set_dob(self, context: RunContext, date_of_birth: str):33"""Save the date of birth."""34self.complete(DOBResult(date_of_birth=date_of_birth))35return "Thank you for confirming."3637@dataclass38class PhoneResult:39phone_number: str4041class CollectPhoneTask(AgentTask[PhoneResult]):42def __init__(self):43super().__init__(44instructions="Ask for a callback phone number."45)4647@function_tool48async def set_phone(self, context: RunContext, phone_number: str):49"""Save the phone number."""50self.complete(PhoneResult(phone_number=phone_number))51return "I've saved your number."
Using TaskGroup with lambdas
TaskGroup collects tasks using the add() method with lambda functions. Pass chat_ctx in the constructor, then await the group directly:
1from livekit.agents import TaskGroup23class AuthenticationAgent(Agent):4async def run_auth_flow(self, context: RunContext):5state: SessionState = context.session.userdata67# Create task group with chat context8task_group = TaskGroup(chat_ctx=self.chat_ctx)910# Add tasks using lambdas - only for missing information11if not state.caller_name:12task_group.add(13lambda: CollectNameTask(),14id="collect_name",15description="Collect caller's name"16)1718if not state.date_of_birth:19task_group.add(20lambda: CollectDOBTask(),21id="collect_dob",22description="Collect date of birth"23)2425if not state.phone_number:26task_group.add(27lambda: CollectPhoneTask(),28id="collect_phone",29description="Collect phone number"30)3132# Await the task group directly33results = await task_group3435# Update session state with collected data36for result in results:37if isinstance(result, NameResult):38state.caller_name = f"{result.first_name} {result.last_name}"39elif isinstance(result, DOBResult):40state.date_of_birth = result.date_of_birth41elif isinstance(result, PhoneResult):42state.phone_number = result.phone_number
Accessing conversation history
Use session.history for full conversation history, or self.chat_ctx inside agents:
1class ContextAwareAgent(Agent):2async def check_previous_context(self, context: RunContext):3# Access full conversation history from session4full_history = context.session.history56# Access current agent's chat context7current_ctx = self.chat_ctx89# Check if information was already mentioned10for msg in full_history:11if "name" in msg.content.lower():12# Name was discussed earlier13pass
See the survey agent example and drive-thru example for canonical implementations of these patterns.
Dynamic per-client routing and flows
For multi-tenant systems, you often need different conversation flows per client. Here's a pattern for config-driven dynamic routing.
Define your client configuration schema
1from dataclasses import dataclass2from typing import List, Optional, Dict, Any34@dataclass5class WarmTransferConfig:6default_number: str7medical_number: Optional[str] = None8cosmetic_number: Optional[str] = None9error_fallback_number: Optional[str] = None1011@dataclass12class QuestionConfig:13id: str14prompt: str15response_type: str # "text", "yes_no", "multiple_choice"16options: Optional[List[str]] = None17required: bool = True1819@dataclass20class IntentFlowConfig:21intent_name: str22clarifying_question: Optional[str] = None # e.g., "Is this cosmetic or medical?"23clarifying_options: Optional[List[str]] = None24questions_by_option: Dict[str, List[QuestionConfig]] = field(default_factory=dict)25warm_transfer_by_option: Dict[str, str] = field(default_factory=dict)2627@dataclass28class ClientConfig:29client_id: str30client_name: str31warm_transfer: WarmTransferConfig32intent_flows: Dict[str, IntentFlowConfig] = field(default_factory=dict)33custom_greeting: Optional[str] = None34timezone: str = "UTC"
Load configuration at call start
1import json2from pathlib import Path34class ConfigLoader:5_configs: Dict[str, ClientConfig] = {}67@classmethod8async def load_client_config(cls, client_id: str) -> ClientConfig:9"""Load client config from database, file, or cache."""10if client_id in cls._configs:11return cls._configs[client_id]1213# Example: Load from JSON file (replace with your data source)14config_path = Path(f"configs/clients/{client_id}.json")15if config_path.exists():16data = json.loads(config_path.read_text())17config = ClientConfig(**data)18cls._configs[client_id] = config19return config2021# Return default config if not found22return ClientConfig(23client_id=client_id,24client_name="Default",25warm_transfer=WarmTransferConfig(default_number="+15551234567")26)2728# In your entrypoint29async def entrypoint(ctx: JobContext):30# Extract client ID from room metadata or participant attributes31client_id = ctx.room.metadata.get("client_id", "default")3233# Load configuration before starting the session34client_config = await ConfigLoader.load_client_config(client_id)3536# Initialize session state with config37session_state = SessionState()38session_state.client_config = client_config3940session = AgentSession()41session.userdata = session_state4243# Start with config-aware agent44await session.start(45agent=create_intake_agent(client_config),46room=ctx.room47)
Define custom task classes for dynamic questions
First, define task classes for your question types:
1@dataclass2class ClarificationResult:3option: str45class ClarificationTask(AgentTask[ClarificationResult]):6def __init__(self, question: str, options: List[str]):7super().__init__(8instructions=f"""Ask the caller: "{question}"9Valid responses are: {', '.join(options)}10Determine which option they want."""11)12self.options = options1314@function_tool15async def select_option(self, context: RunContext, option: str):16"""Record the caller's selection."""17self.complete(ClarificationResult(option=option))18return f"Got it, you selected {option}."1920@dataclass21class QuestionResult:22question_id: str23answer: str2425class DynamicQuestionTask(AgentTask[QuestionResult]):26def __init__(self, question_id: str, prompt: str, options: Optional[List[str]] = None):27instructions = f"Ask: {prompt}"28if options:29instructions += f"\nValid options: {', '.join(options)}"30super().__init__(instructions=instructions)31self.question_id = question_id3233@function_tool34async def record_answer(self, context: RunContext, answer: str):35"""Record the caller's answer."""36self.complete(QuestionResult(question_id=self.question_id, answer=answer))37return "Thank you."
Build TaskGroups dynamically from config
1def add_intent_tasks(2task_group: TaskGroup,3config: ClientConfig,4intent: str,5selected_option: Optional[str] = None6) -> bool:7"""Add tasks to a TaskGroup based on client config for a specific intent.8Returns True if tasks were added."""910if intent not in config.intent_flows:11return False1213flow = config.intent_flows[intent]1415# If we need clarification first16if flow.clarifying_question and not selected_option:17task_group.add(18lambda: ClarificationTask(19flow.clarifying_question,20flow.clarifying_options or []21),22id="clarification",23description="Get clarification from caller"24)25return True2627# Add tasks for the selected option28questions = flow.questions_by_option.get(selected_option, [])2930if not questions:31return False3233for q in questions:34# Use default argument to capture loop variable35task_group.add(36lambda q=q: DynamicQuestionTask(37question_id=q.id,38prompt=q.prompt,39options=q.options if q.response_type == "multiple_choice" else None40),41id=q.id,42description=f"Ask about {q.id}"43)4445return True
Set warm transfer numbers dynamically
1class DynamicRoutingAgent(Agent):2def __init__(self, config: ClientConfig):3self.config = config4super().__init__(5instructions=self._build_instructions()6)78def _build_instructions(self) -> str:9base = f"You are an assistant for {self.config.client_name}."10if self.config.custom_greeting:11base += f"\n\nUse this greeting: {self.config.custom_greeting}"12return base1314def _get_transfer_number(self, context: RunContext, option: str) -> str:15"""Get the appropriate warm transfer number based on context."""16state: SessionState = context.session.userdata17flow = self.config.intent_flows.get(state.intent, {})1819# Check for option-specific number20if option and flow:21specific_number = flow.warm_transfer_by_option.get(option)22if specific_number:23return specific_number2425# Fall back to category numbers26wt = self.config.warm_transfer27if option == "medical" and wt.medical_number:28return wt.medical_number29if option == "cosmetic" and wt.cosmetic_number:30return wt.cosmetic_number3132return wt.default_number3334@function_tool()35async def transfer_to_specialist(36self,37context: RunContext,38option: str39) -> str:40"""Transfer the caller to the appropriate specialist."""41transfer_number = self._get_transfer_number(context, option)4243# Initiate SIP transfer (implementation depends on your setup)44await initiate_warm_transfer(context.session, transfer_number)4546return f"Transferring you now. Please hold."4748@function_tool()49async def handle_error_transfer(self, context: RunContext) -> str:50"""Transfer to fallback number on error or timeout."""51fallback = self.config.warm_transfer.error_fallback_number52number = fallback or self.config.warm_transfer.default_number5354await initiate_warm_transfer(context.session, number)55return "Let me connect you with someone who can help."
Key takeaways
-
Use
userdataas primary memory: Store deterministic data (identity, account info, collected form data) insession.userdata, not just in chat context. -
Maintain a canonical history: Always append to a full conversation history in
userdatabefore truncatingchat_ctx. This lets you restore context when needed. -
Be explicit with TaskGroup context: Don't assume TaskGroup will automatically see earlier conversation. Inject known data into task instructions and only ask for missing information.
-
Design for truncation: When handing off to specialized agents, create truncated contexts that include a summary of session state plus recent messages.
-
Use config-driven flows: For multi-tenant systems, externalize flow logic into configuration that can vary per client.
-
Set transfer numbers dynamically: Use session state and client config to determine the appropriate transfer destination at runtime.