Chapter 725m

RPC & data channels: a visual booking flow

RPC & data channels: a visual booking flow

Your dental receptionist frontend already has an open WebRTC connection to the agent. RPC (Remote Procedure Call) lets you send structured requests from the browser directly to Maya — and get structured responses back — without building a separate REST API. In this chapter, you will build a date picker that calls checkAvailability via RPC, display available slots as clickable buttons, and use data channels to receive real-time booking updates. This is where the dental frontend goes from "voice chat with a transcript" to a fully interactive booking experience.

performRpcMethod registrationBidirectional RPCData channelsStructured messaging

What you'll learn

  • How to register RPC methods on the dental receptionist agent
  • How to call checkAvailability from a frontend date picker
  • How to display available time slots and let the patient click to select
  • How to build bidirectional RPC where Maya triggers UI changes on the frontend
  • How to use data channels for real-time appointment status updates

Registering RPC methods on the dental agent

Every RPC interaction starts with the agent declaring which methods it supports. In Course 1.1, Maya already has check_availability and book_appointment as LLM tools. Now you will expose similar functionality as RPC methods that the frontend can call directly — bypassing the conversational flow when the patient prefers to interact with the UI.

agent.py (add to your Course 1.1 agent)python
import json
from livekit.agents import AgentSession

async def setup_dental_rpc(session: AgentSession):
  """Register RPC methods that the frontend can call directly."""

  @session.on("rpc_request")
  async def handle_rpc(request):
      if request.method == "checkAvailability":
          data = json.loads(request.payload)
          date = data["date"]
          # Same logic as your check_availability tool
          slots = await get_available_slots(date)
          return json.dumps({
              "date": date,
              "slots": slots,  # e.g. ["9:00 AM", "11:30 AM", "2:00 PM", "4:30 PM"]
          })

      if request.method == "bookSlot":
          data = json.loads(request.payload)
          patient_name = data["patient_name"]
          date = data["date"]
          time = data["time"]
          # Same logic as your book_appointment tool
          result = await book_appointment_internal(patient_name, date, time)
          return json.dumps({
              "success": result["success"],
              "patient_name": patient_name,
              "date": date,
              "time": time,
          })

      raise ValueError(f"Unknown method: {request.method}")
What's happening

RPC methods and LLM tools serve different interaction modes. When the patient speaks ("What's available next Tuesday?"), Maya uses her check_availability tool through the conversational flow. When the patient clicks a date picker in the UI, the frontend calls the checkAvailability RPC method directly. Both call the same underlying function — the difference is how the request originates.

RPC reuses the same WebRTC connection

RPC calls travel over the existing LiveKit WebRTC connection. There is no additional HTTP request, no separate WebSocket, no CORS to configure. The frontend calls performRpc, the payload travels through the SFU, and the response comes back the same way. Latency is typically under 100ms.

Building the availability checker

Create a frontend component with a date picker that calls checkAvailability and displays the results as clickable time slot buttons.

src/hooks/useDentalRpc.tstsx
"use client";

import { useSession } from "@livekit/agents-react";
import { useCallback } from "react";

interface AvailabilityResponse {
date: string;
slots: string[];
}

interface BookingResponse {
success: boolean;
patient_name: string;
date: string;
time: string;
}

export function useDentalRpc() {
const session = useSession();

const checkAvailability = useCallback(
  async (date: string): Promise<AvailabilityResponse> => {
    const response = await session.room.localParticipant.performRpc({
      destinationIdentity: session.agent.identity,
      method: "checkAvailability",
      payload: JSON.stringify({ date }),
      responseTimeout: 5000,
    });
    return JSON.parse(response);
  },
  [session]
);

const bookSlot = useCallback(
  async (
    patientName: string,
    date: string,
    time: string
  ): Promise<BookingResponse> => {
    const response = await session.room.localParticipant.performRpc({
      destinationIdentity: session.agent.identity,
      method: "bookSlot",
      payload: JSON.stringify({
        patient_name: patientName,
        date,
        time,
      }),
      responseTimeout: 5000,
    });
    return JSON.parse(response);
  },
  [session]
);

return { checkAvailability, bookSlot };
}

Now build the availability checker component:

src/components/AvailabilityChecker.tsxtsx
"use client";

import { useState } from "react";
import { useDentalRpc } from "@/hooks/useDentalRpc";

interface Slot {
date: string;
time: string;
}

export function AvailabilityChecker() {
const { checkAvailability, bookSlot } = useDentalRpc();
const [date, setDate] = useState("");
const [slots, setSlots] = useState<string[]>([]);
const [selectedDate, setSelectedDate] = useState("");
const [loading, setLoading] = useState(false);
const [booking, setBooking] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleCheckAvailability = async () => {
  if (!date) return;
  setLoading(true);
  setError(null);
  try {
    const result = await checkAvailability(date);
    setSlots(result.slots);
    setSelectedDate(result.date);
  } catch (err) {
    setError("Could not check availability. Try asking Maya instead.");
  } finally {
    setLoading(false);
  }
};

const handleBookSlot = async (time: string) => {
  setBooking(true);
  setError(null);
  try {
    const result = await bookSlot("", selectedDate, time);
    if (result.success) {
      setSlots([]);
      // The BookingConfirmation component will pick up the text stream
    }
  } catch (err) {
    setError("Booking failed. Maya can help you complete it by voice.");
  } finally {
    setBooking(false);
  }
};

return (
  <div className="rounded-lg border border-gray-200 bg-white p-4">
    <h3 className="text-sm font-semibold text-gray-900">
      Check appointment availability
    </h3>

    <div className="mt-3 flex gap-2">
      <input
        type="date"
        value={date}
        onChange={(e) => setDate(e.target.value)}
        className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm
          focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
      />
      <button
        onClick={handleCheckAvailability}
        disabled={!date || loading}
        className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white
          hover:bg-blue-700
          disabled:bg-gray-300 disabled:cursor-not-allowed"
      >
        {loading ? "Checking..." : "Check"}
      </button>
    </div>

    {error && (
      <p className="mt-2 text-sm text-red-600">{error}</p>
    )}

    {slots.length > 0 && (
      <div className="mt-3">
        <p className="text-sm text-gray-600">
          Available on {selectedDate}:
        </p>
        <div className="mt-2 flex flex-wrap gap-2">
          {slots.map((time) => (
            <button
              key={time}
              onClick={() => handleBookSlot(time)}
              disabled={booking}
              className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5
                text-sm font-medium text-blue-700
                hover:bg-blue-100 hover:border-blue-300
                disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {time}
            </button>
          ))}
        </div>
      </div>
    )}
  </div>
);
}

The flow for a visual booking:

1

Patient picks a date

The patient selects "March 15" from the date picker and clicks "Check."

2

Frontend calls RPC

performRpc sends {"date": "March 15"} to the agent's checkAvailability method via the WebRTC data channel.

3

Agent returns slots

The agent responds with {"date": "March 15", "slots": ["9:00 AM", "11:30 AM", "2:00 PM", "4:30 PM"]}.

4

Slots appear as buttons

The frontend renders four clickable time slot buttons. The patient clicks "9:00 AM."

5

Booking via RPC

The frontend calls bookSlot with the selected time. The agent books the appointment and publishes a booking-confirmation text stream.

6

Confirmation card appears

The BookingConfirmation component from Chapter 4 picks up the text stream and renders the green confirmation card.

Bidirectional RPC: agent triggers frontend UI

RPC is not one-directional. Maya can also call methods on the frontend. This is powerful for dental workflows — the agent can push the patient to a specific UI state based on the conversation.

Register a frontend method that the agent can call to display a form:

src/hooks/useAgentCommands.tstsx
"use client";

import { useSession } from "@livekit/agents-react";
import { useEffect, useState } from "react";

interface PatientForm {
visible: boolean;
fields: string[];
prefilled: Record<string, string>;
}

export function useAgentCommands() {
const session = useSession();
const [patientForm, setPatientForm] = useState<PatientForm>({
  visible: false,
  fields: [],
  prefilled: {},
});

useEffect(() => {
  // Register a method the agent can call
  session.room.localParticipant.registerRpcMethod(
    "showPatientForm",
    async (request) => {
      const data = JSON.parse(request.payload);
      setPatientForm({
        visible: true,
        fields: data.fields,     // e.g. ["name", "phone", "insurance_id"]
        prefilled: data.prefilled || {},
      });
      return JSON.stringify({ displayed: true });
    }
  );

  return () => {
    session.room.localParticipant.unregisterRpcMethod("showPatientForm");
  };
}, [session]);

const dismissForm = () => {
  setPatientForm((prev) => ({ ...prev, visible: false }));
};

return { patientForm, dismissForm };
}

On the agent side, Maya can trigger this form mid-conversation:

agent.py (agent calls frontend)python
# Inside the dental agent, when Maya needs patient details
await session.room.local_participant.perform_rpc(
  destination_identity=patient_identity,
  method="showPatientForm",
  payload=json.dumps({
      "fields": ["name", "phone", "insurance_id"],
      "prefilled": {"name": patient_name_from_conversation},
  }),
)
What's happening

Bidirectional RPC turns the dental frontend from a passive voice player into an active participant in the workflow. Maya can say "I'll pull up a form for you to fill in your insurance details" and simultaneously trigger the form to appear in the browser. The patient fills it in, the data goes back to the agent, and Maya confirms the booking. This is the bridge between conversational AI and traditional web forms.

Data channels for appointment status

Beyond RPC (request/response), data channels support publish/subscribe messaging on named topics. Use this for real-time status updates that do not need a response.

src/hooks/useAppointmentStatus.tstsx
"use client";

import { useTextStream } from "@livekit/components-react";
import { useState } from "react";

interface AppointmentStatus {
phase: "checking" | "available" | "booking" | "confirmed" | "failed";
details?: Record<string, string>;
}

export function useAppointmentStatus() {
const [status, setStatus] = useState<AppointmentStatus>({
  phase: "checking",
});

useTextStream({
  topic: "appointment-status",
  onMessage: (message: string) => {
    try {
      const update: AppointmentStatus = JSON.parse(message);
      setStatus(update);
    } catch {
      // Ignore malformed messages
    }
  },
});

return status;
}

The agent publishes status updates as the booking progresses:

agent.py (status updates)python
# Publish appointment status as the workflow progresses
async def publish_status(session, phase, details=None):
  stream = await session.room.local_participant.stream_text(
      topic="appointment-status",
  )
  await stream.write(json.dumps({
      "phase": phase,
      "details": details or {},
  }))
  await stream.close()

# During the booking flow:
await publish_status(session, "checking", {"date": "March 15"})
# ... check availability ...
await publish_status(session, "available", {"slots": "9 AM, 11:30 AM, 2 PM"})
# ... patient picks a slot ...
await publish_status(session, "booking", {"time": "9:00 AM", "patient": "Jane Doe"})
# ... book the appointment ...
await publish_status(session, "confirmed", {"time": "9:00 AM", "patient": "Jane Doe"})

Render a progress indicator on the frontend:

src/components/AppointmentProgress.tsxtsx
"use client";

import { useAppointmentStatus } from "@/hooks/useAppointmentStatus";

const phaseLabels: Record<string, { label: string; color: string }> = {
checking:  { label: "Checking availability...",    color: "text-blue-600" },
available: { label: "Slots available",             color: "text-green-600" },
booking:   { label: "Booking your appointment...", color: "text-yellow-600" },
confirmed: { label: "Appointment confirmed!",      color: "text-green-700" },
failed:    { label: "Booking failed",              color: "text-red-600" },
};

export function AppointmentProgress() {
const status = useAppointmentStatus();
const phase = phaseLabels[status.phase];

if (!phase) return null;

return (
  <div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2">
    {status.phase === "checking" || status.phase === "booking" ? (
      <span className="h-3 w-3 animate-pulse rounded-full bg-blue-400" />
    ) : status.phase === "confirmed" ? (
      <span className="text-green-600">✓</span>
    ) : null}
    <span className={`text-sm font-medium ${phase.color}`}>
      {phase.label}
    </span>
  </div>
);
}

The complete dental frontend

Here is the final conversation section with all components integrated:

src/app/page.tsx (complete conversation section)tsx
import { DentalTranscript } from "@/components/DentalTranscript";
import { BookingConfirmation } from "@/components/BookingConfirmation";
import { DentalTextInput } from "@/components/DentalTextInput";
import { AvailabilityChecker } from "@/components/AvailabilityChecker";
import { AppointmentProgress } from "@/components/AppointmentProgress";
import { DentalStateIndicator } from "@/components/DentalStateIndicator";

{/* Active conversation */}
{(agentState === "listening" ||
agentState === "thinking" ||
agentState === "speaking") && (
<div className="space-y-4">
  <DentalStateIndicator />
  <AppointmentProgress />
  <DentalTranscript />
  <BookingConfirmation />
  <AvailabilityChecker />
  <DentalTextInput />
</div>
)}

The patient now has three ways to interact with Maya:

ModeInputBest for
VoiceSpeak into microphoneNatural conversation, simple requests
TextType in the text fieldSpelling names, insurance IDs, medications
VisualClick date picker and slot buttonsBrowsing availability, selecting times

All three modes work together in the same conversation. A patient might say "I need a cleaning," type their insurance ID, and click a time slot — all within a single session. Maya handles all of it.

RPC vs data channels vs text streams: when to use which

MechanismPatternDirectionDental use case
RPCRequest → ResponseBoth waysCheck availability, book a slot, show a form
Text streamsPublish (fire-and-forget)Agent → FrontendBooking confirmations, status updates
Data channelsPub/sub on topicsBoth waysReal-time appointment progress

Start with voice, add visual interactions later

The dental receptionist works perfectly with voice alone. The RPC-powered availability checker and slot buttons are enhancements that make the experience better — but they are not required. Design your agent so that every workflow can be completed by voice, and then layer on visual shortcuts for patients who prefer to click.

What you learned

  • RPC lets the frontend call agent methods (checkAvailability, bookSlot) and get structured responses
  • Bidirectional RPC lets the agent trigger frontend UI changes (showing forms, navigating screens)
  • Data channels carry real-time status updates on named topics
  • The dental frontend supports three input modes: voice, text, and visual (date picker + slot buttons)
  • All three modes work together in the same conversation
  • performRpc reuses the existing WebRTC connection — no separate API needed

Test your knowledge

Question 1 of 2

What is the difference between calling check_availability as an LLM tool and calling checkAvailability via RPC?

Course complete

You have built a dental receptionist frontend that:

  1. Connects to your Course 1.1 agent via the Session API with token authentication
  2. Tracks Maya's state machine and displays dental-specific UI for each phase
  3. Displays a streaming transcript with booking confirmation cards
  4. Accepts multimodal input — voice for natural conversation, text for precision
  5. Calls agent methods via RPC for visual booking workflows
  6. Receives real-time status updates via data channels

The agent you built in Course 1.1 has not changed. You have given it a face — a web UI that surfaces every feature you built on the backend. The dental receptionist is no longer just a voice on the phone. It is a complete booking experience.

Concepts covered
performRpcMethod registrationBidirectional RPCData channelsAvailability checker