Skip to content

Phone

The phone module manages phone number lifecycle: listing owned numbers, purchasing new ones via Twilio, assigning voice agents and text agents per number, and setting friendly names for easier identification.

src/mods/phone/
├── api/ # CRUD endpoints for phone numbers and agent assignment
├── components/ # UI: phone list, card, details form, buy flow, confirm modal
├── types/ # Phone, PhoneData, BuyPhoneResponse, SearchAvailableResponse
└── views/ # PhoneListView, PhoneDetailsView, BuyPhoneView

All endpoints require an authenticated Session and enforce ABAC permission checks.

MethodRoutePermissionDescription
GET/api/phonesPhone:Collection:ListList all org phone numbers
GET/api/phones/{id}Phone:Instance:ViewGet a single phone by UUID
PUT/api/phones/{id}Phone:Instance:UpdateUpdate friendly name, voice agent, and/or text agent
PUT/api/phones/{phone_number_id}/agent/{agent_id}Phone:Instance:UpdateAssign a voice agent (legacy route)
DELETE/api/phones/{phone_number_id}/agentPhone:Instance:UpdateClear voice agent assignment

The purchase endpoints live in the twilio module but are part of the buy flow:

RouteMethodDescription
POST/api/twilio/search-available-numbersSearch by area code
POST/api/twilio/buy-phone-numberPurchase an E.164 number

The phone resource defines four ABAC permissions. All phone access is org-scoped — there are no assignment-based variants like the contact module.

PermissionGrants
Phone:Collection:ListView the phone number list
Phone:Collection:CreatePurchase new phone numbers
Phone:Instance:ViewView phone details
Phone:Instance:UpdateEdit friendly name and agent assignments

Super-admins and org owners bypass all permission checks.

Collection endpoints check permissions before querying the database — unauthorized requests never reach the DB:

PhoneResource::has_collection_permission(&session, PhoneCollectionPermission::List)
.or_forbidden("Not allowed to list phones")?;

Instance endpoints check permissions after loading the entity (the org ID is needed for the instance check):

PhoneResource::has_instance_permission(&session, PhoneInstanceAction::Update, &instance)
.or_forbidden("Not allowed to update this phone")?;

All three phone views enforce permissions client-side with early-return AccessDenied guards:

ViewPermissionBehavior
PhoneListViewPhone:Collection:ListShows AccessDenied if missing
BuyPhoneViewPhone:Collection:CreateShows AccessDenied if missing
PhoneDetailsViewPhone:Instance:ViewShows AccessDenied if missing

The Buy Number button in the list view only renders when the user holds Phone:Collection:Create. The Save button in the details form disables via a read_only prop when the user lacks Phone:Instance:Update:

// PhoneDetailsView passes read_only based on permission
PhoneDetails {
phone,
agents,
text_agents,
on_save: handle_save,
read_only: !can_update,
}
pub struct Phone {
pub id: Uuid,
pub number: String, // E.164 format, e.g. "+15551234567"
pub friendly_name: Option<String>, // User-defined label, e.g. "Front Desk"
pub agent_id: Option<Uuid>, // Voice agent — None = unassigned
pub text_agent_id: Option<Uuid>, // Text agent — None = unassigned
pub text_agent_auto_reply: bool, // Auto-send best suggestion for inbound SMS
}
pub struct PhoneData {
pub friendly_name: Option<String>, // Trimmed; empty string clears the name
pub agent_id: Option<Uuid>, // None = clear voice agent
pub text_agent_id: Option<Uuid>, // None = clear text agent
pub text_agent_auto_reply: bool,
}

BuyPhoneResponse maps to HTTP status codes:

VariantHTTP Status
Success(String)201
InvalidPhoneNumber400
NoNumbersAvailable404
TwilioNotConfigured500
TwilioError(String)502
InternalError(String)500

SearchAvailableResponse follows the same pattern, returning Vec<AvailablePhone> on success (200).

Each phone number can have an optional friendly_name label (e.g. "Front Desk", "Sales Line").

  • Set or update: PUT /api/phones/{id} with { "friendly_name": "Front Desk", "agent_id": null }
  • Clear: Send an empty string or omit the field — the server trims and normalises to None
  • Fallback: Wherever a friendly name is shown (phone cards, analyzer selectors, call history), the raw E.164 number is always used as a fallback when no name is set
User enters 3-digit area code
→ POST /api/twilio/search-available-numbers
→ Results grid: friendly name, location, capability badges (Voice/SMS/Fax)
→ User selects a number → ConfirmPurchaseModal
→ POST /api/twilio/buy-phone-number (E.164 number)
Server side:
1. Validate E.164 format
2. Fetch org Twilio credentials
3. Call Twilio API to purchase
4. Insert phone_number record (id, org_id, number, twilio_sid, agent_id=None, friendly_name=None)
5. Auto-assign default analyzers to the new number
6. Set voice webhook URL on Twilio to {APP_HOST}/twilio/voice
→ Navigate to phone list

Each phone number supports two independent agent assignments, both managed from the phone details form.

Handles inbound calls on this number. Only one voice agent can be assigned per phone.

  • Assign/update: PUT /api/phones/{id} with agent_id set
  • Clear via dedicated route: DELETE /api/phones/{phone_number_id}/agent
  • Clear via update: PUT /api/phones/{id} with agent_id: null

Handles inbound SMS on this number. Acts as the org-wide default for that number — used when no contact-level text agent assignment exists for the inbound sender.

  • Assign/update: PUT /api/phones/{id} with text_agent_id set
  • Clear: PUT /api/phones/{id} with text_agent_id: null
  • Auto-reply toggle: when text_agent_auto_reply: true, the agent automatically sends the highest-confidence reply suggestion to every inbound SMS on this number (no human review)

Priority order for inbound SMS routing:

Inbound SMS arrives on phone P
→ Contact-level text agent link? → Use that agent + its auto_reply setting
→ No contact link → Phone-level text_agent_id? → Use that + phone's text_agent_auto_reply
→ No phone agent → No suggestions generated; message stored only

The auto-reply toggle only appears in the UI when a text agent is selected. Deselecting the agent hides the toggle and clears the auto_reply state.

After saving, the phone data refetches to reflect the change.

PR #145 added friendly_name via a dedicated migration:

migration/src/m20260223_000000_phone_number_add_friendly_name.rs

After running just migrate, regenerate SeaORM entities with just generate.

PhoneListView — Grid of phone cards (1/2/3 columns responsive). Each card shows the friendly name prominently with the raw number beneath. Links to the details view. Empty state prompts to buy a number. Header includes a “Buy Number” button.

PhoneDetailsView — Loads phone + all agents in parallel. Renders a form with a friendly name text field, agent selector, and save button. Calls PUT /api/phones/{id}.

BuyPhoneView — Hosts the purchase flow. Navigates to the phone list on successful purchase.

  • Agent — voice agents are assigned to phone numbers to handle inbound calls
  • Text Agents — text agents handle inbound SMS; phones act as the org-wide default assignment
  • Twilio — provides the purchase and search APIs, receives inbound calls and SMS
  • Call — uses Phone.friendly_name in the “Received On” call history column