Chapter 815m

State with userdata

State with userdata

Agent handoffs replace the current agent entirely — new instructions, new tools, new personality. But the customer's data needs to survive these transitions. The guest's name, their order items, the running total, any special requests — all of this must be accessible to every agent in the chain. session.userdata is the persistent store that makes this possible.

session.userdataCross-agent state

What you'll learn

  • How session.userdata persists across agent handoffs
  • How to build a typed state object for your workflow
  • How to read and write state from any agent in the chain
  • Best practices for structuring cross-agent state

What is session.userdata?

session.userdata is a dictionary (or object in TypeScript) attached to the session that persists for the entire session lifetime. Unlike agent instance variables, which disappear when an agent is replaced during a handoff, userdata survives every transition. Any agent can read from it and write to it.

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


class GreeterAgent(Agent):
  def __init__(self):
      super().__init__(instructions="You are the greeter at Bella Vista...")

  async def on_enter(self):
      # Initialize state when the session starts
      self.session.userdata["customer_name"] = None
      self.session.userdata["order_items"] = []
      self.session.userdata["total"] = 0.0
      self.session.userdata["feedback"] = None
      self.session.generate_reply(
          instructions="Greet the guest and ask for their name."
      )

  @function_tool
  async def save_name(self, context: RunContext, name: str) -> str:
      """Save the customer's name."""
      self.session.userdata["customer_name"] = name
      return f"Saved name: {name}"
basicUserdata.tstypescript
import { Agent, functionTool, RunContext } from "@livekit/agents";

class GreeterAgent extends Agent {
constructor() {
  super({ instructions: "You are the greeter at Bella Vista..." });
}

async onEnter() {
  this.session.userdata.customerName = null;
  this.session.userdata.orderItems = [];
  this.session.userdata.total = 0;
  this.session.userdata.feedback = null;
  this.session.generateReply({
    instructions: "Greet the guest and ask for their name.",
  });
}

@functionTool({ description: "Save the customer's name." })
async saveName(context: RunContext, name: string): Promise<string> {
  this.session.userdata.customerName = name;
  return `Saved name: ${name}`;
}
}

Building a typed state object

Using raw dictionary keys works for small projects, but it is error-prone at scale. A typo in a key name becomes a silent bug. A better approach is to define a typed state object and store it in userdata.

order_state.pypython
from dataclasses import dataclass, field
from pydantic import BaseModel


class OrderItemData(BaseModel):
  name: str
  quantity: int
  modifications: list[str] = []
  unit_price: float = 0.0


@dataclass
class OrderState:
  customer_name: str = ""
  order_items: list[OrderItemData] = field(default_factory=list)
  total: float = 0.0
  payment_method: str = ""
  tip_percentage: float = 0.0
  feedback_rating: int = 0
  feedback_comment: str = ""
  email: str = ""

  @property
  def item_count(self) -> int:
      return sum(item.quantity for item in self.order_items)

  def add_item(self, item: OrderItemData) -> None:
      self.order_items.append(item)
      self.total = sum(
          i.unit_price * i.quantity for i in self.order_items
      )

  @property
  def order_summary(self) -> str:
      if not self.order_items:
          return "No items ordered yet"
      items = ", ".join(
          f"{i.quantity}x {i.name}" for i in self.order_items
      )
      return f"{items} (Total: ${self.total:.2f})"
orderState.tstypescript
interface OrderItemData {
name: string;
quantity: number;
modifications: string[];
unitPrice: number;
}

class OrderState {
customerName: string = "";
orderItems: OrderItemData[] = [];
total: number = 0;
paymentMethod: string = "";
tipPercentage: number = 0;
feedbackRating: number = 0;
feedbackComment: string = "";
email: string = "";

get itemCount(): number {
  return this.orderItems.reduce((sum, item) => sum + item.quantity, 0);
}

addItem(item: OrderItemData): void {
  this.orderItems.push(item);
  this.total = this.orderItems.reduce(
    (sum, i) => sum + i.unitPrice * i.quantity,
    0
  );
}

get orderSummary(): string {
  if (this.orderItems.length === 0) return "No items ordered yet";
  const items = this.orderItems
    .map((i) => `${i.quantity}x ${i.name}`)
    .join(", ");
  return `${items} (Total: $${this.total.toFixed(2)})`;
}
}
What's happening

The OrderState class centralizes all cross-agent state in one place. Properties like item_count and order_summary compute derived values, so you never have stale data. The add_item method automatically recalculates the total. This is much safer than scattering session.userdata["total"] = ... updates across multiple agents.

Using the state across agents

Initialize the state object once at the start of the session, then access it from any agent.

agent.pypython
from livekit.agents import AgentServer, AgentSession
from livekit.plugins import openai, deepgram, cartesia
from greeter_agent import GreeterAgent
from order_state import OrderState

server = AgentServer()


@server.rtc_session
async def entrypoint(session: AgentSession):
  # Initialize the shared state
  session.userdata["state"] = OrderState()

  await session.start(
      agent=GreeterAgent(),
      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()
agent.tstypescript
import { AgentServer, AgentSession } from "@livekit/agents";
import { DeepgramSTT } from "@livekit/plugins-deepgram";
import { OpenAILLM } from "@livekit/plugins-openai";
import { CartesiaTTS } from "@livekit/plugins-cartesia";
import { GreeterAgent } from "./greeterAgent";
import { OrderState } from "./orderState";

const server = new AgentServer();

server.rtcSession(async (session: AgentSession) => {
session.userdata.state = new OrderState();

await session.start({
  agent: new GreeterAgent(),
  room: session.room,
  stt: new DeepgramSTT({ model: "nova-3" }),
  llm: new OpenAILLM({ model: "gpt-4o-mini" }),
  tts: new CartesiaTTS({ voice: "<voice-id>" }),
});
});

server.run();

Now every agent reads and writes to the same state object:

greeter_agent.pypython
from livekit.agents import Agent, function_tool, RunContext
from order_state import OrderState


class GreeterAgent(Agent):
  def __init__(self):
      super().__init__(instructions="You are the greeter at Bella Vista...")

  @property
  def state(self) -> OrderState:
      return self.session.userdata["state"]

  @function_tool
  async def save_customer_name(self, context: RunContext, name: str) -> str:
      """Save the customer's name."""
      self.state.customer_name = name
      return f"Welcome, {name}!"

  @function_tool
  async def transfer_to_order_taker(self, context: RunContext) -> str:
      """Transfer to the order taker."""
      from order_taker_agent import OrderTakerAgent
      self.hand_off(OrderTakerAgent())
      return f"Transferring {self.state.customer_name or 'you'} to our order taker."
greeterAgent.tstypescript
import { Agent, functionTool, RunContext } from "@livekit/agents";
import { OrderState } from "./orderState";

class GreeterAgent extends Agent {
constructor() {
  super({ instructions: "You are the greeter at Bella Vista..." });
}

get state(): OrderState {
  return this.session.userdata.state as OrderState;
}

@functionTool({ description: "Save the customer's name." })
async saveCustomerName(context: RunContext, name: string): Promise<string> {
  this.state.customerName = name;
  return `Welcome, ${name}!`;
}

@functionTool({ description: "Transfer to the order taker." })
async transferToOrderTaker(context: RunContext): Promise<string> {
  const { OrderTakerAgent } = await import("./orderTakerAgent");
  this.handOff(new OrderTakerAgent());
  return `Transferring ${this.state.customerName || "you"} to our order taker.`;
}
}
order_taker_agent.pypython
from livekit.agents import Agent, function_tool, RunContext
from order_state import OrderState, OrderItemData


class OrderTakerAgent(Agent):
  def __init__(self):
      super().__init__(instructions="You are the order taker at Bella Vista...")

  @property
  def state(self) -> OrderState:
      return self.session.userdata["state"]

  async def on_enter(self):
      name = self.state.customer_name
      greeting = f"Hi {name}! " if name else ""
      self.session.generate_reply(
          instructions=f"{greeting}Ask what they would like to order."
      )

  @function_tool
  async def add_to_order(
      self,
      context: RunContext,
      name: str,
      quantity: int,
      unit_price: float,
      modifications: list[str] = [],
  ) -> str:
      """Add an item to the order."""
      item = OrderItemData(
          name=name,
          quantity=quantity,
          unit_price=unit_price,
          modifications=modifications,
      )
      self.state.add_item(item)
      return f"Added {quantity}x {name}. {self.state.order_summary}"
orderTakerAgent.tstypescript
import { Agent, functionTool, RunContext } from "@livekit/agents";
import { OrderState } from "./orderState";

class OrderTakerAgent extends Agent {
constructor() {
  super({ instructions: "You are the order taker at Bella Vista..." });
}

get state(): OrderState {
  return this.session.userdata.state as OrderState;
}

async onEnter() {
  const name = this.state.customerName;
  const greeting = name ? `Hi ${name}! ` : "";
  this.session.generateReply({
    instructions: `${greeting}Ask what they would like to order.`,
  });
}

@functionTool({ description: "Add an item to the order." })
async addToOrder(
  context: RunContext,
  name: string,
  quantity: number,
  unitPrice: number,
  modifications: string[] = []
): Promise<string> {
  this.state.addItem({ name, quantity, unitPrice, modifications });
  return `Added ${quantity}x ${name}. ${this.state.orderSummary}`;
}
}

Use a property accessor for the state

Defining a state property on each agent class that casts session.userdata["state"] to the correct type gives you autocomplete and type checking throughout your codebase. It also provides a single place to change if you restructure your state storage.

Best practices for cross-agent state

1

Initialize once in the entrypoint

Create the state object in your session entrypoint, before any agent runs. This guarantees it exists when the first agent's on_enter() fires.

2

Use a typed object, not raw keys

A dataclass or class with typed fields catches typos at development time and provides autocomplete. Raw dictionary keys only fail at runtime — and only if you are lucky enough to hit that code path during testing.

3

Put computed values in properties

Order totals, item counts, and summaries should be computed properties, not stored values. This eliminates the risk of forgetting to update a derived value when the source data changes.

4

Keep the state object focused

The state object should contain data that at least two agents need. If data is only used by one agent, store it on the agent instance instead. This keeps the shared state clean and reduces coupling.

Userdata is not persisted across sessions

session.userdata lives in memory for the duration of a single session. When the session ends (customer hangs up, connection drops), the data is gone. If you need data to survive across sessions — for returning customers or order history — write it to a database inside on_exit().

What you learned

  • session.userdata persists across all agent handoffs within a session
  • A typed state object (dataclass or class) is safer than raw dictionary keys
  • Computed properties eliminate stale derived values
  • Initialize state once in the entrypoint; access it from any agent via a property

Test your knowledge

Question 1 of 2

Why is a typed state object (like a dataclass) preferred over raw dictionary keys for session.userdata?

Next up

Your agents have fixed tools and instructions. But what if you want to add a coupon tool only after the order is placed, or change the agent's behavior mid-conversation? In the next chapter, you will learn about dynamic tool management with update_tools() and update_instructions().

Concepts covered
session.userdataCross-agent state