code/+/trust primary logo full color svg
engineering

How to Add SMS to Your Self-Hosted AI Agent (Twilio + OpenClaw)

Code and TrustJune 9, 2026Updated June 9, 2026

To add two-way SMS to a self-hosted OpenClaw AI agent: create a Twilio Messaging Service, point its inbound webhook at POST /sms/inbound on your gateway, route the Body field through OpenClaw's /agent/turn endpoint with a per-number session ID, and reply with a TwiML <Message> response. For opt-in/STOP compliance — required by CTIA and Twilio's Messaging Policy — maintain a simple opted-in set and handle STOP/START/HELP keywords before routing to the agent. This guide covers every step, including how SMS pairs with the inbound voice setup so your agent handles both channels on the same Twilio number.

Why SMS Alongside Voice

Voice calls are synchronous and interruptive — the caller has to be available right now. SMS is asynchronous: the agent can handle a text conversation across minutes or hours, and the user engages on their own schedule. The two channels complement each other:

  • Handoff from voice to SMS. After a call ends, your agent can send a summary, a calendar invite link, or a follow-up question via SMS — continuing the conversation without requiring another call.
  • Pre-screening via SMS. A potential caller texts first ("Can I schedule a call?"), the agent qualifies the request via SMS, books the slot, and only escalates to a voice call when needed.
  • Async task status. The agent handles a background task (web search, database query, sending an email) and texts the result when it's ready — no need to stay on the line.
  • Notification and confirmation flows. Calendar reminders, order confirmations, 2FA codes — SMS is the right channel for short, high-read-rate messages.

Because both voice and SMS use Twilio on the same phone number, the plumbing investment from the voice setup guide carries over directly. You need one Twilio account, one phone number, and one OpenClaw gateway — the SMS webhook is a second endpoint on the same server.

Note on unified tooling: clawcall now handles both inbound voice and SMS as an open-source MIT reference implementation — the same allowlist and gateway-turn model applies to POST /twilio/sms as to voice streams. If you are already deploying clawcall for voice (see the inbound voice guide), SMS support is an additional route on the same server, not a separate tool.

Prerequisites

  • A running OpenClaw gateway with a public HTTPS URL (same as the voice setup)
  • A Twilio account with a phone number that supports SMS (most US numbers do by default)
  • Node.js 18+ and the twilio npm package for signature validation
  • Optional but recommended: clawcall (MIT) — the open-source reference implementation that handles both inbound voice and inbound SMS via the same allowlist + gateway-turn model, so you can run a single process for both channels

Step 1 — Configure the Twilio Messaging Service

Twilio Messaging Services (rather than direct number configuration) give you sender pooling, opt-out management, and better deliverability. Create one in the Twilio Console:

  1. Go to Messaging → Services → Create Messaging Service.
  2. Name it (e.g., "OpenClaw Agent SMS").
  3. Under Integration, set the inbound request URL to: https://your-gateway-domain.com/sms/inbound (POST).
  4. Set the fallback URL to the same endpoint (so retries land correctly).
  5. Add your phone number to the Sender Pool.

The Messaging Service handles STOP/START/HELP keyword responses at the Twilio layer automatically — a user texting STOP is unsubscribed from the Messaging Service before the request reaches your webhook. You still need to track opt-out state in your own database (see Step 4) to avoid accidentally re-texting an opted-out number through a different path.

Step 2 — Write the Inbound SMS Webhook Handler

Twilio POSTs to your webhook with the message body, sender number, and your number. The handler validates the Twilio signature, checks opt-in state, routes the message to OpenClaw, and returns a TwiML reply.

// sms-handler.js
// Handles inbound SMS from Twilio — validates signature, enforces opt-in,
// routes to OpenClaw gateway /agent/turn, returns TwiML <Message> reply.

import express from 'express'
import twilio from 'twilio'
import fetch from 'node-fetch'

const router = express.Router()

const OPENCLAW_GATEWAY = process.env.OPENCLAW_GATEWAY_URL || 'http://localhost:3000'
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN

// In-memory opt-in store — replace with a DB table in production
// Schema: { [e164Number: string]: boolean }
const optedIn = new Map()

// Compliance keywords (CTIA requirement — handle before routing to agent)
const STOP_KEYWORDS  = new Set(['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT'])
const START_KEYWORDS = new Set(['START', 'UNSTOP', 'YES'])
const HELP_KEYWORDS  = new Set(['HELP', 'INFO'])

router.post('/sms/inbound', express.urlencoded({ extended: false }), async (req, res) => {
  // 1. Validate Twilio signature — prevents spoofed webhook requests
  const twilioSignature = req.headers['x-twilio-signature']
  const webhookUrl = `${process.env.PUBLIC_BASE_URL}/sms/inbound`
  const isValid = twilio.validateRequest(
    TWILIO_AUTH_TOKEN,
    twilioSignature,
    webhookUrl,
    req.body
  )
  if (!isValid) {
    return res.status(403).send('Forbidden')
  }

  const from = req.body.From     // caller's E.164 number, e.g. "+18432965626"
  const body = (req.body.Body || '').trim()
  const keyword = body.toUpperCase()

  const twiml = new twilio.twiml.MessagingResponse()

  // 2. CTIA compliance — handle opt-out/in keywords FIRST, before agent routing
  if (STOP_KEYWORDS.has(keyword)) {
    optedIn.set(from, false)
    // Twilio Messaging Service sends the STOP confirmation automatically;
    // returning an empty TwiML avoids a double reply.
    return res.type('text/xml').send(twiml.toString())
  }

  if (START_KEYWORDS.has(keyword)) {
    optedIn.set(from, true)
    twiml.message('You\'re now subscribed. Reply STOP to unsubscribe at any time.')
    return res.type('text/xml').send(twiml.toString())
  }

  if (HELP_KEYWORDS.has(keyword)) {
    twiml.message('Reply STOP to unsubscribe or START to re-subscribe. Msg & data rates may apply.')
    return res.type('text/xml').send(twiml.toString())
  }

  // 3. Block opted-out numbers
  if (optedIn.get(from) === false) {
    // Do not reply — sending to opted-out numbers violates CTIA and Twilio policy
    return res.type('text/xml').send(twiml.toString())
  }

  // 4. Route to OpenClaw gateway agent turn
  // sessionId scoped per phone number so the agent maintains conversation context
  try {
    const agentRes = await fetch(`${OPENCLAW_GATEWAY}/agent/turn`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        message: body,
        sessionId: `sms-${from}`,   // persistent session per sender number
        channel: 'sms',
        metadata: { from, to: req.body.To },
      }),
    })
    const data = await agentRes.json()
    const reply = data.response || 'Sorry, I couldn\'t process that request.'

    // 5. Reply via TwiML — Twilio sends this as an outbound SMS from your number
    // Truncate to 1600 chars (10 SMS segments) — agent replies should be concise
    twiml.message(reply.slice(0, 1600))
  } catch (err) {
    console.error('OpenClaw agent turn failed:', err)
    twiml.message('Sorry, something went wrong. Please try again.')
  }

  res.type('text/xml').send(twiml.toString())
})

export default router

Register this router on your Express server alongside the voice endpoints:

import smsRouter from './sms-handler.js'
app.use(smsRouter)

Step 3 — Opt-In Collection (Required for Messaging Compliance)

CTIA guidelines and Twilio's Messaging Policy require that you obtain explicit opt-in consent before sending marketing or conversational messages to a number. The opt-in mechanism depends on your use case:

  • Double opt-in via web form. User submits their phone number on your site; your server sends a confirmation text ("Reply YES to receive messages from [Agent]"); user replies YES; your webhook handler marks them opted-in. This is the safest compliance posture.
  • Single opt-in via first inbound text. Treat any inbound text as implied consent (the user initiated contact). Your first reply must include opt-out instructions: "Reply STOP to unsubscribe. Msg & data rates may apply." This is acceptable for conversational agents but not for promotional messages.
  • Voice-to-SMS handoff. During a voice call (from the inbound voice guide), the agent says "Would you like me to send you a text summary? Reply YES to confirm." The caller's affirmative response during the call constitutes opt-in. Log the call SID and timestamp as your consent record.

Store opt-in records in a database, not in-memory. The in-memory map in Step 2 is for illustration — in production, use a table:

-- Postgres table for SMS opt-in state
CREATE TABLE sms_opt_in (
  phone_e164   text        PRIMARY KEY,
  opted_in     boolean     NOT NULL DEFAULT true,
  consented_at timestamptz NOT NULL DEFAULT now(),
  source       text,        -- 'web-form' | 'inbound-text' | 'voice-handoff'
  updated_at   timestamptz NOT NULL DEFAULT now()
);

-- Check opt-in before sending
SELECT opted_in FROM sms_opt_in WHERE phone_e164 = $1;

Step 4 — Sending Outbound SMS from the Agent

To let the agent initiate outbound texts (not just reply to inbound), register a send-sms tool in OpenClaw:

// openclaw-tools/send-sms.js
// Tool that lets the OpenClaw agent send an outbound SMS via Twilio.
// Register in openclaw.json under tools[].

import twilio from 'twilio'

const client = twilio(
  process.env.TWILIO_ACCOUNT_SID,
  process.env.TWILIO_AUTH_TOKEN
)

export async function sendSms({ to, body }) {
  // Always check opt-in before sending
  const optIn = await db.query(
    'SELECT opted_in FROM sms_opt_in WHERE phone_e164 = $1', [to]
  )
  if (!optIn.rows[0]?.opted_in) {
    return { error: 'Number is not opted in — cannot send.' }
  }

  const msg = await client.messages.create({
    from: process.env.TWILIO_PHONE_NUMBER,
    to,
    body: body.slice(0, 1600),
  })
  return { sid: msg.sid, status: msg.status }
}

Register this tool in your openclaw.json tools array so the agent can invoke it during any session — chat, voice via the consult tool, or cron-triggered workflows. This is the pattern that makes the voice guide's handoff scenario possible: the agent ends a call and immediately fires send-sms with a summary.

Step 5 — Pairing SMS with Voice on the Same Number

A single Twilio phone number can handle both inbound calls and inbound SMS simultaneously. Twilio routes calls to the Voice webhook and texts to the Messaging Service webhook — they're separate HTTP endpoints on your server. The only configuration requirement is that both webhooks point to your publicly accessible gateway domain.

Session management across channels is the subtle part: a voice session and an SMS session from the same phone number should share context if the conversations are related. The simplest approach: use the caller's E.164 number as the top-level session key, with a channel suffix to disambiguate:

// Voice session ID (from the voice guide):
sessionId: `voice-${callSid}`   // scoped to the specific call

// SMS session ID (from this guide):
sessionId: `sms-${from}`        // persistent across all texts from this number

// Cross-channel session (optional — share context between voice and SMS):
sessionId: `agent-${normalizeE164(from)}`  // shared by both channels

If you use the cross-channel session ID, a caller who spoke with the agent last week and now texts will get a contextual reply based on the conversation history. This is the "persistent agent" use case that makes the combination of voice + SMS qualitatively more capable than either channel alone.

Opt-In/STOP Compliance Checklist

CTIA guidelines and Twilio's Messaging Policy require specific behavior. Use this checklist before going live:

  • STOP keyword (and synonyms: STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) → unsubscribe immediately, no reply required (Twilio Messaging Service handles the confirmation)
  • START keyword (and synonyms: UNSTOP, YES) → re-subscribe, confirm in reply
  • HELP keyword → respond with contact info and opt-out instructions
  • First outbound message to any number → include "Reply STOP to unsubscribe. Msg & data rates may apply."
  • Store opt-in consent with timestamp and source — not just the boolean
  • Never send to a number after they've texted STOP, even if they opted in via a different channel
  • Register your use case with Twilio's Campaign Registry (10DLC) if sending to US numbers at volume — required since 2021 for non-toll-free numbers

10DLC Registration (US Carriers Require This)

If you're sending SMS from a standard US local number (not a toll-free number) at any meaningful volume, you need 10DLC (10-Digit Long Code) registration. Since 2021, US carriers have blocked unregistered application-to-person (A2P) messaging on local numbers. Unregistered messages are filtered at up to 90% rates.

The registration process runs through Twilio's Trust Hub:

  1. Register your brand (business name, EIN, address, vertical)
  2. Create a Campaign (use case description, sample messages, opt-in/opt-out flows)
  3. Associate your phone number with the Campaign

Brand registration is a one-time fee (~$4). Campaign registration is ongoing (~$10/campaign/month). Approval takes 1–5 business days for standard campaigns. This is not optional for US A2P messaging — build it into your timeline.

Frequently Asked Questions

The most common questions about adding SMS to a self-hosted AI agent cover compliance requirements, session management across SMS and voice, handling long agent replies, rate limits, and international messaging.

Q1: Does my agent need to handle STOP even if it never sends first?

A: Yes — any agent that replies to inbound texts is an A2P sender under CTIA guidelines. The requirement applies regardless of who initiates the conversation. STOP handling is mandatory even for purely conversational (non-marketing) agents. The good news: Twilio Messaging Service handles STOP at the network level automatically when configured correctly; your webhook just needs to respect the opt-out state in your database.

Q2: How do I handle agent replies that are longer than one SMS segment (160 chars)?

A: Twilio concatenates multi-segment SMS automatically — messages up to 1,600 characters are sent as up to 10 segments and reassembled on the recipient's device. The per-segment rate applies ($0.0079/segment for US). Prompt your agent to be concise for SMS: "Reply in under 160 characters when possible. Use plain text — no markdown." For truly long responses (e.g., a step-by-step guide), have the agent offer to email instead: "That answer is long — want me to email it to you?"

Q3: Can the same OpenClaw session handle both voice and SMS?

A: Yes — use the caller's E.164 number as the shared session key across channels (see Step 5). The agent will remember context from previous voice calls when the same person texts. This is the cross-channel session pattern. One caveat: realtime voice sessions (via the native plugin) have their own session context that does not automatically merge with the gateway chat session. For full cross-channel memory, route both voice (via clawcall) and SMS through the same /agent/turn endpoint with a shared session ID.

Q4: What rate limits does Twilio impose on outbound SMS?

A: Standard US local numbers have a throughput limit of 1 message per second (MPS) per number. For higher volume, use a Twilio Messaging Service with a sender pool (multiple numbers) or upgrade to a toll-free number (~10 MPS) or short code (~100 MPS). The OpenClaw send-sms tool should include a rate limiter or queue for any use case that might fan out to many recipients simultaneously.

Q5: How do I test the SMS webhook without a real Twilio number?

A: Use ngrok http 3000 to expose your local gateway, then send test requests directly to your /sms/inbound endpoint via curl, mimicking Twilio's POST body:

curl -X POST https://your-ngrok-url.ngrok.io/sms/inbound \
  -d "From=%2B18432965626" \
  -d "To=%2B18438846222" \
  -d "Body=Hello%2C+agent" \
  -d "MessageSid=SM_test_123"

Note that without a real Twilio auth token, signature validation will fail — set SKIP_TWILIO_VALIDATION=true in your local .env for testing, and never disable it in production.

Q6: Can I use this SMS setup with Vapi or Retell instead of OpenClaw?

A: Vapi and Retell are voice-only platforms — they do not handle SMS. For SMS, you're always writing your own webhook handler (as in Step 2) regardless of your voice platform. The OpenClaw routing in Step 2 can be replaced with any LLM API call; the compliance, session management, and Twilio integration logic is platform-agnostic. For a comparison of voice platform options, see Giving Your AI Agent a Phone Number: Twilio vs Vapi vs Retell vs Self-Hosted.

Build the Full Voice + SMS Stack with Code and Trust

Combining inbound voice and two-way SMS into a single agent creates a qualitatively different capability from either channel alone: the agent can switch channels mid-conversation, hand off from synchronous to async, and build persistent context across every interaction. The engineering investment is real — but once the stack is in place, adding new capabilities (outbound calls, MMS, WhatsApp via Twilio) is incremental.

Code and Trust's AI implementation practice covers the full voice + SMS stack: architecture review, webhook implementation, compliance setup, and production deployment. If you want to start with an assessment of what voice and SMS automation would unlock for your specific workflows, the AI Audit is the right entry point — we map your communication flows against current agent capabilities and tell you exactly where the leverage is.

For the voice side of this setup, see How to Give Your Self-Hosted AI Agent Inbound Phone Calls (OpenClaw + Twilio). For the full platform comparison, see Giving Your AI Agent a Phone Number: Twilio vs Vapi vs Retell vs Self-Hosted.

Ready to implement AI in your business?

We'll map every manual workflow against current AI capabilities and show you exactly where your 30–60% cost reduction is hiding. No pitch, no fluff.