Overview
The supervisor pattern keeps a single agent in long-lived control of a session and routes discrete work to specialist tasks. The supervisor decides when each task runs, integrates the result, and continues the conversation. Each task is independent, with its own instructions, tools, and LLM loop. This allows the supervisor to coordinate a set of focused sub-agents rather than handling everything in one prompt.
Use this pattern when one agent should remain aware of the full conversation while delegating focused operations such as collecting structured information, running verification steps, or retrieving external data. The supervisor remains the conversational entry point, while specialists handle narrow work and return results.
When to use the supervisor pattern
The supervisor pattern is one option among several for structuring a voice AI application. Choose the simplest construct that fits your workflow:
- A single agent with tools can handle the conversation if one set of instructions and tools is sufficient.
- The supervisor pattern is appropriate when one agent should remain in control while delegating discrete operations to focused, reusable tasks.
- Agent handoffs are appropriate when one agent's role is complete and another should take over with different instructions or tools. The original agent doesn't participate in subsequent steps.
- Task groups are appropriate for ordered, multi-step delegation where users might need to revisit earlier steps. A task group structures supervisor-driven delegation with built-in sequencing and support for revisiting prior steps.
These patterns aren't mutually exclusive and are often combined in more complex voice AI applications. Different phases of a conversation might use different patterns. For example, an intake agent might use the supervisor pattern to collect structured information, then hand off to a billing agent that uses its own supervisor pattern for payment collection.
For broader context on agent patterns, see the LiveKit blog posts on the supervisor pattern, the ReAct pattern, and the human-in-the-loop pattern.
Pattern anatomy
The pattern has three parts:
- The supervisor. A long-lived
Agentwhose instructions define available specialists, when to invoke each one, and how to interpret their results. - The specialists. One or more
AgentTaskinstances, each with focused instructions, its own tools, and a typed result. See Tasks and task groups for the full task API. - The delegation surface. The mechanism used to start a specialist. The most common approach is a function tool on the supervisor: the tool body instantiates and awaits a task, then returns its result to the LLM. Alternatively, lifecycle hooks (
on_enteroron_exit) can trigger tasks at deterministic points.
Tools and tasks are distinct constructs. A tool is regular code that can perform any operation: call an API, write to a database, or start a task. A task is a sub-conversation with its own LLM loop, which can in turn call tools. Starting a task from a tool is one of several entry points; see Running a task for the others and runtime constraints.
Designing a supervisor
The pattern's effectiveness depends on a few design choices.
Sizing tasks
Decide what belongs in a task versus a tool versus the supervisor itself:
- Tool: single deterministic operations that don't require LLM reasoning, such as fetching a record by ID, sending an email, or performing a computation.
- Task: focused sub-conversations that require reasoning and might span multiple turns, such as collecting a structured address, handling consent flows, or verifying identity.
- Supervisor: the conversational frame and routing logic. Domain-specific reasoning shouldn't live here.
A useful guideline is that if the model needs to ask clarifying questions, the work belongs in a task. If it's a single function call with arguments, it belongs in a tool.
Writing supervisor instructions
The supervisor's instructions should explicitly name each specialist tool and describe when to use it. Be specific because routing behavior depends heavily on these descriptions. Instructions should also define how to interpret each result. For example: “After lookup_order returns, summarize the order status and ask whether the user would like to make changes.”
The supervisor sets the conversational tone. Specialist tasks define their own behavior, while the supervisor frames the overall interaction.
Validating results
Treat task results as untrusted input until validated by the supervisor. Check results before continuing the conversation and define a recovery path for errors or unexpected outputs. Although task results are typed, validation is still required for the underlying values.
Example: routing between specialist tasks
The following supervisor handles two kinds of customer requests by routing to different specialist tasks. LookupOrderTask collects an order number and returns the order status. UpdateAddressTask collects a new shipping address and returns confirmation. The supervisor exposes one function tool per task; the LLM picks the right tool based on what the user says.
from dataclasses import dataclassfrom livekit.agents import Agent, AgentTask, function_tool# Typed result returned when LookupOrderTask completes.@dataclassclass OrderLookupResult:order_id: strstatus: str# Typed result returned when UpdateAddressTask completes.@dataclassclass AddressUpdateResult:address: str# The generic parameter ties this task to the type it returns.class LookupOrderTask(AgentTask[OrderLookupResult]):def __init__(self, chat_ctx=None) -> None:super().__init__(instructions=("Ask the customer for their order number. ""If they don't have one, ask them to check their email."),chat_ctx=chat_ctx,)async def on_enter(self) -> None:await self.session.generate_reply(instructions="Ask for the order number.")# Task-internal tool. The task's LLM calls this when the user# provides an order number. Calling self.complete(...) ends the# task and returns the typed result to the supervisor.@function_tool()async def order_number_collected(self, order_id: str) -> None:"""Call when the customer has provided their order number."""# In a real system, look up the order in your database here.self.complete(OrderLookupResult(order_id=order_id, status="shipped"))class UpdateAddressTask(AgentTask[AddressUpdateResult]):def __init__(self, chat_ctx=None) -> None:super().__init__(instructions=("Collect the customer's new shipping address: street, city, ""state, and zip code. Read it back to confirm before completing."),chat_ctx=chat_ctx,)async def on_enter(self) -> None:await self.session.generate_reply(instructions="Ask for the new shipping address.")@function_tool()async def address_confirmed(self, address: str) -> None:"""Call once the customer has confirmed their new address."""self.complete(AddressUpdateResult(address=address))class CustomerServiceAgent(Agent):def __init__(self) -> None:super().__init__(instructions=("You are a customer service representative. Greet the caller ""and ask how you can help. Route their request:\n""- For questions about order status, call lookup_order.\n""- For shipping address changes, call update_address.\n""After a tool returns, summarize the outcome and ask whether ""the caller needs anything else."),)# Routing tool called by the supervisor's LLM to delegate to# the specialist task. The tool body instantiates and awaits the task,# then returns its result string back to the LLM, which uses it to# continue the conversation.@function_tool()async def lookup_order(self) -> str:"""Use when the customer wants to check the status of an order."""result = await LookupOrderTask(chat_ctx=self.chat_ctx.copy(exclude_instructions=True),)return f"Order {result.order_id} is {result.status}."@function_tool()async def update_address(self) -> str:"""Use when the customer wants to change their shipping address."""result = await UpdateAddressTask(chat_ctx=self.chat_ctx.copy(exclude_instructions=True),)return f"Updated shipping address to: {result.address}."
import { llm, voice } from '@livekit/agents';import { z } from 'zod';// Typed result returned when LookupOrderTask completes.interface OrderLookupResult {orderId: string;status: string;}// Typed result returned when UpdateAddressTask completes.interface AddressUpdateResult {address: string;}// The generic parameter ties this task to the type it returns.class LookupOrderTask extends voice.AgentTask<OrderLookupResult> {constructor(chatCtx?: llm.ChatContext) {super({instructions:"Ask the customer for their order number. If they don't have one, " +'ask them to check their email.',chatCtx,tools: {// Task-internal tool. The task's LLM calls this when the user// provides an order number. Calling this.complete(...) ends the// task and returns the typed result to the supervisor.orderNumberCollected: llm.tool({description: 'Call when the customer has provided their order number.',parameters: z.object({orderId: z.string().describe('The order number'),}),execute: async ({ orderId }) => {this.complete({ orderId, status: 'shipped' });},}),},});}async onEnter(): Promise<void> {this.session.generateReply({instructions: 'Ask for the order number.',});}}class UpdateAddressTask extends voice.AgentTask<AddressUpdateResult> {constructor(chatCtx?: llm.ChatContext) {super({instructions:"Collect the customer's new shipping address: street, city, state, " +'and zip code. Read it back to confirm before completing.',chatCtx,tools: {addressConfirmed: llm.tool({description: 'Call once the customer has confirmed their new address.',parameters: z.object({address: z.string().describe('The full shipping address'),}),execute: async ({ address }) => {this.complete({ address });},}),},});}async onEnter(): Promise<void> {this.session.generateReply({instructions: 'Ask for the new shipping address.',});}}class CustomerServiceAgent extends voice.Agent {constructor() {super({instructions:'You are a customer service representative. Greet the caller and ' +'ask how you can help. Route their request:\n' +'- For questions about order status, call lookupOrder.\n' +'- For shipping address changes, call updateAddress.\n' +'After a tool returns, summarize the outcome and ask whether the ' +'caller needs anything else.',tools: {// Routing tool called by the supervisor's LLM to delegate to// the specialist task. The tool body instantiates and awaits the task,// then returns its result string back to the LLM, which uses it to// continue the conversation.lookupOrder: llm.tool({description: 'Use when the customer wants to check the status of an order.',execute: async (_, { ctx }) => {const result = await new LookupOrderTask(ctx.session.chatCtx.copy({ excludeInstructions: true }),).run();return `Order ${result.orderId} is ${result.status}.`;},}),updateAddress: llm.tool({description: 'Use when the customer wants to change their shipping address.',execute: async (_, { ctx }) => {const result = await new UpdateAddressTask(ctx.session.chatCtx.copy({ excludeInstructions: true }),).run();return `Updated shipping address to: ${result.address}.`;},}),},});}}
The supervisor's instructions name each specialist and describe when to invoke it; the LLM uses those descriptions to route incoming requests. Each task starts when its tool is called, takes over the session until it calls complete(...), and returns its typed result to the supervisor for the rest of the conversation.
To pass the supervisor's chat context into a task so the specialist sees what came before, see Passing conversation history to a task.
Best practices
- Keep specialist tasks focused. One objective per task with a clear typed result. Split tasks that grow in scope.
- Describe routing precisely in supervisor instructions. The model relies on tool descriptions and instructions to route correctly. Ambiguity leads to misrouting.
- Validate task results before continuing. Typed results still require value-level validation (for example, empty or malformed fields).
- Test the supervisor and each task independently. Each task is a self-contained unit with a typed contract. See Testing & evaluation.
- Pass conversation context only when needed. If a task doesn't require history, omitting it improves performance and reduces noise. See Passing conversation history to a task.
Additional resources
The following resources provide more information on the topics discussed in this guide.
Workflows
Model multi-step voice AI apps with agents, handoffs, and tasks.
Tasks & task groups
Define short-lived units of work that return typed results.
Agents and handoffs
Transfer long-lived control between agents with different instructions or tools.
Tool definition and use
Define model-callable functions for external actions or to trigger delegation.
Testing & evaluation
Test the supervisor and each task independently.