Chapter 625m

RunContext, state & the booking tool

RunContext, state & the booking tool

Your dental receptionist can check availability, but it cannot actually book anything. In this chapter, you will build the book_appointment tool, learn how RunContext gives your tools access to the live session, store conversation state with session.userdata, use ToolError for graceful failure handling, and control interruptions during critical confirmations. By the end, a caller can check availability and book an appointment in a single natural conversation.

RunContextsession.userdatasay()ToolErrorallow_interruptions

RunContext: the tool's window into the session

In the previous chapter, you saw that every tool function receives a RunContext as its first parameter. You did not use it — check_availability only needed the date argument. But RunContext is how your tools interact with the live conversation. It gives you access to:

  • context.session — the current AgentSession, which holds the room connection, the agent, and the conversation state
  • context.agent — the Agent instance that owns this tool call
  • context.tool_call — metadata about the current tool invocation (the call ID, the function name, the raw arguments)

The session is the most important. Through it, your tool can speak to the caller, read conversation history, and store data that persists across multiple tool calls.

What's happening

Think of RunContext as the tool's backstage pass. The LLM decides what tool to call and what arguments to send, but RunContext gives the tool access to the live show — the session, the room, the caller. Without it, a tool would be a pure function with no awareness of its surroundings.

Building the book_appointment tool

Here is the complete booking tool. Read through it first, then we will break down every decision.

agent.py
from livekit.agents import function_tool, RunContext, ToolError


@function_tool
async def book_appointment(
  context: RunContext,
  patient_name: str,
  date: str,
  time: str,
) -> str:
  """Book a dental appointment for a patient.

  Args:
      patient_name: The patient's full name
      date: The appointment date (e.g., "next Tuesday", "March 15")
      time: The appointment time slot (e.g., "9:00 AM", "2:00 PM")
  """
  # Validate the time slot against available slots
  valid_slots = ["9:00 AM", "11:30 AM", "2:00 PM", "4:30 PM"]
  if time not in valid_slots:
      raise ToolError(
          f"{time} is not an available slot. Available: {', '.join(valid_slots)}"
      )

  # Store the booking in session state
  booking = {
      "patient_name": patient_name,
      "date": date,
      "time": time,
      "status": "confirmed",
  }

  if not hasattr(context.session, "userdata"):
      context.session.userdata = {}
  context.session.userdata["last_booking"] = booking

  # Speak the confirmation directly — do not let the caller interrupt
  await context.session.say(
      f"I have booked an appointment for {patient_name} on {date} at {time}.",
      allow_interruptions=False,
  )

  return f"Appointment confirmed: {patient_name}, {date} at {time}. Ask if there is anything else you can help with."

There is a lot happening here. Let's walk through each piece.

1

Multiple typed parameters

The tool has three parameters: patient_name, date, and time. Each is a required str. The framework generates a JSON schema with three required string fields, and the LLM must provide all three when calling the tool. The docstring's Args: section describes each one — the LLM reads these descriptions to know what values to extract from the conversation.

2

ToolError for validation failures

If the caller requests a time slot that does not exist, you raise ToolError instead of returning a string. A ToolError is special: the framework sends the error message back to the LLM as a failed tool result. The LLM then apologizes to the caller and asks for a valid time. Regular Python exceptions would crash the tool call — ToolError handles failure gracefully within the conversation flow.

3

session.userdata for state storage

context.session.userdata is a dictionary that persists for the lifetime of the session. Any data you store here is available to all subsequent tool calls in the same conversation. Here we store the booking details so that later tools — cancellation, rescheduling, confirmation emails — can access them without re-collecting information from the caller.

4

say() with allow_interruptions=False

context.session.say() makes the agent speak a specific message immediately, outside of the normal LLM generation flow. The allow_interruptions=False flag is critical for confirmations: the caller hears the full booking confirmation without the agent stopping mid-sentence if there is background noise. For routine conversation, you want interruptions enabled. For "I have booked your appointment" — you do not.

5

The return value guides the LLM's next move

The return string is not spoken to the caller — it goes back to the LLM as context. Notice the instruction at the end: "Ask if there is anything else you can help with." This nudges the LLM to wrap up gracefully after confirming the booking. The caller already heard the confirmation via say(), so the LLM knows not to repeat it.

ToolError vs regular exceptions

Only raise ToolError for expected failures that the LLM should communicate to the caller — invalid input, slot already taken, system unavailable. Regular Python exceptions (like a database connection failure) should be caught and either retried or converted to a ToolError with a user-friendly message. An unhandled exception will surface as an internal error in the logs, and the LLM will receive a generic failure message.

The complete agent with both tools

Here is the full agent.py with both check_availability and book_appointment registered, and updated instructions that guide the agent through the booking flow:

agent.py
from livekit.agents import (
  AgentServer,
  Agent,
  AgentSession,
  function_tool,
  RunContext,
  ToolError,
)
from livekit.plugins import openai, deepgram, cartesia

server = AgentServer()


@function_tool
async def check_availability(context: RunContext, date: str) -> str:
  """Check available appointment slots for a given date.

  Args:
      date: The date to check availability for (e.g., "next Tuesday", "March 15")
  """
  available_slots = ["9:00 AM", "11:30 AM", "2:00 PM", "4:30 PM"]
  return f"Available slots for {date}: {', '.join(available_slots)}"


@function_tool
async def book_appointment(
  context: RunContext,
  patient_name: str,
  date: str,
  time: str,
) -> str:
  """Book a dental appointment for a patient.

  Args:
      patient_name: The patient's full name
      date: The appointment date (e.g., "next Tuesday", "March 15")
      time: The appointment time slot (e.g., "9:00 AM", "2:00 PM")
  """
  valid_slots = ["9:00 AM", "11:30 AM", "2:00 PM", "4:30 PM"]
  if time not in valid_slots:
      raise ToolError(
          f"{time} is not an available slot. Available: {', '.join(valid_slots)}"
      )

  booking = {
      "patient_name": patient_name,
      "date": date,
      "time": time,
      "status": "confirmed",
  }

  if not hasattr(context.session, "userdata"):
      context.session.userdata = {}
  context.session.userdata["last_booking"] = booking

  await context.session.say(
      f"I have booked an appointment for {patient_name} on {date} at {time}.",
      allow_interruptions=False,
  )

  return f"Appointment confirmed: {patient_name}, {date} at {time}. Ask if there is anything else you can help with."


@server.rtc_session
async def entrypoint(session: AgentSession):
  await session.start(
      agent=Agent(
          instructions="""You are a friendly receptionist at Bright Smile Dental clinic.
          Keep responses brief and conversational. Never use markdown or emojis.

          When a caller asks about availability, use check_availability to look up
          real slots. Never guess or make up times.

          When a caller wants to book, collect their full name, preferred date, and
          preferred time slot. Then use book_appointment to complete the booking.
          If the caller has not provided all three pieces of information, ask for
          the missing ones before calling the tool.

          If a requested time is not available, let the caller know and suggest
          the available alternatives.

          After booking, ask if there is anything else you can help with.""",
          tools=[check_availability, book_appointment],
      ),
      room=session.room,
      stt=deepgram.STT(model="nova-3"),
      llm=openai.LLM(model="gpt-4o-mini"),
      tts=cartesia.TTS(voice="<voice-id>"),
  )


if __name__ == "__main__":
  server.run()

Notice how the instructions now describe a complete workflow: check availability first, collect required information, book the appointment, then wrap up. The LLM follows this flow naturally because each step is explicit.

How state flows through the conversation

Here is the typical conversation flow with state management:

1

Caller asks about availability

"Do you have any openings next Tuesday?" The LLM calls check_availability(date="next Tuesday") and receives the list of slots. No state is stored yet — this is a read-only query.

2

Caller picks a time

"The 2 PM slot works. My name is Sarah Chen." The LLM now has all three pieces of information: name, date, and time. It calls book_appointment(patient_name="Sarah Chen", date="next Tuesday", time="2:00 PM").

3

Tool validates and stores

The tool checks that "2:00 PM" is a valid slot, creates a booking dictionary, stores it in session.userdata["last_booking"], and speaks the confirmation with interruptions disabled.

4

State persists for follow-up

If the caller later says "Actually, can I change that to 4:30?" a rescheduling tool could read session.userdata["last_booking"] to know what appointment to modify — without asking the caller to repeat their name and date.

What's happening

session.userdata is deliberately simple — a plain dictionary. It is not a database and it does not persist beyond the session. When the call ends, the data is gone. For production systems, you would write to your database inside the tool function and use session.userdata only for within-call state like "what was the last booking we discussed."

Handling the ToolError path

What happens when a caller requests an invalid time? Try it.

Try saying: "I'd like to book an appointment for next Tuesday at 10 AM. My name is Alex Rivera."

The LLM will call book_appointment with time="10:00 AM". The tool raises a ToolError because 10:00 AM is not in the valid slots list. The framework sends the error message back to the LLM, which should respond with something like: "I'm sorry, 10 AM is not available next Tuesday. I do have openings at 9 AM, 11:30 AM, 2 PM, and 4:30 in the afternoon. Would any of those work for you?"

The conversation recovers gracefully. The caller never sees a stack trace or an error code — they hear a helpful suggestion powered by the error message you provided in the ToolError.

Write ToolError messages for the LLM, not the user

The caller never hears your ToolError message directly. The LLM reads it and crafts a spoken response. So write ToolError messages that help the LLM help the caller: include what went wrong and what the valid alternatives are.

Test the full booking flow

Run your agent and walk through a complete booking conversation:

terminalbash
lk agent dev

Try saying: "Hi, I need to schedule an appointment."

The agent should ask what date works for you. It should not call any tool yet — it does not have enough information.

Try saying: "Do you have anything next Tuesday?"

Watch the logs: check_availability fires with date="next Tuesday". The agent reads back the available slots.

Try saying: "The 2 PM slot, please. My name is Jordan Park."

Watch the logs: book_appointment fires with all three arguments. The agent speaks the confirmation without allowing interruptions. Then it asks if there is anything else.

Try saying: "Actually, do you have anything on Wednesday too?"

The agent should call check_availability again. This tests that the conversation continues naturally after a booking — the agent does not end the call or get confused.

Try saying: "No, that's everything. Thanks."

The agent should wrap up warmly. No tools called — just a goodbye.

Test your knowledge

Question 1 of 3

Why does the book_appointment tool use ToolError instead of a regular Python exception when the requested time slot is invalid?

Looking ahead

Your dental receptionist now has real capabilities: checking availability and booking appointments. In the next chapter, you will add noise cancellation so the agent performs well even when callers are in noisy environments, and you will configure room options for production-quality audio.

Concepts covered
RunContextsession.userdatawait_for_playout()ToolErrordisallow_interruptions()