The Numbers screen under Voice AI lists the DIDs your org owns and binds each one to a trunk and a voice agent. It is the screen that decides where an inbound INVITE for a given number ends up. The screen does not buy numbers. It manages numbers you already own — the empty state asks you to “add a DID you own”. Adding a row here tells TeleQuick what to do with calls that arrive on that number.

What a number row binds together

Each row in the list is one phone_number record, read from Supabase under your org’s RLS scope and ordered by e164. The add/edit modal exposes these fields: The Trunk dropdown is populated from the trunk table for your org and displays display_name, falling back to the carrier trunk_id when the trunk has no display name. The Agent dropdown is populated from agent_config filtered to active = true. An agent you have deactivated will not appear in the list, so you cannot newly bind a number to it. The list row renders the binding as a single line — number_type · country · trunk → agent — with a status tag on the right, plus an edit and a delete button.

Accepted number formats

The modal validates e164 against /^\+?\d{7,15}$/, which mirrors the BFF’s phoneNumberSchema exactly. The leading + is optional because the API accepts a plus-less national number; the form deliberately does not demand a character the API does not require. Anything else (letters, punctuation, too few or too many digits) is rejected client-side with “Enter the number in international format”. country, if filled, must be exactly two letters. If the list fails to load, the screen says so explicitly — “This is a load failure, not an empty list” — rather than rendering the empty state. Treat that message as a Supabase/RLS problem, not as “you own no numbers”.

What happens when you save

Save calls admin.upsertPhoneNumber({ orgId, number }). That procedure does four things in order, and all four matter for whether the call actually routes.
  1. Ownership check. The bound agent_id is asserted to belong to the same org. Binding a number to another org’s agent fails here.
  2. Supabase write. With an id present it is an UPDATE constrained by both id and org_id; without one it is an INSERT. The saved row is read back and drives everything that follows.
  3. Redis dual write. Two keys are written from the same JSON payload: The payload carries only the fields the C++ resolver needs:
  4. Dialplan rebuild. The engine’s dispatch blob is rebuilt so the inbound call routes to the bound agent instead of parking.
The save also emits a phone_number.added or phone_number.updated analytics event carrying e164, number_type, country, provider, and whether an agent and a trunk were bound.

Status values and inbound reachability

status is not cosmetic. It is the switch that decides whether the number exists at all as far as the gateway is concerned. Only active publishes. Saving a number as pending or inactive keeps the Supabase row — so the number stays in your list and keeps its trunk and agent binding — while removing it from the lookup path. Flipping it back to active re-writes both keys from the current row. The list tags active in the “ok” tone and every other status in the “warn” tone, so a non-routing number is visible at a glance.

Trunk ids: database row vs carrier identifier

There are two different things called a trunk id, and the distinction is the single most common source of “the number is saved but nothing routes”.
  • trunk.id — the Postgres primary key (uuid/serial). This is what the Trunk dropdown stores in phone_number.trunk_id, because it is a foreign key.
  • trunk.trunk_id — the carrier-supplied identifier, for example INT-KA-SBC09-9287. This is the key trunk_manager in the C++ gateway is indexed by.
Before writing the Redis payload, upsertPhoneNumber reads the trunk row by its primary key and resolves it to the carrier trunk_id string. The payload then carries the carrier identifier as trunk_id and keeps the primary key separately as trunk_db_id. Without that resolution the gateway would look up the numeric database id, find no matching trunk, and drop the call. If the trunk row is missing or has no carrier trunk_id, the payload’s trunk_id is written as null — the number is still indexed, but with no carrier trunk attached.

Why an unpublished agent drops inbound calls

The engine serves published agent snapshots only. Binding a number to an agent that has never been published produces a row that looks correct in Supabase and Redis but silently drops inbound calls: the DID resolves, the agent id is present, and there is no snapshot for the engine to run. The screen guards against this. It runs agentVersions.publishedAgents({ orgId }) once for the whole org, refetching every 30 seconds, and compares each row’s agent_id against the returned list. A number bound to an agent that is not in that list gets a not published warning tag with the tooltip:
This agent has never been published — inbound calls to this number cannot reach it. Publish it on the agent’s Versions tab.
The tag is advisory. The save is not blocked, and the Redis payload is still written with that agent_id. Fix it by publishing the agent from its Versions tab; the tag clears on the next poll. Note that this is a different condition from an agent that is simply deactivated. Deactivated agents never appear in the modal’s dropdown at all, because the picker filters agent_config to active = true. The “not published” tag is about an agent that exists and is active but has no served snapshot.

Deleting a number safely

The delete button asks for confirmation (Delete <e164>?) and then calls admin.deletePhoneNumber({ orgId, id }). The procedure:
  1. Reads the existing row’s e164 before deleting it — it needs the number to know which Redis field and key to remove.
  2. Deletes the Supabase row, constrained by both id and org_id.
  3. Removes the per-tenant hash field and the flat DID index entry.
  4. Rebuilds the dialplan rules blob.
Step 4 is the one not to skip conceptually: a deleted number’s per-DID rule has to go with it, otherwise the dispatch blob keeps routing that DID until some unrelated save happens to trigger a rebuild. The delete emits a phone_number.deleted event with the e164. If you only want to stop taking calls on a number temporarily, set its status to inactive instead. That removes it from both Redis keys with the same practical effect on routing, but keeps the row, the trunk binding, and the agent binding intact for when you turn it back on.
  • Authentication — how console and gateway calls are scoped to an org
  • Telemetry — CDR fields recorded for the calls these numbers receive