# Console: numbers and inbound binding

> How the Numbers screen binds a DID to a trunk and an agent, what the save writes to Supabase and Redis, and why status and publish state decide whether inbound calls connect.

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:

| Field         | Meaning                                                        |
| ------------- | -------------------------------------------------------------- |
| `e164`        | The number itself. Shown in the list in monospace.               |
| `country`     | Two-letter country code (`US`, `IN`).                            |
| `number_type` | `local`, `toll-free`, or `mobile`.                               |
| `status`      | `active`, `pending`, or `inactive`. See below.                   |
| `trunk_id`    | The trunk row this DID arrives on. Optional (`— none —`).        |
| `agent_id`    | The voice agent that answers inbound calls. Optional.            |

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:

   | Key                                | Shape | Who reads it                                                        |
   | ---------------------------------- | ----- | ------------------------------------------------------------------- |
   | `dids:<org_id>`                    | Hash, field = `e164` | Kept for backwards compatibility with anything that scans DIDs by tenant. |
   | `telequick:did:<e164>`     | String | The flat global index the C++ gateway reads to route an inbound INVITE — a single `GET`, no tenant scan. |

   The payload carries only the fields the C++ resolver needs:

   ```json
   {
     "e164": "+14155550123",
     "tenant_id": "org_abc",
     "agent_id": "agt_xyz",
     "trunk_id": "INT-KA-SBC09-9287",
     "trunk_db_id": "84425069",
     "rate_plan_id": null,
     "status": "active"
   }
   ```

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.

| Status     | Redis effect                                           | Inbound result |
| ---------- | ------------------------------------------------------ | -------------- |
| `active`   | Payload written to both `dids:<org_id>` and the flat DID index. | The gateway resolves the number and routes it. |
| `pending`  | Both keys are **deleted**.                              | The gateway finds nothing for this DID. |
| `inactive` | Both keys are **deleted**.                              | The gateway finds nothing for this DID. |

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.

## Related

- [Authentication](/concepts/authentication) — how console and gateway calls are scoped to an org
- [Telemetry](/platform/telemetry) — CDR fields recorded for the calls these numbers receive
