> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mesh.texturehq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Phone

# Phone connector

An agent's own phone number. People text it the way they'd text anyone else,
with no app to install, and the agent texts back. Voice calls will attach to
the same number later.

This is the third conversation connector, and the first whose install is not
a vendor account. The install is a **phone number**, and which telecom API
carries it is a setting. Twilio and Telnyx are the two providers in v1. They
were built side by side on purpose, so that neither one shapes the contract.

## What the connector is responsible for

* receive text messages delivered by the number's provider
* authenticate each delivery with that provider's own scheme
* drop what is not a conversation before anything is persisted (strangers,
  carrier opt-out keywords, delivery receipts)
* map the sender's number to a Mesh actor inside the owning RuntimeAgent's
  perspective, and each (our number, their number) pair to a conversation
* tell the turn what medium the reply is going out in
* send the reply as plain text, split to fit carrier limits

The not-responsible-for list matches Slack's ([`slack.md`](/connectors/slack)): no
inference, no persona, no memory policy, no leaks across perspectives.

## The install is a number, not a vendor

`connector_type = 'phone'` (`0086_phone_connector.sql`). One row per number:

| column | value |
| - | - |
| `external_app_id` | the number in E.164 (`+15551234567`), which is the route |
| `external_workspace_id` | NULL: a number has no workspace |
| `conversation_namespace` | the number itself (see *Conversations* below) |
| `metadata` | `agent.PhoneSettings`: provider, account id, provider number id, Telnyx messaging profile, `shared_account`, and who may text it |

Secrets use role names, not vendor names: `phone_api_secret` (what Mesh calls
the provider's API with) and `phone_webhook_key` (what inbound deliveries are
verified against). What each holds is the provider's business. Twilio uses one
Auth Token for both roles, so it is stored twice. Telnyx uses an API key and
the account's Ed25519 public key. Keeping "which key verifies inbound" a fact
of the row keeps the loader free of per-vendor rules.

Naming the type after the channel (`sms`) would have forced a second install
for the same number when voice lands. Naming it after the vendor (`twilio`)
would have made moving a number between vendors re-key its conversation
history.

## Providers

`internal/connector/phone.Provider` is what the connector needs from a vendor:

| method | Twilio | Telnyx |
| - | - | - |
| `PrepareAccount` | Account SID + Auth Token (shape-checked; an `SK…` API Key SID is named as the wrong paste) | API key + public key |
| `ListNumbers` | `IncomingPhoneNumbers`; flags numbers without SMS | `phone_numbers/messaging`; flags numbers without a messaging profile |
| `ConfigureInbound` | sets the number's `SmsUrl` | sets the messaging profile's `webhook_url`, which applies to **every number in the profile** |
| `SearchAvailable` / `Buy` | `AvailablePhoneNumbers`, then create the number with `SmsUrl` in the same call | create a dedicated messaging profile with the webhook, then a number order into it (completes asynchronously) |
| `ParseInbound` | HMAC-SHA1 over the URL plus sorted form params, in `X-Twilio-Signature` | Ed25519 over `timestamp\|body`, with a 5-minute replay window |
| `Ack` | empty TwiML `<Response></Response>` ("send nothing yourself") | any 2xx |

Unknown numbers and failed signatures both get the same opaque 401.

Providers are selected by name only in `internal/connector/phone/providers`,
the same shape as `internal/execution/backends`. Nothing else switches on a
provider string. A third provider (Plivo, Vonage, Bandwidth, or an iMessage
bridge like Sendblue or Linq) is a new package plus a catalog entry. It needs
no change to routing, storage, the admin handlers, or the UI.
`TestConnectorCatalogAgreesWithProviders` pins the offered set to the wired
set.

## Inbound

`POST /phone/sms/{digits}`, registered with the provider by Mesh at connect
time. The order matches every other ingress: look up the install by the
number in the URL, then verify with **that** install's key. Every
authentication failure is the same opaque 401.

Twilio signs the URL it called, so the ingress rebuilds it from `PUBLIC_URL`
plus the request path. It never uses the `Host` a proxy forwarded, and a
signature computed over the internal URL fails.

After authentication, a delivery is acked and dropped for any of these
reasons, in order:

1. The provider says it isn't an inbound message (a status callback, a
   delivery receipt, or a group MMS).
2. It is addressed to a different number, for example a shared Telnyx profile
   delivering to the wrong install.
3. The sender isn't a phone number (a short code or an alphanumeric sender).
4. It is a self-echo.
5. **The sender isn't allowed** (see below).
6. It is a carrier subscription keyword: STOP, STOPALL, UNSUBSCRIBE, CANCEL,
   END, QUIT, OPTOUT, REVOKE, START, UNSTOP. YES, HELP and INFO are
   deliberately excluded. They are ordinary words in a conversation, and
   swallowing a "yes" costs more than a duplicate help message.
7. There is no text (media-only MMS; v1 reads text).

Acked drops are answered with the provider's success response. A non-2xx
would make the provider retry a decision.

### Who can text it

A phone number is publicly dialable, and every message a stranger sends costs
a model call and a billed reply. So a phone install **fails closed**: only
`allowed_senders` (E.164) reach the agent. An empty list admits nobody, and
the UI says so. `allow_anyone` opens it, with a warning. The allowlist is
enforced at ingress, **before** normalization, so a stranger's text never
becomes a graph row, a model call, or a reply. Because it is dropped with the
same success ack as everything else, the drop doesn't reveal which numbers are
allowed.

The allowlist changes without reconnecting:
`PUT /api/agents/{slug}/connectors/{id}/phone-settings`, applied live by a
registry reload.

### Conversations

The conversation key is `phone:<our number>:<their number>`, the namespace is
our number, and the topology is always direct. Keying the namespace by number
means a person who texts two of an agent's numbers is in two conversations.
The reply to each must leave from the number it was sent to, and
`RuntimeForRun` resolves a resumed run by namespace.

The sender's normalized E.164 is both the actor id and the reply address. A
text carries no display name, so the number stands in until the agent learns
one.

## Outbound: the medium, and the renderer

Two separate things make a reply fit a phone, and neither is a second model
pass. A rewrite after the model would mean the graph records one message and
the person receives another, which would make history, memory, and search
quietly wrong.

* **The medium** (`connector.Medium`). The connector declares how a reply is
  experienced: plain text, links as bare URLs, about 320 characters is
  comfortable, and each extra part is a separate billed message. The engine
  carries it per install (`turn.Engine.WithMedium`), and the prompt renders
  one line about **where this reply is going** (`prompt.mediumLine`). This
  covers the medium's register, not its wire format; `prompt.go` explains
  that distinction. The line names the reply, not the thread, so it stays true
  when verified identity linking brings Slack-shaped history into an SMS conversation.
  Slack and Telegram declare the zero value, and their prompts are unchanged.
* **The renderer** (`internal/render/plain`). Markdown becomes plain text: a
  link keeps its URL, lists keep their structure, and code keeps its lines.
  Replies are split by carrier segment budget: 10 segments of the part's own
  encoding, where GSM-7 allows 153 septets per segment and a single emoji
  turns the whole part into UCS-2 at 67 units. It is total and truncates
  nothing. An over-long reply goes out as several parts, which shows up as a
  prompt-tuning problem instead of a hidden edit.

The reply effect is `phone.sms.reply`. It is named for the channel as well as
the connector so that a text and speech on a call stay distinguishable in
`runs.committed_by_effect`.

## Credentials: instance account or the agent's own

This follows the same split as Slack's configuration token (install-wide,
creates apps) versus an app's own credentials (per agent, never shared).

* **The instance phone account** (Settings → Phone numbers, owner only;
  `internal/settings/phone.go`). One provider account for the whole install,
  stored as one install-owned secret. With it, an agent can
  **get a new number** (search, buy, and point it at Mesh in one step) or use
  a number already on the account, with nothing to paste.
* **The agent's own account.** Credentials are pasted on the connect screen,
  stored connector-owned, and never shared.

A number on the instance account **references** the instance credential; it
doesn't copy it. The install is flagged `shared_account` and holds no secret
of its own. `App.applyInstancePhoneAccount` resolves the account at every
registry reload, between the loader and `NewRegistry`, the same slot as
`applyInstanceModelDefaults`. Rotating the token therefore fixes every agent
at once. The write door refuses two things while numbers borrow the account:
replacing it with an account that doesn't hold those numbers, and removing
it. Checking which numbers the new account holds, rather than comparing
account ids, is what makes this provider-neutral: Telnyx has no account id.
At load, an install provisioned under a different account is reported and
left unrouted.

Buying is the one irreversible, billed step. Every check that can refuse runs
before it: route availability, the allowlist, and the request shape. A
purchase whose connection then fails to save says so, and names the number,
so nobody buys a second one.

**Why not a Twilio subaccount per agent?** It would give each agent its own
credential on the one-click path too. But US business-texting registration
(10DLC brand and campaign) is tied to the account that holds the number, so
every subaccount would need its own registration. That makes the one-click
path worse for the common case of one company running several agents. It
remains a reasonable option for an install that must isolate agents by
billing or compliance, and it would be a new provisioning mode, not a change
to this contract.

## Compliance the connector can't do for you

* **US A2P 10DLC.** Since February 2025, AT\&T, T-Mobile and Verizon block
  (not throttle) texts from unregistered business numbers. The operator
  registers a brand and campaign, or verifies a toll-free number, with their
  provider. The catalog says this before setup, and the success screen says
  it again for +1 numbers.
* **Reply-only.** The agent answers people who texted first. There is no
  outbound-initiation tool, because messaging someone who hasn't opted in
  needs consent evidence Mesh doesn't collect.
* **STOP.** Providers and carriers enforce opt-outs themselves. The connector
  just keeps the agent from replying to the keyword.

## Outbound SMS health in the dashboard

The stored connector status `active` means the inbound route is configured; it
is not a delivery receipt. Phone badges in the dashboard fetch
`GET /api/agents/{slug}/connectors/{id}/phone-health` independently of the page's
configuration read. The connection list and detail explain the result; the detail
page offers **Check again** after fixing registration. Checks run on page load or
manual refresh, with a ten-second deadline, and never send a test text or change
provider configuration. A failed or unsupported check is yellow **SMS unverified**.

Twilio checks the selected number's SMS capability and, for +1 long codes, its
Messaging Service membership and that service's A2P campaign. A missing service
or campaign is red; pending campaign approval is yellow; failed registration is
red. An account's unrelated approved campaign cannot make this number healthy.
Toll-free numbers are directed to toll-free verification, and international
numbers are not classified as missing A2P 10DLC registration.

An approved campaign alone remains unverified: Twilio's public
[Services PhoneNumbers API](https://www.twilio.com/docs/messaging/api/phonenumber-resource)
does not expose the individual number's registration state. Twilio documents
that distinction in its
[number registration guide](https://www.twilio.com/docs/messaging/compliance/a2p-10dlc/troubleshooting-a2p-brands/troubleshooting-a2p-phone-number-registration-issues).
The check also reads the latest outbound message from this number. A failed or
undelivered result exposes the error code, including 30034 (unregistered) and
30035 (number registration pending). Only a delivered message within seven days
gets a green **SMS delivered** badge; queued/sent is not delivered, and this is
historical evidence, not a guarantee about the next recipient.

The endpoint resolves only the owning connector's credentials. Existing legacy
shared-account installs must still match their stored account binding. No secrets,
message bodies, or recipient numbers are returned. Large account scans are bounded
at 100 services; timeouts, incomplete scans, and API failures remain unverified.
This check does not update conversation messages with per-message delivery
receipts; webhook-based delivery tracking remains separate work.

## Setup instructions are data

`GET /api/connector-catalog` groups every connector by what it does ("Join
your work chat", "Chat in a messaging app", "Give it a phone number",
"Connect your own software"). For each one it carries a summary, what you'll
need, steps with links, and notes. The phone connector also carries its
providers' credential fields. The dashboard renders all of it with one
component (`SetupGuide`), and the operator API returns the same thing, so an
agent following a skill reads the instructions a person does
(`internal/admin/connector_catalog.go`).

## Seam ledger: the third connector paid the bill again

[`telegram.md`](/connectors/telegram) listed where the second connector leaked outside
its own packages, and asked for a connector descriptor before a third one
paid again. This connector did not build that descriptor, so it paid again.
The bill:

* **Own packages** (legitimate): `internal/connector/phone` and its provider
  subpackages, `internal/render/plain`, this document.
* **The same leaks as Telegram**: a `ConnectorTypePhone` constant, secret
  kinds, and a `connectorSecretKinds` case (`internal/agent`); an ingress
  handler, a mux route, and a `rebuildServing` case (`internal/app`); save
  handlers (`internal/admin`); a wizard step and a connection-page panel
  (`web/`); one reply effect (`internal/run`).
* **What it did close:**
  * The **catalog half.** Connector copy, categories, and provider credential
    fields are served by `GET /api/connector-catalog`, and the picker renders
    from it.
  * **Per-vendor switching.** Within the phone connector, a new provider
    touches none of the leak sites above, because the provider is a setting
    behind `phone.Provider`, not a new connector type.

The descriptor half is still open work: route shape, credential dispositions,
and the mention-token form, declared once, so the agent, app, and admin
switches collapse the way `model.Provider` collapsed model selection.

## Seams left for later

* **Voice.** A sibling path (`/phone/voice/{digits}`) on the same install, and
  a separate provider interface next to `Provider`. A call is a media stream,
  not a request/response, and a provider that can text but not call must not
  implement a no-op.
* **MMS.** `Inbound.MediaCount` is carried; attachments are not fetched yet.

Cross-connector identity is implemented through
[`runtime/identity-linking.md`](/runtime/identity-linking). A newly observed
number starts as a separate actor. After private code verification against the
account the person already uses on another channel, the accounts share private
memory and requester history within the same agent's perspective, subject to
their original restrictions.

## Lifecycle reactions

None. A text message cannot be reacted to through any provider API, so the
phone connector provides no `connector.Reactor` and the runtime skips the
working / done / failed marks entirely; the reply is the only signal.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.