Loading...
Loading...
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 skill4agent add caffeinelabs/skills connector-twilio⚠️ Experimental () — 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.twilio-client@0.1.2
Scope — the package is the messaging surface only.is 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.)twilio-client
ic.http_request*.twilio.com| User intent | Capability |
|---|---|
| Send an SMS | |
| Send an MMS (image) | same, with |
| Send via a Messaging Service (recommended for US traffic) | same, |
| Check delivery status | |
| List / search sent messages | |
| Own or browse phone numbers | |
| Set up a Messaging Service | |
| Register for US A2P 10DLC | |
| Verify a toll-free number | |
#basicAuth { user; password }| Flavour | | When |
|---|---|---|
| API Key (default — prefer this) | API Key SID ( | Production. Revocable and scoped: leaking one does not surrender the account. |
| Account SID + Auth Token | Account SID ( | Dev only. The Auth Token is the account — it can create sub-accounts, buy numbers, and spend money. |
AC…AC…SK…21608AccessControl.hasPermission(state, caller, #admin)⚠️ 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 thecheck and can overwrite the credential — and this one spends money.caller == owner
config.auth = ?#basicAuth { user; password }Authorization: Basic …defaultConfigis_replicated = ?false⚠️ Do not set it toor?true, 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 uniquenull(so the responses never agree byte-for-byte). This is the same defect that produced ~13 duplicate emails via the Gmail connector and drovesid0.1.0.slack-client
fetch*list*mops add twilio-client@0.1.2
mops add caffeineai-authorization@1.0.1configasync*module classconfigasync// Illustrative sketch, not a file to copy: `cfg`/`accountSid` are assumed to
// exist and the argument lists are elided. Marked motoko-check:skip for that
// reason — the compiled examples are the three mixins below.
import MessageApi "mo:twilio-client/Apis/Api20100401MessageApi";
// free function — config passed explicitly
let m = await* MessageApi.createMessage(cfg, accountSid, /* … */);
// class facade — config captured once
let messages = MessageApi.Api20100401MessageApi(cfg);
let m2 = await messages.createMessage(accountSid, /* … */);createMessage""false00.0[]null?Tnullconfig, 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, contentSidimport AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinTwilioConfig "mixins/twilio-config";
import MixinTwilioMessaging "mixins/twilio-messaging";
actor {
let accessControlState = AccessControl.initState();
include MixinAuthorization(accessControlState, null);
// Admin-held Twilio credentials — never returned to the frontend.
let twilioConfig = {
var accountSid : Text = ""; // AC… — also a positional arg on every v2010 call
var keySid : Text = ""; // SK… (or the Account SID again, in dev)
var keySecret : Text = ""; // the API-key secret (or the Auth Token, in dev)
var fromNumber : Text = ""; // E.164, e.g. "+15551234567"
};
include MixinTwilioConfig(accessControlState, twilioConfig);
include MixinTwilioMessaging(twilioConfig);
};import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";
mixin (
accessControlState : AccessControl.AccessControlState,
twilioConfig : {
var accountSid : Text;
var keySid : Text;
var keySecret : Text;
var fromNumber : Text;
},
) {
// All THREE are required, and this must agree with the guard in
// twilio-messaging.mo: `keySid` is the Basic-Auth *username*, so a blank one
// means every request goes out unauthenticated and Twilio answers 20003 —
// while the UI cheerfully reports "Configured".
public query func isTwilioConfigured() : async Bool {
twilioConfig.accountSid.size() > 0 and twilioConfig.keySid.size() > 0 and twilioConfig.keySecret.size() > 0;
};
// The sending number is not a secret — the UI may display it.
public query func getTwilioFromNumber() : async Text {
twilioConfig.fromNumber;
};
// Admin-only. NOTE `#admin` — never a first-caller-claims-ownership check,
// which the shared anonymous principal would defeat.
public shared ({ caller }) func setTwilioCredentials(
accountSid : Text,
keySid : Text,
keySecret : Text,
) : async () {
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
Runtime.trap("Unauthorized: Only admins can set Twilio credentials");
};
twilioConfig.accountSid := accountSid;
twilioConfig.keySid := keySid;
twilioConfig.keySecret := keySecret;
};
public shared ({ caller }) func setTwilioFromNumber(number : Text) : async () {
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
Runtime.trap("Unauthorized: Only admins can set the sending number");
};
twilioConfig.fromNumber := number;
};
};import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import { createMessage } "mo:twilio-client/Apis/Api20100401MessageApi";
import { defaultConfig; type Config } "mo:twilio-client/Config";
mixin (
twilioConfig : {
var accountSid : Text;
var keySid : Text;
var keySecret : Text;
var fromNumber : Text;
},
) {
// Credentials ride config.auth; defaultConfig is already non-replicated.
func twilioClientConfig() : Config {
{
defaultConfig with
auth = ?#basicAuth { user = twilioConfig.keySid; password = twilioConfig.keySecret };
max_response_bytes = ?(200_000 : Nat64);
};
};
/// Send an SMS to `to` (E.164). Returns the message SID.
public shared ({ caller }) func sendSms(to : Text, body : Text) : async Text {
if (caller.isAnonymous()) Runtime.trap("Sign in to send messages");
// Same three-way check as isTwilioConfigured(): accountSid goes in the URL
// path, keySid is the Basic-Auth user, keySecret the password. Missing any
// one of them fails at Twilio, not here, so check before spending cycles.
if (
twilioConfig.accountSid.size() == 0 or twilioConfig.keySid.size() == 0 or twilioConfig.keySecret.size() == 0
) {
Runtime.trap("Twilio is not configured (an admin must set all three credentials)");
};
let msg = await* createMessage(
twilioClientConfig(),
twilioConfig.accountSid, // accountSid — in the URL path, not the credential
to, // to (E.164)
"", "", // statusCallback, applicationSid
0.0, // maxPrice (0 = no cap)
false, // provideFeedback
0, 0, // attempt, validityPeriod
false, // forceDelivery
null, null, // contentRetention, addressRetention (omitted)
false, // smartEncoded
[], // persistentAction
null, // trafficType (omitted)
false, // shortenUrls
null, // scheduleType — MUST be null for an immediate send
"", // sendAt (scheduled sends only)
false, // sendAsMms
"", // contentVariables
null, // riskCheck (omitted)
twilioConfig.fromNumber, // from (use EITHER from OR messagingServiceSid)
"", // fallbackFrom
"", // messagingServiceSid
body, // body
[], // mediaUrl (set for MMS)
"", // contentSid (Content API templates)
);
// `sid` is optional in the generated model because the spec marks it
// nullable, though Twilio always sets it on a successful create. Fall back
// to "" rather than trapping: the outcall has already happened, so a trap
// would roll back this canister's own state while the SMS stays delivered.
switch (msg.sid) { case (?sid) sid; case null "" };
};
};mediaUrl = ["https://example.com/image.jpg"]sendAsMms = truefrom = ""messagingServiceSidto+"+15551234567""555-1234"21211frommessagingServiceSidfromApi20100401AvailablePhoneNumberCountryApiMessagingV1BrandRegistrationApi.createBrandRegistrationscustomerProfileBundleSida2PProfileBundleSidmock = truePENDINGAPPROVEDFAILEDMessagingV1UsAppToPersonApi.createUsAppToPersonmessageFlowmessageSamplesusAppToPersonUsecaseMessagingV1PhoneNumberApi.createPhoneNumber(cfg, serviceSid, phoneNumberSid)deletePhoneNumberprivacyPolicyUrltermsAndConditionsUrlcreateUsAppToPerson""MessagingV1TollfreeVerificationApi| Module | For |
|---|---|
| send / fetch / list / update / delete messages |
| MMS media on a message |
| numbers you own; delete = release |
| browse numbers to buy |
| account balance and account records |
| user-defined message events |
| Messaging Services (sender pools) |
| A2P brand |
| A2P campaigns |
| sender pool membership |
| toll-free verification |
| branded link shortening |
| carrier deactivation list |
throw Error.reject("HTTP <status> body[…]: …")diagnosticscodemessagemore_infotry { … } catch (e) { Error.message(e) }TocreateMessagestatus = #queued#acceptedfetchMessage#delivered#undelivered#failederror_codestatusCallbacklistMessageApi20100401*next_page_uriprevious_page_uri?Text/2010-04-01/…listServicelistPhoneNumbermetanext_page_urlprevious_page_urlpage_sizemetalistMessagemeta?ListAlphaSenderResponseMetaListServiceResponsepageSizelistMessagemax_response_bytes// Illustrative sketch, not a file to copy — `res` is assumed to be the decoded
// list response. Marked motoko-check:skip for that reason.
// v2010 (listMessage and every other Api20100401* list): top-level, a path
switch (res.next_page_uri) { case (?path) { /* fetch the next page */ }; case null {} };
// Messaging v1 (listService, listPhoneNumber, the A2P registries): nested, a full URL
switch (res.meta) { case (?m) { m.next_page_url }; case null null };usecasecreateServiceTextnotificationsmarketingverificationdiscussionpollundeclaredusAppToPersonUsecaseMessagingV1UsAppToPersonUsecaseApi.fetchUsAppToPersonUsecase?TnullcontentRetention#retain#discardaddressRetention#retain#obfuscatetrafficType#freescheduleType#fixedriskCheck#enable#disable#Text?#fixedscheduleTypeSendAtnullmaxPrice0.0MaxPrice=00.0xTwilioApiVersionUsAppToPerson""stickySenderareaCodeGeomatchConfig.baseUrlapi.twilio.commessaging.twilio.com/connect/twilio/settings/twiliosetTwilioCredentials#adminuseInternetIdentityuseActorextension-authorization/settings/twilioAC…SK…setTwilioCredentialssetTwilioFromNumber+15551234567isTwilioConfigured()Bool20003getTwilioFromNumberisCallerAdminisTwilioConfigured()false/settings/twiliocode2000321211214082160821610statusfetchMessage/ → Main UI (any signed-in user; empty-state when unconfigured)
/settings/twilio → Admin credentials + sending number (admin-only)
# No /connect/twilio: Twilio uses pasted long-lived credentials, not a redirect flow./settings/twilio2000321211214082160821610statusCallbackmediaUrlPOST …/IncomingPhoneNumbers/{Sid}.jsonAccountSidAccountSidapplication/x-www-form-urlencodedspec-mergemops add twilio-client@0.1.2chatMessageSK…useInternetIdentityuseActor#admin