Your first call
Mint a token, join a room, and put an agent on the other end of it.
Ten minutes, four commands, one browser tab. At the end of it a room exists on your own media server, you are in it, and an agent is talking back.
This assumes the media plane is already up. If it is not, do The media plane first — it is a thirty-minute runbook and everything below depends on it.
1. Point the CLI at your switchboard#
Three variables, all from the box you provisioned. Names only here; the values
live in your secret store — see env/README.md in the repository.
export LIVEKIT_URL=wss://sg.example.com
export LIVEKIT_API_KEY=...
export LIVEKIT_API_SECRET=...
Check the media plane is answering before going further. This is the cheapest oracle you have, and it distinguishes "DNS resolves" from "the process is up":
npx udk health
2. Mint a token#
A join token is a signed statement of who you are and what you may do in one named room. It is not a login, it carries no account, and it expires.
npx udk token create --room demo --identity daniel --role human
--role is not cosmetic. human speaks and listens; observer listens only;
agent additionally carries canPublishData, which agent control messages
require. Full detail on Access tokens and grants.
3. Join the room#
Any LiveKit client SDK will connect with that token — the browser SDK is the fastest to try:
<script type="module">
import { Room } from 'https://cdn.jsdelivr.net/npm/livekit-client/+esm'
const room = new Room()
await room.connect('wss://sg.example.com', '<the token from step 2>')
await room.localParticipant.setMicrophoneEnabled(true)
room.on('trackSubscribed', (track) => {
if (track.kind === 'audio') track.attach(document.body)
})
</script>
You are now in a room by yourself. Nothing answers, because nothing has been dispatched into it yet.
4. Put an agent in it#
start_call does the whole thing in one call — creates the room, dispatches a
worker into it, and returns the token the human joins with. Over MCP:
curl -sX POST "$LIVEGRID_CONTROL_URL/mcp" \
-H "Authorization: Bearer $UDK_SECRET" \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "start_call",
"arguments": { "room": "demo-2", "user_identity": "daniel", "mode": "voice" }
}
}'
The response carries a token for daniel. Join with it exactly as in step 3, and
the agent is on the other end.
What just happened#
Nothing you did touched audio. The control plane signed a token and asked the media server to dispatch a worker; the media server put you and the worker in the same room and moved packets between you. That separation is the subject of Control plane and media plane, and it is the thing worth understanding before you deploy any of it.
When it does not work#
| What you see | Almost always |
|---|---|
503 with a message about credentials | LIVEKIT_API_KEY / LIVEKIT_API_SECRET unset where the control plane runs |
| The token mints, the browser never connects | LIVEKIT_URL is the control plane's URL, not the media server's |
| You connect, the agent never joins | No worker is running, or it registered under a different AGENT_NAME |
| The agent joins and never speaks | The worker's token was minted with the human role, so it has no canPublishData |
More in Errors.