Skip to main content

Configuring LiveKit Agents at Dispatch with Metadata

Say you're building a restaurant agent that serves many restaurants, each with different opening hours, a different address, and a different menu. You could deploy a separate agent per restaurant, but it's more efficient to handle all of them with a single generic agent that you customize at runtime, at the moment it's dispatched.

LiveKit supports job metadata, a JSON object you pass alongside the dispatch. The metadata can hold whatever you like, but in the restaurant example it's enough to pass the restaurant's ID; the agent looks up everything else from there.

This post shows how to build and dispatch one agent that serves six restaurants, specializes into the one the user picks, and answers questions about its opening hours and menu. You can find the full source code for this demo on GitHub.

Use cases#

But it doesn't have to be restaurants. Consider a support line that answers for a different company on every call, a booking assistant that represents a different clinic or hotel, or a marketplace where each seller gets the same agent configured with their own catalog. In each case you run one deployment and select the right configuration when the client connects.

You can use the same technique to tailor the experience to the user, not just the business. A companion or coaching app greets the caller by name and looks up their history and preferences at the start of the call. Based on the user's region, you might also select the language, voice, and other attributes, so the same agent greets one caller in English and the next in Japanese. On security: most clients are verified with tokens, but for SIP clients see this post on layered caller verification.

The common thread is that the configuration varies per session, but the code does not.

How it works#

Your production solution has at least three components:

ComponentDescription
FrontendThe client your end user interacts with when they choose to 'talk to an agent'.
BackendYour business logic. It issues LiveKit tokens that grant the user permission to join a room, and dispatches an agent to that room with the correct metadata.
AgentYour generic agent, with all the capabilities it needs such as function tools or external data, configured per calling client.

The metadata can be any JSON object; in this demo it carries just the restaurant's ID.

Dispatching an agent and a user to the same room, based on a provided ID, looks like this:

Loading diagram…

Code walkthrough#

For the restaurant use case described above, where a single agent serves six restaurants, the code looks like this:

Step 1: the frontend sends an ID#

When the user picks a restaurant, the frontend POSTs that restaurant's ID to the backend and gets back everything it needs to join the room.

1
// frontend/components/restaurant-session.tsx
2
const res = await fetch(`${BACKEND_URL}/api/connection-details`, {
3
method: 'POST',
4
headers: { 'Content-Type': 'application/json' },
5
body: JSON.stringify({ restaurant_id: restaurant.id }),
6
});
7
const { serverUrl, participantToken } = await res.json();

Once it has serverUrl and participantToken, the demo uses the LiveKitRoom component to join the room with that token.

1
<LiveKitRoom
2
serverUrl={connection.serverUrl}
3
token={connection.participantToken}
4
connect={true}
5
audio={true}
6
video={false}
7
onDisconnected={onClose}
8
>
9
<AgentView restaurant={restaurant} />
10
<VoiceAssistantControlBar />
11
<RoomAudioRenderer />
12
</LiveKitRoom>

Step 2: the backend dispatches the agent with the ID as metadata#

In response to the client's request, the backend:

1
const AGENT_NAME = 'restaurant-agent';
2
3
const dispatchClient = new AgentDispatchClient(LIVEKIT_URL,
4
LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
5
6
app.post('/api/connection-details', async (req, res) => {
7
const { restaurant_id } = req.body ?? {};
8
9
// A unique room per session keeps each dispatch isolated.
10
const roomName = `restaurant-${restaurant_id}-${randomUUID()}`;
11
12
// 1) Explicitly dispatch the agent, handing it the id as ctx.job.metadata.
13
// createDispatch creates the room if it does not already exist.
14
await dispatchClient.createDispatch(roomName, AGENT_NAME, {
15
metadata: JSON.stringify({ restaurant_id }),
16
});
17
18
// 2) Mint a short-lived token so the diner can join that same room.
19
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
20
identity: `diner-${randomUUID()}`,
21
ttl: '15m',
22
});
23
at.addGrant({ roomJoin: true, room: roomName,
24
canPublish: true, canSubscribe: true });
25
26
res.json({ serverUrl: LIVEKIT_URL, participantToken: await at.toJwt() });
27
});

The room name in this demo is unique per session, which keeps each diner's dispatch cleanly isolated from the rest. The agent name, which is the name your agent workers register under, stays constant.

Note that to make the backend easy to run locally, it uses a wide-open cors(), which you should not enable in production.

Step 3: the agent reads the ID and configures itself#

For each job, the agent reads the metadata and configures its instructions, data, and tools as needed. For simplicity this demo hardcodes the restaurant information, but in production you'd load it from an external source so it stays up to date.

1
@server.rtc_session(agent_name="restaurant-agent")
2
async def entrypoint(ctx: agents.JobContext) -> None:
3
# Extract restaurant_id from the JSON metadata
4
restaurant_id = parse_restaurant_id(ctx.job.metadata)
5
restaurant = get_restaurant(restaurant_id)
6
logger.info("Starting session for restaurant_id=%r -> %s", restaurant_id, restaurant.name)
7
8
session = AgentSession(
9
...
10
)
11
12
await session.start(agent=RestaurantAgent(restaurant), room=ctx.room, room_options=...)
13
await ctx.connect()
14
await session.say(f"Welcome to {restaurant.name}. How can I help you?")
1
class RestaurantAgent(Agent):
2
def __init__(self, restaurant: Restaurant) -> None:
3
self.restaurant = restaurant
4
super().__init__(
5
instructions=build_instructions(restaurant),
6
...
7
)
8
9
@function_tool
10
async def get_opening_hours(self, context: RunContext) -> str:
11
"""Return the restaurant's opening hours for every day of the week."""
12
lines = [f"{days}: {hours}" for days, hours in
13
self.restaurant.opening_hours.items()]
14
return f"Opening hours for {self.restaurant.name}:\n" + "\n".join(lines)
15
16
# get_address and get_menu follow the same shape...

That's the entire pattern. To configure on users instead of restaurants, you change almost nothing: carry a user_id in the metadata instead of a restaurant_id, and change get_restaurant to get_user_profile.

Pitfalls to watch for#

This pattern is common, so it's worth calling out a few pitfalls developers often hit:

Avoid (or explicitly handle) duplicate room names. If you dispatch the agent by embedding RoomConfiguration in the access token, that dispatch happens only when the room is first created. If the room already exists, the token's dispatch configuration is silently ignored and your agent never joins. This demo sidesteps the issue by using unique room names and the explicit dispatch API.

The dispatch name must match exactly. The agent registers with @server.rtc_session(agent_name="restaurant-agent") and the backend dispatches to "restaurant-agent". If these drift, the dispatch is created but no agent worker ever picks it up, so keep the two values in sync.

Metadata is a string, always. ctx.job.metadata is a string, not a parsed object. You must json.loads it yourself and handle the case where it's empty or not valid JSON.

Console mode has no metadata. Running uv run src/agent.py console is the fastest way to talk to your agent, but it only simulates an agent joining a room, so there's no metadata. To test with metadata, use the Agent Console, which supports job metadata.

Wrapping up#

Configuring a LiveKit agent per session is a common need. Follow the recommendations in this article and you'll have a robust way to customize a single agent for many different scenarios.

The complete restaurant example is at livekit-demo-single-agent-multiple-restaurants. There's no hosted version, but the README has full setup instructions.

Related