Chapter 1120m

Beyond audio: the data plane

Beyond audio: the data plane

Your dental receptionist can talk, book appointments, and manage context — but everything so far has been audio and LLM messages. LiveKit is not just an audio transport. It is a full realtime data platform. In this chapter, you will learn how to send text streams for booking confirmations, byte streams for downloadable content, and participant attributes for frontend state — all flowing alongside the audio in the same WebRTC connection.

Text streamsByte streamsParticipant attributesData plane

Why the data plane matters

Consider what happens after your agent books an appointment. The patient hears "You're all set for Tuesday at 2 PM" — but what if they want written confirmation? What if the frontend needs to display the booking details? What if a dashboard needs to show the agent's current state?

Audio alone cannot solve these problems. You need a way to send structured data from the agent to other participants in the room — frontends, dashboards, logging services — in real time. That is what the data plane provides.

What's happening

Think of the data plane as a second channel running parallel to the audio. The audio channel carries voice. The data channel carries everything else — text, files, state updates, metadata. Both use the same low-latency WebRTC transport, so data arrives at the same speed as voice.

Text streams: sending booking confirmations

Text streams let your agent send structured text to any participant in the room. The text arrives in real time and can be displayed by a frontend, logged by a service, or processed by another system.

Here is how to send a booking confirmation as a text stream:

agent.pypython
from livekit.agents import Agent, function_tool, RunContext

class DentalReceptionist(Agent):
  def __init__(self):
      super().__init__(
          instructions="""You are a friendly receptionist at Bright Smile Dental clinic.
          After booking an appointment, always send a written confirmation.""",
          tools=[book_appointment],
      )

  async def send_booking_confirmation(self, booking: dict):
      """Send a text stream with booking details to the frontend."""
      stream = await self.session.room.local_participant.stream_text(
          topic="booking-confirmation",
      )
      await stream.write(
          f"Booking confirmed: {booking['name']} on {booking['date']} "
          f"at {booking['time']}. Bright Smile Dental, 123 Main St."
      )
      await stream.close()
1

Access the local participant

self.session.room.local_participant is your agent's identity in the room. Just like a human participant can publish audio tracks, your agent can publish data streams.

2

Open a text stream with a topic

stream_text(topic="booking-confirmation") creates a named text stream. The topic acts as a channel name — frontends subscribe to specific topics to receive only the data they care about.

3

Write and close

Write your content to the stream, then close it. You can write multiple times before closing for longer messages. The frontend receives each write as it arrives, enabling real-time display.

Now integrate this into the booking tool so it fires automatically after a successful booking:

agent.pypython
@function_tool
async def book_appointment(context: RunContext, name: str, date: str, time: str) -> str:
  """Book an appointment for a patient.

  Args:
      name: Patient's full name
      date: Desired appointment date
      time: Desired appointment time
  """
  booking = {"name": name, "date": date, "time": time}

  # Store in session state
  context.session.userdata["last_booking"] = booking

  # Send text stream confirmation to the frontend
  agent: DentalReceptionist = context.agent
  await agent.send_booking_confirmation(booking)

  return f"Appointment booked for {name} on {date} at {time}."

Topics are flexible

You can create any topic name you want. Use booking-confirmation for bookings, transcript for live transcription, agent-status for state updates. Frontends subscribe to the topics they need and ignore the rest.

Byte streams: sending downloadable content

While text streams are ideal for display text, byte streams let you send binary data — PDFs, images, or any file content. Here is how to send structured appointment details as a downloadable byte stream:

agent.pypython
import json

async def send_appointment_details(self, booking: dict):
  """Send appointment details as a JSON byte stream."""
  details = {
      "clinic": "Bright Smile Dental",
      "address": "123 Main Street, Suite 200",
      "phone": "(555) 123-4567",
      "patient": booking["name"],
      "date": booking["date"],
      "time": booking["time"],
      "instructions": "Please arrive 15 minutes early for paperwork.",
  }

  stream = await self.session.room.local_participant.stream_bytes(
      topic="appointment-details",
  )
  await stream.write(json.dumps(details).encode("utf-8"))
  await stream.close()

The frontend can receive this byte stream and render it as a card, generate a PDF, or save it to the user's device. The agent does not need to know how the frontend will use the data — it just sends it.

Participant attributes: visible state

Participant attributes are key-value pairs attached to any participant in the room. Unlike streams (which are fire-and-forget messages), attributes persist for the duration of the session and are visible to all other participants. They are ideal for state that the frontend needs to display continuously.

agent.pypython
class DentalReceptionist(Agent):
  async def update_agent_state(self, state: str, **extra):
      """Update participant attributes so frontends can reflect agent state."""
      attributes = {"agent_state": state}
      attributes.update(extra)
      await self.session.room.local_participant.set_attributes(attributes)

  async def on_enter(self):
      # Set initial state
      await self.update_agent_state("greeting")

      await self.session.generate_reply(
          instructions="Greet the caller warmly."
      )

Use attributes to drive frontend UI changes — show a booking form when agent_state is "booking", display a confirmation screen when it changes to "confirmed", or show a loading spinner during tool execution.

Here is how attributes flow through the booking process:

agent.pypython
@function_tool
async def book_appointment(context: RunContext, name: str, date: str, time: str) -> str:
  """Book an appointment for a patient.

  Args:
      name: Patient's full name
      date: Desired appointment date
      time: Desired appointment time
  """
  agent: DentalReceptionist = context.agent

  # Update state to "booking" so frontend can show progress
  await agent.update_agent_state("booking", patient_name=name)

  booking = {"name": name, "date": date, "time": time}
  context.session.userdata["last_booking"] = booking

  # Send confirmation stream
  await agent.send_booking_confirmation(booking)

  # Update state to "confirmed"
  await agent.update_agent_state("confirmed", patient_name=name)

  return f"Appointment booked for {name} on {date} at {time}."

Attributes are public

Every participant in the room can read every other participant's attributes. Do not store sensitive data like medical records or payment information in attributes. Use them for UI state only.

How frontends consume this data

You will build the full frontend in Course 1.2, but here is a preview of what the receiving side looks like. A frontend subscribes to text streams and attribute changes using the LiveKit client SDK:

frontend-preview.tstypescript
// Preview — you will build this in Course 1.2
import { Room, RoomEvent } from "livekit-client";

// Subscribe to text streams
room.registerTextStreamHandler("booking-confirmation", (reader, participantInfo) => {
// Display the booking confirmation in the UI
console.log("Booking confirmed:", reader.readAll());
});

// React to attribute changes
room.on(RoomEvent.ParticipantAttributesChanged, (changed, participant) => {
const state = participant.attributes["agent_state"];
// Update UI based on agent state
});
What's happening

The beauty of this architecture is separation of concerns. Your agent focuses on conversation and business logic. The frontend focuses on presentation. The data plane connects them. Your agent does not need to know whether the frontend is a React app, a mobile app, or a CLI tool — it just publishes streams and attributes, and any subscriber receives them.

The complete data-plane-enabled receptionist

Here is the booking tool with all data plane features integrated:

agent.pypython
from livekit.agents import Agent, function_tool, RunContext, TurnHandlingOptions, MultilingualModel
import json

class DentalReceptionist(Agent):
  def __init__(self):
      super().__init__(
          instructions="""You are a friendly receptionist at Bright Smile Dental clinic.
          Help callers book appointments. After booking, confirm verbally
          and send a written confirmation.""",
          tools=[book_appointment],
          turn_handling=TurnHandlingOptions(
              turn_detection=MultilingualModel(),
              min_endpointing_delay=0.5,
              max_endpointing_delay=1.5,
              interruption_mode="adaptive",
          ),
      )

  async def send_booking_confirmation(self, booking: dict):
      stream = await self.session.room.local_participant.stream_text(
          topic="booking-confirmation",
      )
      await stream.write(
          f"Booking confirmed: {booking['name']} on {booking['date']} at {booking['time']}"
      )
      await stream.close()

  async def update_agent_state(self, state: str, **extra):
      attributes = {"agent_state": state}
      attributes.update(extra)
      await self.session.room.local_participant.set_attributes(attributes)

  async def on_enter(self):
      await self.update_agent_state("greeting")
      await self.session.generate_reply(
          instructions="Greet the caller warmly and ask how you can help."
      )

Test it

Run your agent with lk agent dev and open Playground.

Test text streams. Say "I'd like to book an appointment. My name is Sarah, next Tuesday at 2 PM." After the agent confirms verbally, check the Playground data panel — you should see the booking-confirmation text stream with the structured confirmation message.

Test participant attributes. Watch the Playground participant inspector. As the conversation progresses, you should see the agent_state attribute change from greeting to booking to confirmed.

Try saying: "Can you send me the details?" This tests whether the agent's verbal confirmation and the data stream work together naturally.

Test your knowledge

Question 1 of 3

Why are participant attributes better suited for agent state (like 'greeting' or 'booking') than text streams?

Looking ahead

Your dental receptionist now speaks, thinks, uses tools, manages context, and sends data to frontends. It is a complete voice AI agent. In the next chapter, you will deploy it to LiveKit Cloud so it runs 24/7 without your laptop being open.

Concepts covered
Text streamsByte streamsParticipant attributesData plane