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
slack.md): 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:
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:
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:
- The provider says it isn’t an inbound message (a status callback, a delivery receipt, or a group MMS).
- It is addressed to a different number, for example a shared Telnyx profile delivering to the wrong install.
- The sender isn’t a phone number (a short code or an alphanumeric sender).
- It is a self-echo.
- The sender isn’t allowed (see below).
- 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.
- There is no text (media-only MMS; v1 reads text).
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: onlyallowed_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 isphone:<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.goexplains 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.
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.
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 statusactive 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
does not expose the individual number’s registration state. Twilio documents
that distinction in its
number registration guide.
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 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/phoneand its provider subpackages,internal/render/plain, this document. - The same leaks as Telegram: a
ConnectorTypePhoneconstant, secret kinds, and aconnectorSecretKindscase (internal/agent); an ingress handler, a mux route, and arebuildServingcase (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 catalog half. Connector copy, categories, and provider credential
fields are served by
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 toProvider. 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.MediaCountis carried; attachments are not fetched yet.
runtime/identity-linking.md. 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 noconnector.Reactor and the runtime skips the
working / done / failed marks entirely; the reply is the only signal.