Control plane and media plane
Four processes, one of which touches audio. Which is which, and why they are apart.
Four processes. Exactly one of them touches audio, and knowing which is the difference between a deployment that works and one that is mysteriously slow.
The four#
| Process | Runs on | Carries media | Holds the signing key |
|---|---|---|---|
Media plane (livekit-server) | a rented VM, public IP, UDP 50000–60000 | yes | yes |
| Control plane (Node) | a container platform | no | yes |
| API (Flask) | a container platform | no | no |
| Worker (Python agent) | beside the media plane | yes, as a participant | no |
Media plane#
An SFU — a selective forwarding unit. Every participant sends its audio and video to this one box, and the box forwards each stream to whoever subscribed to it. It is the only component in the system that packets pass through, which is why it lives on a machine with a public address and an open UDP range and why it is deployed by hand rather than by a push.
One per region, and they share nothing. See Regions.
Control plane#
A few hundred lines of Node using the standard library's HTTP module. It mints join tokens, chooses a region, dispatches agents, reports health, and serves the MCP surface. It never sees a byte of audio.
That is what makes it deployable to an ordinary container platform while the media plane needs a VM: it has no UDP requirement, no bandwidth profile, and no reason to be near anybody. It does hold the signing key pair, which is the reason token minting lives here and not in the API — one process holds the secret, and every other caller asks it.
An unreachable media plane makes the control plane degraded, not down. It is still answering and can still mint tokens for when the media plane comes back. Returning 503 would take the whole unit out of a load balancer over a dependency it does not own. See Health and status.
API#
The Flask app. Call history, statistics, avatar enrolment and consent, webhook
receipt, ingest from the worker. Every route under /public/* is behind
require_customer() and scoped to one organisation.
It talks to the control plane rather than to the media plane — that is why
/public/live is a request to the control plane's MCP endpoint rather than a
LiveKit client call. Keeping the media credentials in one process means the
Flask app needs no LiveKit dependency at all.
Worker#
The agent. It runs on the media VM and joins rooms as a participant, so unlike the other three it is on the audio path. It is also the only component that sees the inside of a conversation: what each side said, how long the caller waited, which tools fired.
Two things about it are deliberate and surprising:
- It posts to the API over HTTP rather than writing to the database. The media VM has a public IP and an open UDP range because that is what an SFU needs. Putting a database connection string on the most exposed machine you operate, to save one HTTP hop, is a bad trade.
- Every write is fire-and-forget. A call is the product; a row about a call is not. If the database is slow or the API is redeploying, the correct outcome is a missing record and a conversation the person never noticed was at risk.
Why lifecycle and content come from different places#
The worker knows the most about a call and is not sufficient on its own. It only runs when an agent was dispatched, so a room two humans joined leaves no record; and a worker that crashes takes its unwritten record with it, losing precisely the calls most worth looking at, silently.
The media server has neither problem — it is the thing that opens and closes the room. So:
- lifecycle comes from the media server's webhook and is authoritative
- content comes from the worker and is best-effort
Both upsert into the same lg_calls row on (room_name, region), so it does not
matter which arrives first. Webhooks and events ·
Transcripts