SMS agent quickstart

This is the shortest path from zero to a sent message over MCP. There are two compliance paths; toll-free is the fastest and the recommended default (one form, no fees, no brand/campaign). Pick 10DLC only when you specifically need local numbers or higher throughput.

Whatever path you take, get_sms_setup_status is the source of truth. After every step, call it: it returns the unified state, the reason for any hold, the concrete fix, the exact next tool to call, and canSend (whether production sends are allowed yet). Poll it while approvals are pending — don’t guess from individual objects.

All SMS tools return sms_unavailable when Telnyx isn’t configured on the platform. Role gates match the REST surface: buying numbers and compliance submissions need manage (owner/admin); wiring and sending need developer+.

Path A — Toll-free (fastest, free)

purchase_phone_number(numberType: "toll_free")
  → submit_tollfree_verification
  → poll get_sms_setup_status  until canSend == true
  → send_sms
  1. Buy the number.

    { "name": "purchase_phone_number",
      "arguments": { "e164": "+18445550123", "numberType": "toll_free" } }
    

    If the cost is over your approval threshold (or you’re not an owner), you get an approvalUrl instead — surface it and wait for approval, then the purchase completes.

  2. Submit the verification. One form: business + contact details, the numbers to verify, use case, sample/production message content, the opt-in workflow plus at least one opt-in evidence URL (screenshots must show consent checkboxes UNCHECKED by default), and message volume. Required carrier columns the tool validates up front: entityType (legal entity type), the businessRegistrationNumber/Type/Country triplet (EIN + "US" for a US business), and privacyPolicyUrl + termsAndConditionsUrl — these are DEDICATED fields; describing them in additionalInformation does not populate them. Full state name ("California", not "CA") and a non-empty additionalInformation are required quirks — the tool validates locally and returns errors if anything is missing, so fix and resubmit for free.

    { "name": "submit_tollfree_verification",
      "arguments": { "phoneNumbers": ["+18445550123"], "businessName": "…",
        "entityType": "PRIVATE_PROFIT", "businessRegistrationNumber": "12-3456789",
        "businessRegistrationType": "EIN", "businessRegistrationCountry": "US",
        "privacyPolicyUrl": "https://…/privacy", "termsAndConditionsUrl": "https://…/terms",
        "useCase": "…", "optInWorkflowImageUrls": ["https://…/optin.png"],
        "messageVolume": "…", "additionalInformation": "…" } }
    
  3. Poll status. get_sms_setup_status(numberId) moves pending_review → approved. Typical turnaround ≈5 business days. Resubmission is free and unlimited (except a terminal “High Risk - Fraud” rejection).

  4. Send. Once canSend is true, send_sms. Before then, allowPending: true gets a send accepted but not delivered — carriers reject pre-approval toll-free traffic (Telnyx 40329) and the message finalizes delivery_failed. Use it to smoke-test your wiring, never to reach a real recipient. And sent is not proof of delivery: poll list_sms_messages for the final status + errorCode.

Path B — 10DLC local

register_sms_brand           ← get_sms_brand reads it back (brandId)
  → UNVERIFIED? read failureReasons → update_sms_brand (fix in place + re-vet)
  → (sole-prop only) confirm_sms_brand_otp   (resend_sms_brand_otp for a new PIN)
  → create_sms_campaign        ← $15 per submission — validate first; list_sms_campaigns first if one may exist
      (approval-gated: poll get_approval(approvalId) → completion.result.campaignId)
  → assign_number_to_campaign  ← campaignId from list_sms_campaigns / get_sms_setup_status
  → poll get_sms_setup_status  until canSend == true
  → send_sms
  1. Register the brand (once per org — the org’s legal identity).

    • Standard: entityType (LLCs and corporations are PRIVATE_PROFIT) + companyName and ein exactly as printed on the IRS CP-575 / 147C letter (punctuation and LLC/Inc. count — this is not your display name) + the address the IRS has + email, phone, vertical. TCR verifies within minutes.
    • Sole proprietor: entityType: "SOLE_PROPRIETOR", no EIN — firstName/lastName + US mobilePhone + address. The PIN is texted automatically (otpRequired: true, expires in 24 h). Caps: 1 campaign, 1 number, ~1,000 msgs/day.
    • Formatting is normalized for you (EIN dashes, “utah” → UT, phone formats). Every problem comes back at once as field-named errors; warnings flag likely TCR trouble (e.g. a legal name with no LLC/Inc.).
    { "name": "register_sms_brand",
      "arguments": { "entityType": "PRIVATE_PROFIT", "displayName": "Acme",
        "companyName": "Acme Widgets, LLC", "ein": "12-3456789",
        "email": "ops@acme.com", "phone": "+18015550123", "website": "https://acme.com",
        "vertical": "TECHNOLOGY", "street": "1 Main St", "street2": "Suite 4",
        "city": "Vernal", "state": "UT", "postalCode": "84078" } }
    
    • If the brand comes back UNVERIFIED, get_sms_brand / get_sms_setup_status carry failureReasons — TCR’s own words and the exact fields (e.g. TAX_ID → ein, companyName, entityType) — plus submittedFields showing what was compared. Correct only those fields with update_sms_brand (confirm: true): the brand is fixed in place and re-vetted. Do not delete and re-register. Telnyx allows one re-vet after registration and then one per 3 months (revetAvailableAt).
  2. Confirm the OTP (sole-prop only):

    { "name": "confirm_sms_brand_otp", "arguments": { "brandId": "…", "pin": "123456" } }
    

    PIN expired or never arrived? resend_sms_brand_otp(brandId). Lost the brandId? get_sms_brand returns it as a top-level brandId (and again as brand.id); so does get_sms_setup_status on any number, under the same brandId key — never ask an operator to look it up. If Telnyx can’t automate this, the status degrades to action_required with instructions rather than blocking — check get_sms_setup_status.

  3. Create the campaign — mind the $15. Each create_sms_campaign submission (and every resubmission) costs $15. The tool validates the whole payload locally first and returns errors with no charge if anything fails TCR rules — never spend money on a payload you haven’t cleared. Over your approval threshold it returns an approvalId + approvalUrl; the charge happens on approval. Then poll get_approval(approvalId): status moves to approved, and completion.result.campaignId is the id assign_number_to_campaign needs — or completion.status: "failed" tells you nothing was created (re-run create_sms_campaign; it requests a fresh approval, never a dead one). Lost track of a campaign? list_sms_campaigns lists them with campaignId and assignedNumberIds, and get_sms_setup_status on a number whose brand already has a live campaign says assign_number_to_campaign with that campaignId instead of steering you into another $15 submission.

    { "name": "create_sms_campaign",
      "arguments": { "brandId": "…", "usecase": "ACCOUNT_NOTIFICATION",
        "description": "Acme sends billing and account notices to customers who opted in at signup.",
        "messageFlow": "Customers tick an unchecked consent box next to their mobile number at https://acme.com/signup.",
        "sampleMessages": ["Acme: your invoice is ready. Reply STOP to opt out.",
                           "Acme: new sign-in on your account. Reply HELP for help."],
        "privacyPolicyLink": "https://acme.com/privacy", "dryRun": true } }
    

    What carriers check first — and what the tool now always sends — are the subscriber opt-in / opt-out / help attestations plus the opt-in, STOP and HELP reply messages; leave the replies out and they are generated from the brand (name + contact). Rules enforced locally: 2–5 distinct samples, at least one naming the brand and one with opt-out language; subUsecases for MIXED (2–5) and LOW_VOLUME (1–5); SOLE_PROPRIETOR only with a sole-proprietor brand. Run with dryRun: true to see the resolved campaign and warnings free, fix any errors, then drop dryRun to submit.

    • Rejected? list_sms_campaigns / get_sms_setup_status carry failureReasons and providerStatus. TCR_FAILED → fix the named fields and submit a new campaign ($15). TELNYX_FAILED / MNO_REJECTED → appeal_sms_campaign (optionally with replacement samples) — no new submission.
  4. Assign the number. assign_number_to_campaign(campaignId, numberId) — enforces the 49-numbers-per-campaign T-Mobile cap.

  5. Poll status. get_sms_setup_status walks TCR review → per-carrier MNO review (T-Mobile ≤24 h; AT&T/Verizon 1–3 business days).

  6. Send once canSend is true.

Wire it into an app

Once a number can send, attach it to a service to inject the SMS_API_* env vars and let the app send/receive on its own:

{ "name": "attach_phone_number_to_service", "arguments": { "numberId": "…", "serviceId": "…" } }
{ "name": "set_sms_inbound_webhook", "arguments": { "numberId": "…", "url": "https://app.example.com/sms/inbound" } }

Need the injected values for a local .env or a runtime outside Tandem? reveal_sms_credentials(numberId) reads back SMS_API_URL / SMS_API_KEY / SMS_PHONE_NUMBER / SMS_WEBHOOK_SECRET per attached service. list_env_vars only previews them, and the attach response shows the key once.

The inbound URL is inbound-only. Pass optional statusUrl on the same tool to subscribe to terminal outbound delivery status (sms.status); otherwise poll list_sms_messages for a message’s terminal status + errorCode.

See the SMS & phone numbers guide for the SMS_* env contract, the send API, and inbound signature verification.

Cheat sheet

  • Start and re-check with get_sms_setup_status — it names the next tool, carries the brandId on the 10DLC path, and tells you canSend.
  • Toll-free is fastest and free. Prefer it unless you need local presence / higher throughput.
  • 10DLC campaigns cost $15 per submission. The tool validates locally first — never submit a payload with errors; use dryRun: true to preview.
  • Rejections are fixable in place: brand → update_sms_brand, campaign TELNYX_FAILED/MNO_REJECTED → appeal_sms_campaign. The reasons are always in failureReasons.
  • Sends are blocked until compliance is approved; the send error carries the setupStatus so you know why. allowPending bypasses the gate, not the carriers.
  • You never need platform staff to read a value to you. get_sms_brand for the brand id, reveal_sms_credentials for the injected SMS_* values.