ShipVoice
Primer / Part II · Building Agents

Chapter 5: Function Tools

In the previous chapter you designed an agent with clear instructions and a well-defined persona. But instructions alone only let the agent talk. To make it useful (booking appointments, looking up records, sending confirmations), the agent needs to take action. That is what function tools are for.

Function tools are Python functions that the LLM can choose to call during a conversation. When the caller says “book me in for Thursday at 2pm,” the LLM does not just generate a response saying it has been booked. It calls a book_appointment function with the appropriate arguments, waits for the result, and then responds based on what actually happened. Tools turn a conversational agent into an operational one.

The rest of this chapter walks through defining tools, controlling when they fire, handling results and errors, and dealing with long-running operations.

Defining Tools with @function_tool

The @function_tool decorator is the primary way to give your agent capabilities. Place it on a method inside your Agent class and the framework automatically registers the function as a tool the LLM can invoke.

from livekit.agents import Agent, RunContext, function_tool

class ReceptionistAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="You are a receptionist for a plumbing company.",
        )

    @function_tool()
    async def check_availability(
        self,
        context: RunContext,
        date: str,
        service_type: str,
    ) -> str:
        """Check appointment availability for a given date and service type.

        Args:
            date: The requested date in YYYY-MM-DD format.
            service_type: The type of service, e.g. 'drain cleaning' or 'pipe repair'.
        """
        slots = await self._fetch_slots(date, service_type)
        if slots:
            return f"Available slots on {date}: {', '.join(slots)}"
        return f"No availability on {date}."

Several things happen automatically here. The function name check_availability becomes the tool name the LLM sees. The docstring becomes the tool description. The type-hinted parameters date and service_type become the argument schema. The LLM receives all of this as structured metadata and uses it to decide when and how to call the tool.

You can also define tools as standalone functions outside a class. This is useful when multiple agents share the same tool:

@function_tool()
async def lookup_customer(
    context: RunContext,
    phone_number: str,
) -> dict:
    """Look up a customer record by phone number.

    Args:
        phone_number: The customer's phone number in E.164 format.
    """
    return await db.find_customer(phone_number)


class SalesAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="You are a sales agent.",
            tools=[lookup_customer],
        )


class SupportAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="You are a support agent.",
            tools=[lookup_customer],
        )

Standalone tools are passed to the tools parameter in the agent constructor. They work alongside any @function_tool methods defined on the class itself.

Tool Descriptions That Guide the LLM

The docstring on your tool function is not documentation for human developers. It is a prompt for the LLM. The model reads it to decide whether to call the tool, what arguments to pass, and how to interpret the result. Write it accordingly.

A vague description leads to unreliable tool use:

@function_tool()
async def update_record(self, context: RunContext, data: str) -> str:
    """Update a record."""  # Too vague. What record? When should this be called?
    ...

A specific description leads to precise behavior:

@function_tool()
async def update_service_address(
    self,
    context: RunContext,
    street: str,
    city: str,
    postal_code: str,
) -> str:
    """Update the service address for the current customer's appointment.

    Call this ONLY after the customer has confirmed their address.
    Do NOT call this if the customer is still deciding or correcting details.

    Args:
        street: Street address including unit number if applicable.
        city: City name.
        postal_code: Canadian postal code in A1A 1A1 format.

    Returns a confirmation message with the updated address, or an error
    if the postal code is outside the service area.
    """
    ...

Three principles make tool descriptions effective. First, describe when to call the tool and when not to call it. The LLM needs boundaries, not just capabilities. Second, describe the expected format and meaning of each argument. “Canadian postal code in A1A 1A1 format” is far more useful than just “postal code.” Third, describe what the return value means. If the tool can return different kinds of results, say so.

You can also override the description entirely using the decorator parameter:

@function_tool(name="book_slot", description="Book an appointment slot. Only call after confirming the date and time with the caller.")
async def _internal_booking_method(self, context: RunContext, slot_id: str) -> str:
    ...

This is useful when the function name is an internal implementation detail that would confuse the LLM.

RunContext: the Execution Context

Every tool function receives a RunContext object (conventionally named context or ctx). This is the bridge between your tool and the broader agent session.

@function_tool()
async def save_business_info(self, context: RunContext, name: str) -> str:
    """Save the business name."""

    # Access the agent session
    session = context.session

    # Access shared state across tools
    context.userdata["business_name"] = name

    # Access the raw function call metadata
    call_info = context.function_call
    print(f"Tool called: {call_info.name}, args: {call_info.arguments}")

    # Wait for the agent to finish speaking before proceeding
    await context.wait_for_playout()

    return f"Saved business name: {name}"

The key attributes are:

context.session gives you the AgentSession. From here you can call session.say() to make the agent speak, session.generate_reply() to trigger a full LLM response, or access any other session-level functionality.

context.userdata is a shared object that persists across the entire session. Set it when creating the AgentSession and every tool in every agent within that session can read and write to it. This is the primary mechanism for passing state between tools.

context.speech_handle provides access to the current speech being generated. You can check context.speech_handle.interrupted to know if the caller interrupted the agent while the tool was running.

context.function_call contains the raw function call information from the LLM, including the tool name and raw arguments.

context.wait_for_playout() waits for the agent to finish speaking. This is critical inside tools. You cannot directly await a SpeechHandle inside a tool call. Use this method instead.

Return Values

What your tool returns determines what happens next. There are four distinct behaviors:

String or dict: The return value is serialized and sent back to the LLM as the tool result. The LLM then generates a response informed by that result.

@function_tool()
async def get_hours(self, context: RunContext) -> str:
    """Get business hours."""
    return "Monday-Friday 8am-5pm, Saturday 9am-1pm, closed Sunday."

None: The tool completes silently. No result is sent to the LLM, and no new reply is generated. Use this for side-effect-only operations where the LLM does not need feedback.

@function_tool()
async def log_interaction(self, context: RunContext, note: str) -> None:
    """Log a note about this interaction. The caller does not need to know."""
    await analytics.log(session_id=context.session.id, note=note)

Agent instance: Returning an Agent triggers a handoff. The current agent is replaced by the returned agent, and the conversation continues with the new agent.

@function_tool()
async def transfer_to_billing(self, context: RunContext) -> Agent:
    """Transfer the caller to the billing department."""
    return BillingAgent()

Tuple of (Agent, string): A handoff with a result message. The string is sent to the LLM as the tool result before the handoff occurs, giving the current agent a chance to say something like “Let me transfer you now.”

@function_tool()
async def escalate_to_manager(self, context: RunContext, reason: str) -> tuple:
    """Escalate this call to a manager."""
    return ManagerAgent(), f"Escalating to manager. Reason: {reason}"

Error Handling with ToolError

When a tool encounters an expected error (invalid input, a missing resource, a temporarily unavailable service), raise ToolError. This sends a descriptive error message back to the LLM, which can then recover gracefully by informing the caller or trying a different approach.

from livekit.agents.llm import ToolError

@function_tool()
async def book_appointment(
    self,
    context: RunContext,
    date: str,
    time: str,
) -> str:
    """Book an appointment for the given date and time."""
    if not self._is_valid_date(date):
        raise ToolError(
            f"'{date}' is not a valid date. Ask the caller for a date "
            f"in YYYY-MM-DD format."
        )

    slot = await self._find_slot(date, time)
    if slot is None:
        raise ToolError(
            f"No availability at {time} on {date}. Suggest the caller "
            f"try a different time or date."
        )

    await self._confirm_slot(slot)
    return f"Appointment booked for {date} at {time}. Confirmation #: {slot.id}"

The LLM receives the ToolError message as an error response and uses it to craft an appropriate reply. In the example above, if the date is invalid, the LLM will ask the caller to repeat their date. If there is no availability, it will suggest alternatives. The LLM adapts its behavior based on the error message you provide.

This is fundamentally different from raising a regular Python exception. An unhandled exception crashes the tool execution and produces a generic error. A ToolError is a structured communication channel between your tool and the LLM. Write the message as if you are talking to the LLM, because you are.

Long-Running Tools

Some tools take time. Looking up a record in an external API, processing a payment, or generating a document can each take seconds. Seconds of silence on a phone call feel like an eternity.

The standard pattern is to start speaking before the tool finishes its work:

@function_tool()
async def process_payment(self, context: RunContext, amount: float) -> str:
    """Process a payment for the given amount."""

    # Start speaking immediately -- do not await
    self.session.generate_reply(
        instructions="Tell the caller you're processing their payment and it will take a moment."
    )

    # Run the actual API call
    result = await payment_api.charge(amount)

    # Wait for the hold message to finish playing
    await context.wait_for_playout()

    return f"Payment of ${amount:.2f} processed. Transaction ID: {result.id}"

The key detail: session.generate_reply() is called without await. This means the agent starts speaking the hold message while the payment API call runs concurrently. When the API returns, context.wait_for_playout() ensures the hold message finishes before the LLM generates its next response with the payment result.

Non-Reversible Operations

Some operations cannot be undone. Once you have charged a credit card or submitted a form, the caller interrupting the agent should not cancel the operation. Use context.disallow_interruptions() to prevent this:

@function_tool()
async def submit_application(self, context: RunContext) -> str:
    """Submit the completed application. This cannot be undone."""

    # Prevent interruptions -- this operation cannot be rolled back
    context.disallow_interruptions()

    self.session.generate_reply(
        instructions="Tell the caller you're submitting their application now."
    )

    result = await application_api.submit(context.userdata["application"])
    await context.wait_for_playout()

    return f"Application submitted. Reference number: {result.ref}"

Handling Interruptions in Long-Running Tools

For reversible operations, you may want to detect if the caller interrupted and clean up accordingly:

@function_tool()
async def search_inventory(self, context: RunContext, query: str) -> str | None:
    """Search product inventory."""
    import asyncio

    search_task = asyncio.ensure_future(inventory_api.search(query))
    await context.speech_handle.wait_if_not_interrupted([search_task])

    if context.speech_handle.interrupted:
        search_task.cancel()
        return None  # Tool is removed from history; return value is ignored

    results = search_task.result()
    return f"Found {len(results)} items matching '{query}'."

When a tool is interrupted, it is removed from the conversation history entirely. The LLM never sees the result, and from its perspective the tool call never happened. This means the return value does not matter after an interruption, but you should still cancel any in-flight work.

Dynamic Tools

Not every tool should be available at every point in the conversation. A “confirm booking” tool is nonsensical before the caller has provided any booking details. A “transfer to billing” tool is only relevant once a billing question has been identified.

Adding and Removing Tools at Runtime

Use agent.update_tools() to change the available tools mid-conversation:

class OnboardingAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="Collect business information from the caller.",
        )

    @function_tool()
    async def save_business_name(self, context: RunContext, name: str) -> str:
        """Save the business name provided by the caller."""
        context.userdata["business_name"] = name

        # Now that we have the name, add the submission tool
        await self.update_tools(self.tools + [self._submit_tool()])

        return f"Business name saved: {name}"

    def _submit_tool(self):
        @function_tool(name="submit_business", description="Submit the business for registration. Only call after all required fields are collected.")
        async def submit(context: RunContext) -> str:
            return await register_business(context.userdata)
        return submit

The update_tools() method replaces all tools on the agent, so include self.tools when you want to add to the existing set rather than replace it. You can also remove tools:

# Remove a specific tool
await agent.update_tools([t for t in agent.tools if t is not some_tool])

Toolsets

When you have a group of related tools that should be added or removed together, use a Toolset:

from livekit.agents.llm import Toolset, Tool
from livekit.agents import function_tool, RunContext

class CalendarToolset(Toolset):
    def __init__(self):
        super().__init__(id="calendar_tools")
        self._check = function_tool(
            self._check_availability,
            name="check_availability",
            description="Check available appointment slots for a date.",
        )
        self._book = function_tool(
            self._book_slot,
            name="book_slot",
            description="Book a specific appointment slot.",
        )

    async def _check_availability(self, context: RunContext, date: str) -> str:
        slots = await calendar_api.get_slots(date)
        return f"Available: {', '.join(slots)}" if slots else "No slots available."

    async def _book_slot(self, context: RunContext, slot_id: str) -> str:
        booking = await calendar_api.book(slot_id)
        return f"Booked. Confirmation: {booking.id}"

    @property
    def tools(self) -> list[Tool]:
        return [self._check, self._book]

Pass the toolset to your agent like any other tool. The framework flattens the toolset automatically, so the LLM sees individual tools, not the grouping:

class SchedulingAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="Help callers schedule appointments.",
            tools=[CalendarToolset()],
        )

Excluding Tools from the Greeting

When your agent generates a greeting in its on_enter method, certain tools should not be available yet. The ToolFlag.IGNORE_ON_ENTER flag excludes a tool from any generate_reply calls made during on_enter:

from livekit.agents.llm.tool_context import ToolFlag

class IntakeAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="Collect the caller's address.",
        )

    async def on_enter(self) -> None:
        self.session.generate_reply(
            instructions="Greet the caller and ask for their address."
        )

    @function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
    async def confirm_address(self, context: RunContext, address: str) -> str:
        """Confirm the address the caller provided."""
        # This tool is NOT available during the greeting.
        # The LLM cannot confirm an address before one is given.
        return f"Address confirmed: {address}"

Without this flag, an overeager LLM might try to call confirm_address during the greeting with hallucinated arguments. The flag prevents this by temporarily hiding the tool.

Summary

Function tools are what make a voice agent more than a conversational interface. They connect the LLM to your business logic, databases, and external services.

The key patterns to remember:

  1. Docstrings are prompts. Write tool descriptions for the LLM, not for developers. Be specific about when to call, when not to call, and what to expect.
  2. RunContext is the bridge. Use it to access session state, shared userdata, and speech coordination.
  3. Return values control flow. Strings feed back to the LLM. None is silent. An Agent triggers a handoff.
  4. ToolError is structured communication. Raise it to guide the LLM toward recovery, not to crash the tool.
  5. Long-running tools need hold patterns. Speak first, process concurrently, wait for playout.
  6. Dynamic tools match conversation state. Add tools as they become relevant. Remove them when they are not.

Chapter 6 introduces Tasks and Task Groups, a higher-level abstraction for organizing multi-step workflows where each step has its own tools, instructions, and completion criteria.

This primer is the theory. ShipVoice is the code: a LiveKit boilerplate with per-minute billing, auth, telephony, and deploy already wired in.

Get founding access