Bulbul · Telephony · Proposal

Where should outbound routing live?

Numbers, trunks, and connections after the self-serve launch: why the per-number trunk field should retire, the model I propose we land on, and a migration that cannot burn the house down.

60-second primer

How an outbound call leaves our platform

Five hops and one word of jargon carry this whole deck.

Bulbul agent our voice AI LiveKit Cloud media + SIP egress Kamailio our SIP router Carrier Maqsam, STC, … 📞 SIP trunk some carriers peer with LiveKit directly, skipping Kamailio
The agent names a trunk when it asks LiveKit to dial; the trunk is what tells LiveKit where the call goes next.

Where we are

Today, every phone number carries its own trunk ID

A hand-typed field, copied onto each number when it is registered. It was the right bridge during migration, and it is now the weakest link in the chain.

+966 11 XXX XX01 trunk: ST_carrier_xxxx +966 11 XXX XX02 trunk: ST_carrier_xxxx +966 92 XXX XX03 trunk: “ ” (typo, whitespace) LiveKit trunks same config, copied n times Shared outbound pool calls pick a random number from the org, no filter one bad trunk ⇒ random failures A number is an identity. A trunk is infrastructure. We are storing infrastructure on identities.

Why it existed: the field was deliberate backward compatibility, a bridge to carry pre-existing numbers into the new model until trunks could be consolidated. The consolidation was always the plan; connections shipping to prod is what finally makes it possible.

Why now

Connections make the trunk field removable, almost

We just shipped SIP connections: a first-class object describing a carrier relationship. That should make the hand-typed trunk field on numbers redundant. Working on exactly that this week convinced me the removal has to be sequenced carefully.

The destination is right: nobody should type trunk IDs once a connection owns routing. The UI just must not get there before the backend does. So the real question is: what is the destination, precisely?

Proposal

The connection owns routing. The number owns identity.

Every number resolves its route through one of three explicit shapes, and “no route” becomes a visible state instead of an empty string.

Phone number identity + ownership Connection: livekit_direct owns its LiveKit trunk, stored at creation Carrier, via its own trunk Maqsam-style direct peering Connection: platform_sip shared bridge trunk + routing set ID Kamailio → carrier tenant carried in a header (shipped) Legacy (no connection) number’s own trunk, migration-only Kamailio → carrier same routing plane, older signpost Resolution returns a typed plan (route kind + trunk + routing IDs). An empty trunk string becomes impossible to even represent.

Decision 1 · platform-routed connections

One trunk per connection, or one shared bridge?

For connections that route through our Kamailio stack, LiveKit needs a trunk pointing at it. The hidden cost of doing that per connection: Kamailio only accepts callers it can authenticate, so every auto-created trunk also needs an auto-created credential pair (“subscriber”) on the Kamailio side, generated, stored, rotated, and cleaned up in sync.

A · Trunk + credentials per connection

N trunks, N credential pairs

  • Per-tenant revocation and rate limiting at the SIP layer
  • Auth username itself identifies the tenant
  • Two systems to keep in sync on every create/delete
  • Password lifecycle (generate, store, rotate) × every connection
  • More moving parts in the self-serve path
Recommended

B · One shared bridge trunk per environment

1 trunk, 1 credential pair, set up once in Terraform

  • No per-connection provisioning dance at all
  • Tenant identity via the account header we already ship (TEL-160)
  • Consolidation goal achieved where it matters
  • Shared credential: revoking it affects everyone
  • Per-tenant SIP-layer limits need Kamailio-side rules instead

Decision 2 · direct carriers

Do direct-to-LiveKit carriers ever move behind Kamailio?

A few carriers (the Maqsam-style setups) peer with LiveKit Cloud directly today. Full consolidation would eventually put everything behind our own stack.

A · Everything behind Kamailio, eventually

One routing plane

  • Single place for routing logic, observability, control
  • Real migration project: carrier coordination, IP allowlists re-pointed
  • All media hops through our stack (latency, capacity, geography)
  • Domain cleanup held hostage to carrier timelines
Recommended

B · Permanent hybrid

Two supported shapes, both first-class

  • All the domain cleanup, none of the carrier migrations
  • Direct peering stays for carriers that integrate best with LiveKit
  • Consolidation “per routing boundary”, not literally one trunk
  • Two code paths to maintain (already true today)

Design point · there is no secret backup path

The old trunks are not a fallback, and we should not pretend they are

A tempting design is “if the connection is down, quietly fall back to the number’s old trunk”. But the legacy trunks point at Kamailio too, and the carrier routing they ride on is exactly the config that connections now own. The old trunk is the same road with an older signpost, not an independent route.

This also makes onboarding safe by construction: linking a number to a new connection changes nothing about routing. Only the deliberate cutover step does, and only after the connection is verified.

The plan

Six steps, two of which touch live routing

Step 1 Typed routing + guardrails Resolution returns an explicit route plan (direct / platform / legacy / no-route); empty trunks filtered out of the shared pool; unlink and deprovision guards. Legacy behavior reproduced bit for bit. No routing change
Step 2 Trunk-optional UI, gated The register and edit forms relax the trunk field only when the linked connection actually owns routing. No routing change
Step 3 Connections own their trunk from day one Direct connections store their outbound trunk at creation, required by the schema for outbound use. Connections are days old, so the few existing rows get recreated under the strict model; no backfill machinery. No routing change
Step 4 Platform bridge One shared trunk and one credential in Terraform, proven on the lab and canary. Each platform connection then switches with a test call. Hot · per connection
Step 5 Retire per-number trunks Snapshot all values, then per cohort: null the column, test call, monitor. Null means fail closed; rollback is restoring one value. Hot · per number
Step 6 Cleanup UI stops asking for trunks on connection-routed numbers, server-side validation mirrors the same rule, shared contracts updated. Connection-less numbers keep the legacy field until that population is gone. No routing change

Safety property: only steps 4 and 5 change live routing, and both are per-connection or per-number, verified with real test calls, monitored, and instantly reversible. There is no flag day and no deadline pressure.

This week

What lands Monday, and what we’re asking

Asks

scroll or use ↑ ↓ keys