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.
What you'll learn
- How
session.userdatapersists 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.
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}"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.
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})"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)})`;
}
}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.
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()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:
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."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.`;
}
}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}"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
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.
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.
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.
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.userdatapersists 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().