Agent SMS Guide

SMS for AI Agents: Send, Read Replies, Take Action

This guide turns any AI agent into something that texts real people and handles what they text back. It is three API calls: one to send, one to read, one to reply. It works the same from Hermes, OpenClaw, LangChain, a cron script, or Claude through the MCP server. If your agent can make an HTTP request, it can run a phone number.

The Pattern: Agent-Initiated Texting for AI Agents

Most SMS docs assume a human is typing. Agents are different: they run on schedules, they retry, and they need to read the reply hours later without a browser session. The shape that works is:

1
Your agent decides to reach out
A cron sweep finds overdue invoices, an appointment needs confirming, a lead went quiet.
2
It sends a text from its own number
POST /v1/sms/send with an idempotency key so a retried job never double-texts a customer.
3
The reply lands on the same number
Your agent reads it by polling the inbox (zero infrastructure) or receives it as a signed webhook push (relay mode).
4
The agent acts on it
Writes the promise date to a sheet, marks the invoice, schedules a follow-up, or replies in-thread.

Prerequisites: an API key from the dashboard and a provisioned US local number. Every request below authenticates with Authorization: Bearer ac_live_...

1. Send a Text from Your Agent

One call. from takes the number ID or the E.164 string of a number you own, body is 1 to 1600 characters.

Send an SMS
curl -X POST https://api.agentcall.co/v1/sms/send \
  -H "Authorization: Bearer ac_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+12702468123",
    "to": "+13145550142",
    "body": "Hi Dana, invoice 1042 for the Maple St repaint, $4,200, due Friday. Pay here: https://buy.stripe.com/inv1042",
    "idempotencyKey": "inv-1042-reminder-1"
  }'
Response (201)
{
  "id": "msg_cmq47obtq004x",
  "from": "+12702468123",
  "to": "+13145550142",
  "body": "Hi Dana, invoice 1042 ...",
  "status": "queued",
  "cost": 0.015,
  "createdAt": "2026-08-09T14:02:11.000Z"
}

Confirm it actually landed. The 201 means the carrier accepted the message, not that a handset showed it. Read the message back a moment later for the real outcome:

Check delivery
curl https://api.agentcall.co/v1/sms/msg_cmq47obtq004x \
  -H "Authorization: Bearer ac_live_YOUR_API_KEY"

# { "status": "delivered", ... }
#
# status: queued -> sent -> delivered | failed
#   queued     handed to the carrier, no receipt yet
#   sent       carrier accepted it, handset delivery unconfirmed
#   delivered  confirmed on the recipient's handset
#   failed     carrier rejected it; see errorCode (40010 = number not
#              registered for A2P texting yet)

Always set idempotencyKey for automated sends. Agents retry. A replayed request with the same key returns 201 with the header X-AgentCall-Idempotency-Replayed: true and does not send or bill a second message. Derive the key from the work item, like inv-1042-reminder-1, not from a timestamp.

Rate limit: 60 sends per minute per account. Pricing: $0.015 per outbound text, $0.008 per inbound, no per-segment math. See pricing.

Over MCP the same call is the send_sms tool. For a text that should go out later or on a schedule, use create_schedule (POST /v1/numbers/:numberId/schedules) instead of running your own timer.

2. Read the Reply: Polling or Push

Every inbound text is stored against the number the moment it arrives. What varies is how your agent learns about it. There are two patterns, and picking the right one is mostly a question of how much infrastructure you want to run.

Option A: Poll the inbox (start here)

No public endpoint, no webhook verification, nothing to deploy. Your agent asks for new messages whenever it wants them. This is the right choice for scheduled workflows like invoice chasing, where a reply an hour later is fine.

Poll for new inbound messages
curl "https://api.agentcall.co/v1/sms/inbox/num_cmpexgapz001r?since=2026-08-09T14:00:00Z&limit=20" \
  -H "Authorization: Bearer ac_live_YOUR_API_KEY"

# {
#   "data": [
#     {
#       "id": "msg_cmq48xk2m011a",
#       "from": "+13145550142",
#       "to": "+12702468123",
#       "body": "Ah thanks, I'll get it out Thursday.",
#       "otp": null,
#       "receivedAt": "2026-08-09T14:38:27.000Z"
#     }
#   ],
#   "hasMore": false
# }

Note the path takes the number ID (the num_... string from GET /v1/numbers), not the phone number. Track the last receivedAt you processed and pass it as since on the next poll.

Option B: Relay mode (AgentCall pushes to your agent)

Set the number's smsMode to relay and AgentCall HMAC-signs and POSTs every inbound text to your HTTPS endpoint within seconds. AgentCall runs no LLM in this mode; your agent is the brain, AgentCall is the pipe. Use this when reply latency matters or your agent is already a server.

Enable relay mode on a number
curl -X POST https://api.agentcall.co/v1/numbers/num_cmpexgapz001r/inbound-config \
  -H "Authorization: Bearer ac_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "ai",
    "systemPrompt": "You answer calls for Precision Painting. Take a message with the caller name and number.",
    "smsMode": "relay",
    "agentWebhook": {
      "url": "https://agent.yourdomain.com/agentcall/sms",
      "signingSecret": "a-random-secret-at-least-16-chars"
    }
  }'

You do not have to build the relay: one command installs it

Most agents run on a laptop or a VPS with no public HTTPS endpoint, so the honest version of "point a webhook at your agent" used to be "write a webhook, host it somewhere reachable, then write the service that drains it and replies." All of that is now open source and set up by one command.

From nothing to a working relay
git clone https://github.com/Kintupercy/agentcall-hermes-bridge.git
cd agentcall-hermes-bridge && npm install && npx wrangler login

./bootstrap.sh --install-consumer \
  --number-id num_cmpexgapz001r \
  --allow +15551234567

That creates the always-on HTTPS endpoint your agent lacks, generates and installs the signing secrets on both sides, installs a service next to your agent that restarts forever, puts your number into relay mode, and runs a signed self-test. It refuses to print READY unless that self-test passes. It defaults to a hosted subdomain, so you need no domain and no DNS, and --dry-run prints every command before anything is created.

If you run Hermes, you write nothing at all: the official adapter is detected automatically. For any other agent you write one script that takes the text on stdin and prints the reply on stdout.

Prove it end to end before you trust it
agentcall_sms_consumer.py preflight   # config, bridge, API key, your agent. Sends nothing.
agentcall_sms_consumer.py selftest    # a signed synthetic text through the real loop. Texts nobody.
agentcall_sms_consumer.py verify --number +15551234567
                                      # the real one: you text the number, this watches it land

Texts are never lost in transit. Each one is claimed rather than deleted, and acknowledged only once your agent's reply has actually been sent, so a crash, a restart, or an agent that errors mid thought means the text comes back instead of disappearing. Source, threat model, and the full walkthrough: github.com/Kintupercy/agentcall-hermes-bridge.

Before you connect a powerful agent. A number in relay mode lets a text message attempt anything your agent can do. If it can run commands, spend money, or read your files, decide deliberately whether those tools belong on this channel, and put a narrower agent on SMS if not. allowedSenders helps, but caller ID is a claim rather than a credential.

Or build your own relay endpoint

What AgentCall POSTs to your URL
POST https://agent.yourdomain.com/agentcall/sms
Content-Type: application/json
X-AgentCall-Signature: sha256=8f3a1c...   (HMAC-SHA256 of the raw body)
X-AgentCall-Event: sms.relay

{
  "message": {
    "id": "msg_cmq48xk2m011a",
    "from": "+13145550142",
    "to": "+12702468123",
    "body": "Ah thanks, I'll get it out Thursday.",
    "receivedAt": "2026-08-09T14:38:27.000Z"
  },
  "conversation": {
    "id": "smsconv_cmq47obtq004x",
    "contactPhone": "+13145550142"
  },
  "context": { "channel": "sms", "numberId": "num_...", "agentId": "agent_..." },
  "smsContext": {
    "currentMessage": { "id": "msg_cmq48xk2m011a", "direction": "inbound", "body": "Ah thanks, I'll get it out Thursday.", "createdAt": "2026-08-09T14:38:27.000Z", "ageDays": 0, "isGreeting": false },
    "recentMessages": [ { "id": "msg_...", "direction": "inbound", "body": "Hi Laura", "createdAt": "2026-08-08T18:30:00.000Z", "ageDays": 1, "isGreeting": true } ],
    "recentSubstantiveMessages": [],
    "olderMessages": [ { "id": "msg_...", "direction": "inbound", "body": "How much is it?", "createdAt": "2026-06-08T14:00:00.000Z", "ageDays": 62, "isGreeting": false } ],
    "freshness": { "windowDays": 7, "referenceTime": "2026-08-09T14:38:27.000Z", "recentMessageCount": 1, "recentSubstantiveCount": 0, "olderMessageCount": 1 },
    "sources": ["sms_recent", "sms_older"]
  }
}

Verify the signature by computing HMAC-SHA256 of the raw request body with your signing secret and comparing to the header. Delivery retries on non-2xx responses, so make your handler idempotent on message.id.

Use smsContext for anything about recency. It is the thread already sorted by age, so your agent never has to treat the last few texts as a recent conversation. On a quiet thread they can be months old, and an agent that conflates the two will describe a June exchange as something you just talked about. recentMessages covers the last 7 days, recentSubstantiveMessages is that list with plain greetings removed, and olderMessages is background only. When recentSubstantiveMessages is empty, the honest answer is that there has been no recent substantive text discussion. The field is additive, so an endpoint written against the older payload keeps working unchanged.

The systemPrompt in the config powers inbound voice AI on the same number, not relay texts, so give it a real prompt: one number can answer calls with AgentCall's voice AI and relay texts to your agent at the same time.

Personal agents: lock the number to yourself. Add allowedSenders: ["+1..."] to the config and texts from anyone else are dropped before they reach your webhook. For the full personal-agent story see Text Your Own AI Agent. Relay works on the free tier: every Free account gets 20 outbound texts a month, and your agent's replies come out of that same allowance. Pro is unlimited. Two-way AI SMS, where our model writes the reply, is Pro only.

There is a third mode, smsMode: "ai", where AgentCall's own managed model answers texts using your prompt. That is a different product shape (AgentCall as the brain); this guide is about your agent staying the brain.

3. Reply in the Same Thread

When your agent has thought about the reply (checked the ledger, updated the sheet), it answers on the conversation, not with a raw send. The conversation endpoint enforces STOP opt-outs for you and keeps the whole exchange threaded per contact, which is also your agent's memory of the relationship.

Reply to a conversation
curl -X POST https://api.agentcall.co/v1/sms-conversations/smsconv_cmq47obtq004x/reply \
  -H "Authorization: Bearer ac_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "Perfect, noted for Thursday. The link stays live until then.",
    "idempotencyKey": "inv-1042-reply-1"
  }'

GET /v1/sms-conversations/:id returns the last 50 messages of the thread, oldest first. Feeding that history to your agent before it composes is what makes the reply sound like a continuation instead of a cold open. Over MCP these are get_sms_conversation and reply_to_sms_conversation.

4. Let the Agent Act Mid-Conversation (Action Bridge)

If you use smsMode: "ai" (AgentCall answers texts with your prompt), you can hand the AI real tools. You declare up to 8 tool definitions and one actionWebhook URL; when the model decides to use a tool mid-conversation, AgentCall POSTs the call to your endpoint and relays your result back into the reply. Your server stays the hands even when AgentCall is the mouth.

Tool call your endpoint receives
POST https://agent.yourdomain.com/agentcall/action
X-AgentCall-Signature: sha256=...
X-AgentCall-Event: action.invoke

{
  "tool": "mark_invoice_promised",
  "arguments": { "invoiceId": "1042", "promisedDate": "2026-08-13" },
  "context": {
    "channel": "sms",
    "contact": { "phone": "+13145550142", "name": null },
    "threadId": "smsconv_cmq47obtq004x",
    "callId": null
  }
}

# You respond within the timeout (default 4s):
{ "result": "Invoice 1042 marked promised for Aug 13." }

The same tools work on voice calls with channel: "voice", so one webhook serves both. Failures are soft: if your endpoint is down, the AI tells the customer it could not complete the action instead of claiming it did.

5. Who Your Agent Can Text on Day One

New accounts start with guardrails, because a brand-new account texting a list of strangers looks exactly like spam to carriers and to us. Here is what works immediately and how the limits lift:

Always allowed
Numbers you own, and anyone who texted or called you first (30-day window). Inbound threads and callbacks work from minute one.
Starter allowance (new numbers)
Free: text up to 10 different new numbers, lifetime. New Pro accounts: up to 25 during the first week. Content-screened; follow-ups to the same people never consume a slot.
Verify a specific number
POST /v1/verified-destinations sends a 6-digit code to that number from an AgentCall platform number; confirm it and the destination unlocks. Up to 25 verified destinations on Pro.
Whole customer list
Submit a business verification (legal name, website or EIN, use case, sample message). Lifts the restriction in minutes on Pro, with a daily ramp on new recipients during week one. After 7 days on Pro everything unlocks automatically.

The error codes are designed for agents: a 403 body names the exact endpoint that unblocks it, so an agent reading destination_not_verified or sms_starter_exhausted can tell its owner precisely what to do next. More detail in the FAQ.

Troubleshooting Agent SMS Delivery

The API returned 201 but nothing arrived
201 means the carrier accepted the message, not that a phone displayed it. Check what actually happened: GET /v1/sms/:messageId returns a status of queued, sent, delivered, or failed, plus an errorCode when the carrier rejected it. If it says failed with errorCode 40010, the sending number is not yet registered for A2P texting.
status is stuck on queued or sent
queued means we have handed it to the carrier and no delivery receipt has come back yet; sent means the carrier took it but has not confirmed handset delivery. Both usually resolve within seconds. A message that never leaves queued is the classic signature of a number whose 10DLC registration has not completed, so check the number's messaging state next.
The number's messaging state is not 'registered'
GET /v1/numbers returns a messaging object per number. 'pending' means registration is still provisioning with the carriers, which Telnyx puts at roughly two hours typically and occasionally a few days; texts sent before it clears may be dropped. 'action_needed' means registration failed and we are retrying every 15 minutes. New numbers start here, which is why you should provision a number well before you need it rather than minutes before a demo.
One specific recipient never gets anything
Suspect a carrier-level STOP before anything else. If that phone ever replied STOP to a message from the campaign, the carrier silently drops everything after it while statuses still look fine. Text START from the recipient's phone to the AgentCall number to clear it.
Replies are not reaching my relay webhook
Check in order: the number's smsMode is actually relay (GET the inbound-config), your URL is public HTTPS, and allowedSenders does not exclude the sender. An allowlisted number that is not on the list is dropped silently by design. If the text reaches your agent but the reply never arrives, check the reply call's response: on the free tier your agent's replies draw from the same 20 outbound texts a month as everything else, and past that the reply endpoint returns 403 plan_limit_sms.
My agent double-texted a customer
The send was retried without an idempotencyKey. Always derive the key from the work item. This is the single most valuable line in the send call.
403 on a send
Read the error code, not just the status. plan_limit_sms is the Free 10-per-month cap, destination_not_verified and sms_starter_exhausted are the trust gates in section 5, recipient_opted_out means they texted STOP and the block is permanent until they text START.

Works with Any Agent Framework

Everything above is plain HTTPS, so the integration cost is whatever your framework charges for an HTTP tool, which is usually nothing:

  • Claude, Cursor, Windsurf: connect the hosted MCP server and the agent gets send_sms, get_inbox, and the conversation tools with no code at all.
  • OpenClaw: two environment variables and auto-discovery, see the OpenClaw guide.
  • Hermes: this guide covers the texting half; for loading your agent's daily brief into inbound voice calls on the same number, see Hermes on the Phone. One number does both.
  • Anything else: the agentcall npm SDK wraps every endpoint here, and /llms-full.txt is a complete plain-text API reference your agent can read directly.