The full application

Every LiveKit worker has one entrypoint. It is where you build the agents, register them in shared state, and start the session on the triage agent. This is the moment the architecture collapses from three separate classes into one running system.

triage_agent.py
python
from livekit.agents import JobContext, WorkerOptions, cli
from livekit.agents.voice import AgentSession
from livekit.agents.llm import function_tool

async def entrypoint(ctx: JobContext):
    # Only pick up rooms this worker is responsible for
    if not ctx.room.name.startswith("medical_"):
        return

    await ctx.connect()

    # Build shared state and register all specialists
    userdata = UserData(ctx=ctx)
    triage = TriageAgent()
    support = SupportAgent()
    billing = BillingAgent()

    userdata.personas.update({
        "triage": triage,
        "support": support,
        "billing": billing,
    })

    session = AgentSession[UserData](userdata=userdata)

    # Caller always lands on triage first
    await session.start(agent=triage, room=ctx.room)

The entrypoint builds every agent once per call, registers them in personas, and starts the session on the triage agent. The prefix check keeps this worker from grabbing rooms meant for other demos.

triage_agent.py
python
from livekit.agents.llm import function_tool
from livekit.agents.voice import Agent
from livekit.plugins import deepgram, silero
from utils.livekit_utils import get_livekit_llm
from base_agent import BaseAgent
from user_data import RunContext_T
from prompt_utils import load_prompt

class BillingAgent(BaseAgent):
    """Handles insurance, copays, and payment plans."""

    def __init__(self) -> None:
        super().__init__(
            instructions=load_prompt("billing_prompt.yaml"),
            stt=deepgram.STT(),
            llm=get_livekit_llm(),
            tts=deepgram.TTS(model="aura-asteria-en"),
            vad=silero.VAD.load(),
        )

    @function_tool
    async def transfer_to_support(self, context: RunContext_T) -> Agent:
        """Transfer to Support if the caller has medical service questions."""
        await self.session.say(
            "Let me transfer you to Patient Support for that."
        )
        return await self._transfer_to_agent("support", context)

    @function_tool
    async def transfer_to_triage(self, context: RunContext_T) -> Agent:
        """Transfer back to Triage if the caller needs re-routing."""
        return await self._transfer_to_agent("triage", context)

Every specialist follows the same shape. Inherit BaseAgent, load the domain prompt, declare the transfer tools it needs. Adding a new specialist is now a ten minute job.

Matching exercise: Match each piece to its role in the entrypoint

Loading practice…

Callers rarely know which specialist they need. They have a problem and want help. The triage agent asks a couple of clarifying questions and routes correctly the first time. It also gives you one place to add policies like authentication, fraud checks, or priority routing without touching the specialists.