Run a live SMS agent
Let an LLM hold a real-time text conversation from your business number — with MCP's wait_for_message, or with webhooks and the REST API.
Two ways to put an LLM on the other end of a text thread:
| MCP loop | Webhooks + REST | |
|---|---|---|
| How | The model calls send_message, then wait_for_message (a 25-second long-poll), then repeats | Your server gets message.received, calls your LLM, replies with POST /messages |
| Best for | Assistants and prototypes; one conversation at a time | Production; many conversations at once |
| Infrastructure | None. Just an MCP client | A public HTTPS endpoint |
#Option A: MCP loop
Connect a client with a key that has conversations:read and messages:send (setup), then give the model instructions like:
System prompt
You are the SMS assistant for Acme Plumbing. To talk to a customer:
1. send_message with `to` (first message) or `conversationId` (replies). Keep texts under 300 characters.
2. wait_for_message with the conversationId and afterActivityId = the lastActivityId you last saw.
3. If it returns timedOut: true, call wait_for_message again (up to 10 times), then stop and say the customer didn't reply.
4. When the customer's need is handled, call set_conversation_status with status "done".
Never promise prices; offer to have a person follow up.What happens on the wire:
JSON
// → send_message
{ "to": "+15125550123", "body": "Hi Ada, it's Acme Plumbing. Is Thursday 2pm still good for the water heater install?" }
// ← { "message": { "id": "m1…" }, "conversationId": "c4f1…" }
// → wait_for_message
{ "conversationId": "c4f1…", "afterActivityId": "m1…", "timeoutSeconds": 25 }
// ← { "activities": [{ "id": "m2…", "direction": "inbound", "body": "Can we do 4pm instead?" }], "lastActivityId": "m2…" }
// → send_message
{ "conversationId": "c4f1…", "body": "4pm Thursday works. You're booked, see you then!" }Passing afterActivityId each time means a reply that arrives between two waits is never missed.
#Option B: webhooks + REST
Your server owns the loop, which scales to any number of simultaneous conversations:
agent.ts
import express from "express";
import OpenAI from "openai";
import { Ody, verifyWebhookSignature } from "@ody/sdk";
const ody = new Ody(process.env.ODY_API_KEY!);
const llm = new OpenAI();
const app = express();
app.post("/ody/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyWebhookSignature(process.env.ODY_WEBHOOK_SECRET!, req.headers, req.body)) return res.status(401).end();
res.status(200).end();
const event = JSON.parse(req.body.toString("utf8"));
if (event.type !== "message.received" || (await seen(event.id))) return;
const { conversation, activities } = await ody.conversations.get(event.data.conversationId);
if (conversation.assigneeUid) return; // a teammate owns this thread, so stay out
const history = activities
.filter((a) => a.type === "message" && a.body)
.slice(-20)
.map((a) => ({ role: a.direction === "inbound" ? "user" : "assistant", content: a.body! }) as const);
const completion = await llm.chat.completions.create({
model: "gpt-5-mini",
messages: [{ role: "system", content: "You are Acme Plumbing's SMS assistant. Be brief and friendly." }, ...history],
});
await ody.messages.send({
conversationId: conversation.id,
body: completion.choices[0].message.content!.slice(0, 600),
idempotencyKey: `reply-${event.id}`, // a webhook retry never double-replies
});
});
app.listen(3000);Key details:
idempotencyKey: reply-${event.id}ties each reply to the inbound event, so duplicate deliveries can't produce duplicate texts.- Back off when a human steps in. Here the bot stops once a teammate is assigned. You could also stop when the latest outbound message has an
authorUidthat isn't the key's. - Rebuild context from the thread, not from memory. It's the source of truth and includes what your team typed in the app.
#Guardrails
- Test with an
ody_test_…key: replies are recorded assimulatedand never sent. - Live texting from local numbers requires 10DLC, and your campaign's use case and sample messages should match what the bot sends.
- Inbound texts are untrusted input. Don't give the bot tools that can spend money (see prompt injection).
- Respect quiet hours (no marketing texts late at night in the recipient's time zone).
Last updated Wed Oct 14 2026 00:00:00 GMT+0000 (Coordinated Universal Time)Something wrong on this page? Tell us