npx skills add ...
npx skills add caffeinelabs/skills --skill connector-twilio
EXPERIMENTAL, NOT YET VERIFIED AGAINST LIVE TWILIO, and it spends real money — every message is billed, and a US-bound production number additionally needs A2P 10DLC registration (fees, weeks of lead time). Say both things to the user before building. That said, if a Caffeine build does send SMS or MMS, or configures Twilio messaging, from a canister, the `twilio-client` mops package (Twilio REST API) with a canister-held HTTP Basic credential is the only supported path. Hand-rolling `ic.http_request` calls to `api.twilio.com` or `messaging.twilio.com` is a FORBIDDEN anti-pattern — it bypasses the typed bindings, the per-operation host routing, the Basic-Auth header construction, and above all the non-replicated outcall default that stops one `send` from becoming ~13 billed messages. Load this skill whenever the user, spec, or any prior task mentions SMS, MMS, "text message", "send a text", phone numbers, Twilio, a Messaging Service, A2P 10DLC, toll-free verification, short codes, or an alphanumeric sender — and BEFORE writing any code that touches a Twilio endpoint.
npx skills add caffeinelabs/skills --skill connector-twilio
Send SMS / MMS and configure Twilio messaging from a Caffeine canister.
⚠️ Experimental (
twilio-client@0.1.2) — no call has ever been made from this client. Its write path could work at all only recently: before, every write discarded its arguments and posted an empty body. The wire format now matches what Twilio documents (form-encoded body, percent-encoded values, optional fields omitted) and all 118 files typecheck, but structurally correct is not verified. Treat the first successful send as the acceptance test, and do not present Twilio to a user as a fully supported platform feature until one has happened. Sends cost money, so a failed experiment is not free.
Scope — the package is the messaging surface only.
twilio-clientis pruned to 35 API modules (all of Messaging v1 plus the v2010 messaging path: Account, Message, Media, IncomingPhoneNumber and its variants, AvailablePhoneNumber, the A2P registries). Voice/calls, recordings, conferences, queues, applications, SIP and usage records are not in the package — if a build needs those, they are outside this connector. (Counts: 35 API modules, 82 models, 118 files, all typechecking.)
Load this skill when the user, spec, or a prior task mentions sending a text
message, SMS/MMS, notifying someone by phone, buying or listing phone numbers, or
any Twilio messaging concept. Raw ic.http_request to *.twilio.com is an
anti-pattern that re-implements auth, host routing, percent-encoding and JSON
parsing by hand — and, done naively, sends every message ~13 times.
Intent → capability mapping:
| User intent | Capability |
|---|---|
| Send an SMS | Api20100401MessageApi.createMessage with from = a Twilio number |
| Send an MMS (image) | same, with mediaUrl = ["https://…"] and sendAsMms = true |
| Send via a Messaging Service (recommended for US traffic) | same, from = "" + messagingServiceSid |
| Check delivery status | fetchMessage (status, error_code) |
| List / search sent messages | listMessage (paginated) |
| Own or browse phone numbers | Api20100401IncomingPhoneNumberApi, …AvailablePhoneNumberCountryApi |
| Set up a Messaging Service | MessagingV1ServiceApi.createService |
| Register for US A2P 10DLC | MessagingV1BrandRegistrationApi → MessagingV1UsAppToPersonApi → MessagingV1PhoneNumberApi (in that order — see US A2P 10DLC) |
| Verify a toll-free number | MessagingV1TollfreeVerificationApi |
Twilio credentials are something a human must go and fetch from a console, so the build is not done when the backend compiles — it is done when the app tells the admin where to get the credential and gives them somewhere to paste it. See Auth model, then Frontend for the page that MUST ship, and repeat the steps in the completion message.
Ask before writing code: which number sends? A US-bound production app needs a Messaging Service + A2P registration (weeks of lead time, real fees); a demo/internal app can send from a single trial number to verified recipients only. Report the choice and its consequences back to the prompting user.
Both flavours are the same #basicAuth { user; password } credential and the
client treats them identically; they differ in blast radius.
| Flavour | user / password | When |
|---|---|---|
| API Key (default — prefer this) | API Key SID (SK…) / its Secret | Production. Revocable and scoped: leaking one does not surrender the account. |
| Account SID + Auth Token | Account SID (AC…) / Auth Token | Dev only. The Auth Token is the account — it can create sub-accounts, buy numbers, and spend money. |
The Account SID (AC…) is also a required positional argument to every
v2010 operation (it is in the URL path), regardless of which flavour is used. So
an app using an API Key stores three values: Account SID, Key SID, Key Secret.
AC…) is on the console dashboard — copy it.SK…) and the Secret. The Secret is shown once — if
the admin navigates away it cannot be recovered, only replaced.
For dev only, take the Auth Token from the dashboard instead.21608; trial messages also carry a
"Sent from your Twilio trial account" prefix.The admin pastes them through an admin-gated setter — gated on
AccessControl.hasPermission(state, caller, #admin). They are held by the
canister only and never returned to the frontend.
⚠️ Never gate the setter on a first-caller-claims-ownership scheme. On the IC every unauthenticated caller is the same anonymous principal, so if an anonymous call claims ownership first, every anonymous caller passes the
caller == ownercheck and can overwrite the credential — and this one spends money.
The canister hands them to the client only through
config.auth = ?#basicAuth { user; password }, which every method turns into an
Authorization: Basic … header. No method takes a credential argument and none
puts it in the URL, so it cannot leak through a logged query string.
defaultConfig ships is_replicated = ?false, so anything derived from it by
record update is correct as-is. Nothing to remember, nothing to add.
⚠️ Do not set it to
?trueornull, and disregard any older advice to do so. An older version of this SKILL claimed writes should stay replicated "so IC consensus dedups retries". That is false and expensive. A replicated outcall is performed by every node in the subnet: the request is sent ~13 times, so ~13 SMS are sent and ~13 are billed, the credential leaves every node, and consensus fails anyway because Twilio stamps each reply with a uniquesid(so the responses never agree byte-for-byte). This is the same defect that produced ~13 duplicate emails via the Gmail connector and droveslack-client0.1.0.
Reads (fetch* / list*) are equally fine non-replicated: one node's view of a
message log is what you want, and it is the cheaper path.
The admin gate in the recipe below needs the authorization component alongside the client:
Every module offers both. The free function takes config first and is async*;
the module class captures config and is async:
All parameters are positional and there are 27 of them on createMessage.
Pass "" / false / 0 / 0.0 / [] / null for the ones you do not
use — the optional enum parameters are ?T precisely so that null omits them
from the wire. Count carefully; a misplaced empty string silently sends the
wrong field. The order is:
config, accountSid, to, statusCallback, applicationSid, maxPrice, provideFeedback, attempt, validityPeriod, forceDelivery, contentRetention, addressRetention, smartEncoded, persistentAction, trafficType, shortenUrls, scheduleType, sendAt, sendAsMms, contentVariables, riskCheck, from, fallbackFrom, messagingServiceSid, body, mediaUrl, contentSid
The migration chain head:
For MMS: mediaUrl = ["https://example.com/image.jpg"] and sendAsMms = true.
To send through a Messaging Service, leave from = "" and set
messagingServiceSid instead.
to must be E.164: +, country code, no spaces, dashes or parentheses —
"+15551234567". "555-1234" fails with 21211. Normalize in the frontend and
again in the canister; do not trust either alone.from vs messagingServiceSid — exactly one. Setting both is an error.
A bare from number is fine for non-US traffic and demos; US-bound
production traffic should go through a Messaging Service (sender pool,
sticky sender, and it is what A2P registration attaches to).Api20100401AvailablePhoneNumberCountryApi.Before any US long code can text US destinations, all three must exist. Without them US carriers reject the traffic outright.
MessagingV1BrandRegistrationApi.createBrandRegistrations,
referencing Trust Hub customerProfileBundleSid + a2PProfileBundleSid
(created out of band). Pass mock = true in dev to skip the fee. Status starts
PENDING and settles to APPROVED / FAILED over hours to days; it fails if
business details are incomplete, inconsistently formatted, or do not match
registry data.MessagingV1UsAppToPersonApi.createUsAppToPerson,
referencing both the Messaging Service and the brand. Most onboarding
failures land here. T-Mobile rejects campaigns whose messageFlow does not
describe opt-in, or whose messageSamples do not match the declared
usAppToPersonUsecase.MessagingV1PhoneNumberApi.createPhoneNumber(cfg, serviceSid, phoneNumberSid).
A number lives in exactly one Messaging Service at a time; reassignment needs
deletePhoneNumber first.Registration deadline in force: campaigns without working privacyPolicyUrl
and termsAndConditionsUrl hard-400 since 2026-06-30. Both are positional
arguments on createUsAppToPerson and "" fails; the URLs must resolve to public
HTTPS pages, because Twilio fetches them during registration.
Toll-free numbers use a separate flow —
MessagingV1TollfreeVerificationApi — not A2P.
Documented and messaging-focused (this recipe):
| Module | For |
|---|---|
Api20100401MessageApi | send / fetch / list / update / delete messages |
Api20100401MediaApi, …MediaInstanceApi | MMS media on a message |
Api20100401IncomingPhoneNumberApi (+ Local/Mobile/TollFree) | numbers you own; delete = release |
Api20100401AvailablePhoneNumberCountryApi | browse numbers to buy |
Api20100401BalanceApi, …AccountApi | account balance and account records |
Api20100401UserDefinedMessageApi (+ Subscription) | user-defined message events |
MessagingV1ServiceApi | Messaging Services (sender pools) |
MessagingV1BrandRegistrationApi (+ Otp, BrandVettingApi) | A2P brand |
MessagingV1UsAppToPersonApi (+ UsecaseApi) | A2P campaigns |
MessagingV1PhoneNumberApi, …ShortCodeApi, …AlphaSenderApi, …ChannelSenderApi | sender pool membership |
MessagingV1TollfreeVerificationApi | toll-free verification |
MessagingV1Linkshortening*, …DomainConfig*, …DomainCertsApi | branded link shortening |
MessagingV1DeactivationsApi | carrier deactivation list |
Not in the package (pruned from the generated surface): calls, recordings, conferences, participants, queues, applications, SIP domains and credentials, usage records and triggers, addresses, keys, tokens, balance transactions. The package ships the messaging surface only — for anything above, this connector is not the path.
throw Error.reject("HTTP <status> body[…]: …")
on 4xx/5xx. diagnostics is on, so the reject text carries Twilio's own error
body (code, message, more_info). Wrap in
try { … } catch (e) { Error.message(e) }.To, 21408 region not permissioned (enable
the destination country's geo permissions in the console), 21608 unverified
recipient on a trial account, 21610 recipient has unsubscribed (STOP),
21703 sender pool exhausted, 21704 the Messaging Service has no numbers,
21714 pool size capped.createMessage returns status = #queued
or #accepted; delivery is asynchronous. Poll fetchMessage for
#delivered / #undelivered / #failed and read error_code, or configure a
statusCallback URL (needs an inbound HTTP endpoint — out of scope here).listMessage
and every other Api20100401* list — return top-level next_page_uri /
previous_page_uri (?Text, and a path such as /2010-04-01/…, not a full
URL). Messaging v1 lists (listService, listPhoneNumber, the A2P registries)
instead nest pagination under meta, as next_page_url / previous_page_url
(full URLs) plus page_size. Only 10 of the 70 list responses use the meta
form; listMessage is not one of them. The meta field is typed
?ListAlphaSenderResponseMeta on every v1 list, including
ListServiceResponse — identical records are deduplicated to one shared module
at codegen time, so the name reflects whichever list sorted first, not the
endpoint you called.pageSize defaults to 50 and caps at 1000. Bound every list call — an unbounded
listMessage on a busy account will blow max_response_bytes.Reading the two shapes:
usecase on createService is Text, not a variant. Valid: notifications,
marketing, verification, discussion, poll, undeclared. Anything else 400s.usAppToPersonUsecase is a different, brand-tier-dependent enum — query
MessagingV1UsAppToPersonUsecaseApi.fetchUsAppToPersonUsecase for what a given
brand may use.?T — pass null to omit them, and prefer that.
The variants are closed: contentRetention #retain/#discard,
addressRetention #retain/#obfuscate, trafficType #free,
scheduleType #fixed, riskCheck #enable/#disable. There is no
#Text escape hatch — a value the spec does not list cannot be expressed.
Passing ?#fixed for scheduleType on an immediate send is a 400:
Twilio reads it as a scheduled message and then finds no SendAt. null is
the correct value for every one of these unless you specifically want the
behaviour.maxPrice is omitted when 0.0, which is what you want. Sending
MaxPrice=0 would cap the message price at zero and make Twilio refuse paid
delivery; omitting it means "no cap". Pass 0.0 to omit.xTwilioApiVersion (on the UsAppToPerson methods) — pass "" unless Twilio
support asks otherwise.stickySender / areaCodeGeomatch are US + Canada only.Config.baseUrl is unused. Every operation carries a hardcoded host
(api.twilio.com for v2010, messaging.twilio.com for v1), pinned at codegen
time from the merged spec. Do not set it and do not expect it to redirect
traffic.Twilio needs no OAuth: the credential is a long-lived pair the admin pastes,
so there is no redirect URI, no /connect/twilio route, and no per-user
handshake. Do not build one. What a Twilio build MUST ship is the page that lets
the admin get and enter the credentials — acceptance criteria, not
suggestions; a build missing them is broken, not merely incomplete:
/settings/twilio from the nav or from the not-configured prompt.A login flow — required. setTwilioCredentials gates on #admin, so the
app needs non-anonymous callers. Take login, useInternetIdentity / useActor
plumbing and the admin-role gate from
extension-authorization.
An admin settings page — /settings/twilio (admin-gated). Required:
AC…) from the dashboard;SK…) and the Secret — the Secret is displayed only once;setTwilioCredentials; clear the secret on
success; keep the form re-submittable, because keys get rotated.setTwilioFromNumber, with an E.164
example (+15551234567) beside it and client-side validation.isTwilioConfigured() (Bool) — "Configured" / "Not
configured". That predicate requires all three values, Key SID
included: it is the Basic-Auth username, so a blank one means every request
is unauthenticated and Twilio answers 20003 while the page claims to be
configured. Never render the secret back, not even masked. The sending
number may be displayed (getTwilioFromNumber); it is not a secret.isCallerAdmin is true and hide it otherwise. Add the link where the nav is
defined, not inside this page.Empty-state nudges. When isTwilioConfigured() is false, never render a
dead "Send" button: admins get a "Set up Twilio" link to /settings/twilio;
non-anonymous non-admins get an explanation — e.g. "Texting isn't set up yet —
an administrator needs to add Twilio credentials in Settings."
Translate Twilio's errors. Failures arrive as rejected calls carrying
Twilio's code. Map at least these to an action rather than showing the raw
reject:
20003 → "The Twilio credentials are wrong — an admin should re-paste them"21211 → "That phone number isn't valid — use the +15551234567 format"21408 → "Texting that country isn't enabled on this Twilio account"21608 → "On a trial account the recipient must be verified in Twilio first"21610 → "That number has replied STOP and cannot be texted"Never promise delivery. A successful call means queued, not delivered.
Word the UI accordingly ("Message queued") and, if delivery matters, show the
polled status from fetchMessage.
Suggested route layout:
The app cannot send anything until a human creates a Twilio account, buys a number and pastes credentials — so the completion message is part of the deliverable, not a summary of it. It MUST contain, in this order:
/settings/twilio, reachable from the nav once signed in.20003 → re-paste credentials; 21211 →
E.164 format; 21408 → enable the destination country; 21608 → verify the
recipient (trial); 21610 → recipient unsubscribed.Do not compress this to "configure Twilio in Settings" and do not substitute a link to Twilio's documentation. Use the same wording here as in the settings-page panel so the two cannot drift.
statusCallback
delivery receipts, need an inbound HTTP endpoint on the canister — a different
component, not this client.mediaUrl takes a public URL Twilio
fetches; the canister cannot POST image bytes through this client.POST …/IncomingPhoneNumbers/{Sid}.json accepts an
AccountSid form field (used to move a number between subaccounts) while
AccountSid is also its path parameter. The generator has a single namespace
for both, so the form copy is dropped and transferring a number to a
subaccount is not reachable through this client. Every other endpoint is
unaffected.application/x-www-form-urlencoded
body with percent-encoded parameters, which is what Twilio requires — but no
call has been made. Treat a first successful send as the real acceptance test.spec-merge (Messaging v1 + API v2010), then pruned to the messaging surface.
Newer Twilio features absent from those specs are absent here.mops add twilio-client@0.1.2 — the generated Twilio REST bindings (35 messaging modules).chat-free quickstart: sending SMS — the Message resource, its fields and statuses.SK….useInternetIdentity / useActor plumbing, and the #admin gate the credential setter needs.*