OdyDocs

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 loopWebhooks + REST
HowThe model calls send_message, then wait_for_message (a 25-second long-poll), then repeatsYour server gets message.received, calls your LLM, replies with POST /messages
Best forAssistants and prototypes; one conversation at a timeProduction; many conversations at once
InfrastructureNone. Just an MCP clientA 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 authorUid that 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 as simulated and 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