Overview
A bot can own a real phone number. People text it and the bot answers in a thread. Calls and texts from one caller land in the same thread. The carrier side (buying numbers, registration, STOP/HELP handling, opt-outs, consent) lives in the cloud API. This page covers the runtime (allternit-api) side, in cmd/allternit-api/src/channel_phone.rs.
Flow
- The carrier posts an inbound text to the cloud. The cloud verifies the signature, dedupes, answers STOP/HELP/START, and relays everything else to the runtime at
/webhooks/channels/sms(providersms). Opted-out senders never reach the runtime. - The runtime checks Telnyx’s Ed25519 signature again (
telnyx-signature-ed25519over<telnyx-timestamp>|<body>) using the public key stored with the number. - The text opens or continues the thread keyed
phone:<botE164>:<callerE164>. - The bot’s reply goes out through the cloud:
POST /api/v1/channels/sms/send. The carrier key never reaches the runtime.
Thread key
channel_phone::resolve_thread(db, rt, number_id, caller_e164) -> Result<(thread_id, session_id), String> returns the thread for a caller on a number, creating it on the number’s bot when new. The voice worker uses it for calls, so a caller who texts and then calls keeps one conversation.
Replies
Replies are plain text. A reply over 1,600 characters is split at a paragraph, line, sentence or word break, and each part ends with(1/3). If a later part is refused after an earlier one went out, the send is reported as uncertain, never as rejected, so nothing is sent twice.
Pictures and files
In. When a text arrives with MMS media, the cloud copies each item (at most 10; 25 MB each, 15 s per fetch, content type kept) into the owner’s cloud storage before the text is queued, and the queued request carriesmedia: [{ url, contentType, name, bytes, stored }] where url is a permanent file link (see Files) instead of the carrier’s expiring URL. Plan caps apply. An item that can’t be stored (storage or links not configured, over the caps, fetch failed) keeps the carrier’s URL with stored: false and the reason is logged.
Out. A bot’s attachments on an SMS become permanent links in the text (see Starting conversations); the runtime uploads them through POST /api/v1/runtime-devices/me/files/uploads (Files). No MMS is sent. Other bot-made files (artifacts, share paths) can call cloud_files::upload_file(&CloudUploader, path) in allternit-api to get a link; only the SMS path uses it today.
Connect a number to a bot
PUT /gateway/phone-numbers/:numberId (signed in):
{ "number": { "numberId", "e164", "botId", "accountId", "state": "CONNECTED" } }. The token and key are stored sealed. Returns 503 when the runtime has no encryption key, 400 for a bad number or a bot you don’t own. DELETE /gateway/phone-numbers/:numberId disconnects it.
Move a number to another bot
The cloud decides which bot answers a number.PATCH /api/v1/phone/numbers/:id on cloud-api (signed in as the number’s owner, or the runtime’s own device credential for a number assigned to it):
200 { "number": { "id", "runtimeId", "botId", "e164", "carrier", "type", "smsState", "voiceState", "portState", "createdAt" }, "previousBotId": "bot_..." }. Errors: 400 bad_request (empty botId), 404 number_not_found (not yours, released, or a platform number), 404 runtime_not_found (runtimeId isn’t one of your computers), 403 runtime_cannot_move_number (a runtime credential tried to hand the number to another runtime).
It updates phone_numbers.bot_id (and runtime_id plus the number’s relay route when runtimeId changes) and moves invites still open on the number (pending, verified, joining) to the new bot. Consent records, opt-outs, registration and the carrier side are per number and don’t change. Per-bot voice settings (voice_bot_config) stay with each bot. The cloud then sends phone.numbers.changed to the number’s runtime (and to the old runtime when it moved), which pulls its numbers at once.
On the runtime, the pull (phone_sync.rs) rewrites channel_phone_numbers.bot_id, makes the new bot the number’s only member in channel_account_bots, and moves identityChannels.phone from the old bot’s config to the new bot’s (a new bot already showing a different number keeps it). Existing threads keep their bot.
POST /api/v1/phone/numbers/sync (runtime, signed in) pulls now and answers { "synced": [ids], "removed": [ids], "skipped": [{ "numberId", "reason" }] }, or 409 { "error": "not_paired" | "sync_failed", "message" }. The app calls it right after the PATCH, best effort, so the move applies even if the push hasn’t arrived.
Texting and calling out (runtime)
Code:cmd/allternit-api/src/phone_outbound.rs. The runtime asks the cloud, which holds the carrier keys and the consent gate; it never talks to the carrier or LiveKit itself.
numberId defaults to the bot’s only number (or the owner’s only number). A text goes through POST <cloud>/api/v1/channels/sms/send and is written into the phone:<botE164>:<toE164> thread as a channel.message.sent bot message, so it shows on every surface. A call goes through POST <cloud>/api/v1/phone/calls/outbound; on success the runtime writes a call.started placeholder (callId = the room, placeholder: true). A contact is POST <cloud>/api/v1/phone/numbers/:id/consent with source owner_contacts.
A cloud 403 no_consent becomes 403 { "error": "no_consent", "message": "..." }. If the cloud has not enabled dialing, /phone/call answers 503 { "error": "calls_unavailable" }.
Bot tools. phone_text { to, text } and phone_call { to, purpose } (optional agent_id) are served on the internal connectors MCP endpoint next to allternit_mail.*, and listed only for an owner who has a number. A refusal is returned as { "ok": false, "error": "no_consent", "message": "<sentence the bot can say>" }, not a tool error.
Cloud: the dial
POST /api/v1/phone/calls/outbound { numberId, to, botId, purpose } (Clerk session or an allternit_* bearer, the same as /channels/sms/send) issues a consentRef. If LiveKit is configured and ALLTERNIT_LIVEKIT_OUTBOUND_TRUNK_ID is set, it also creates the room call-out-<uuid> with an allternit-voice agent dispatch (metadata { direction: "outbound", consentRef, botId, ownerId, numberId, to, from, purpose }), then creates the SIP participant on the trunk with the number’s E.164 as caller ID, and answers { consentRef, basis, expiresAt, room, dialing: true }. With the trunk variable unset it answers { consentRef, basis, expiresAt } and dials nothing. A LiveKit failure is 502 { "error": "livekit_dial_failed" }. The voice worker hangs up any outbound call without a consentRef.
Inert until configured
With no connected number there are nosms accounts, so /webhooks/channels/sms answers 401 for everything. The cloud send route answers 503 sms_not_configured until its carrier env is set.