AI textingnew
Business AI Texting: let an AI agent answer the texts your numbers receive — grounded in your knowledge base, with hand-off built in — and take any thread over from code.
Business AI Texting lets one of your AI agents write back to the texts a business number receives. The reply is written server-side on every inbound text — grounded only in the workspace's knowledge base, in your team's voice (it learns from the replies your team has already sent), 24/7. When a text needs a person (a refund, a complaint, "can I talk to someone?", a question the knowledge doesn't cover) the agent says so, hands the thread to your team and goes quiet in that conversation. Anyone — a teammate in the inbox, or your code — can take over any conversation and hand it back.
This is the hosted alternative to running your own SMS agent: no LLM, no webhook server, no loop — three switches.
Everything below is the same thing the app shows as the AI texting card on the AI agent screen, the How texts answer row in Settings → Numbers, and the I'll take it from here pill on a thread. Flip a switch here and a teammate sees it there, and vice versa.
#The three switches
A text is answered only when all three are on:
| Switch | Level | Endpoint | What it controls |
|---|---|---|---|
textingEnabled | Agent | POST /agents/:id/texting | Whether this agent may answer texts at all. Independent of enabled (the master switch — off means no calls and no texts). |
textingAgentId | Number | PUT /numbers/:e164/texting | Which agent answers this line's texts. Independent of the line's voice routing — a line can ring the team for calls and still let an agent answer its texts. One agent per line. |
aiTexting.paused | Conversation | POST /conversations/:id/ai-texting | A human owns this thread; the agent stays quiet in it until handed back. Set by your team, your code, or by the agent itself when it hands off. |
Plus one workspace-level precondition you don't control per switch: texting must be live — the workspace's 10DLC campaign is approved, or the line is toll-free. Every response below reports it as textingReady; the agent starts answering automatically the moment it flips to true. Set everything up before that if you like.
None of this needs a publish: no carrier assistant is involved, so each change applies to the very next inbound text.
#What the agent will and won't do
- Answers only from the knowledge base. Hours, prices, services, policies — if the texting AI can't read it in an article, the agent says your team will confirm and hands off. It never invents a fact. Control what it may read with the knowledge audience:
texting(AI texting on every number) ortexting:<e164>(one line only). - Never pretends to be human. Asked if it's a bot, it says it is your business's AI assistant and offers to bring in a teammate.
- Never asks for card numbers, IDs, passwords or codes, and never promises a booking, payment or callback time unless the knowledge says exactly how that works.
- Hands off on refunds, cancellations, disputes, complaints, legal, medical or safety topics, and whenever someone asks for a person — the run has
handoff: trueand the thread is paused for the agent from then on. - Stays out of the way of carrier keywords (STOP, START, HELP, YES…), of a teammate who already replied to that text, and of a paused thread.
- Won't loop. At most 20 replies per conversation per hour, and exactly one reply per inbound text (the run is keyed on the inbound activity, so a redelivered webhook never double-replies).
#Pricing
30 AI credits per carrier segment of a reply, from the same credit wallet as the agent's calls (creditsPerSegment in every status response). A one- or two-sentence reply is usually one segment (30 credits); a longer one can be two (60). Only replies that are actually sent are charged — skipped texts and hand-offs without a reply cost nothing. When the wallet is empty the run is skipped with reason: "no_credits" and texts fall through to your team.
#The run object
Every inbound text the texting agent looks at produces one run — your audit trail and the answer to "why didn't the AI reply?". Read them per agent (GET /agents/:id/texting) or per thread (GET /conversations/:id/ai-texting/runs).
iduuidagentIduuid | null,agentNamestring | nullconversationIduuid | nullinboundActivityIduuid | null — The customer's text that triggered the run.replyActivityIduuid | null — The agent's reply, whenstatusisreplied. The same activity carriesaiTextingAgentIdin the thread; webhook payloads do not carry it — read the thread or the runs.lineE164string | null,contactE164string | nullstatus"replied" | "skipped" | "failed"reasonstring | null — Why no reply went out.texting_offno agent linked to the line ·agent_offthe agent or its texting switch is off ·texting_not_enabledtexting isn't live on the workspace yet ·pauseda human owns the thread ·superseded/already_replieda teammate answered first ·opt_out_keywordSTOP/HELP/… ·empty_messagemedia-only text ·rate_limitedthe 20-per-hour cap ·no_credits·workspace_restricted·trial_locked·model_declinedthe agent chose to hand off without a reply ·model_unavailable/not_configured/send_failedinfrastructure.detailstring | null — Free text forfailedruns.groundingMode"vector" | "fulltext" | "inline" | "truncated" | "none" | null — How knowledge was retrieved.groundingSourcesarray — The knowledge articles the reply drew on (articleId,title).handoffboolean — The agent handed the thread to the team.segmentsinteger | null,creditsinteger | null — Forrepliedruns.createdAttimestamp
#Texting status for an agent
/v1/api/agents/:id/textingEverything about one agent's texting in one read.
Scope: agent:manage · Query: limit (runs, 1–100, default 20)
Returns
enabledboolean — The agent's AI texting switch.agentEnabledboolean — The agent's master switch.links{ voiceNumbers, textingNumbers } — Lines that send this agent their calls, and lines that send it their texts.textingReadyboolean — Outbound texting is live on the workspace (10DLC approved or toll-free).liveboolean —enabled && agentEnabled && textingReady && links.textingNumbers.length > 0: the agent is answering texts right now. Whenfalse, the four fields above tell you which switch is missing.creditsPerSegmentinteger — 30.runsarray of runs — Newest first.
curl https://api.ody.co/v1/api/agents/$AGENT_ID/texting \
-H "Authorization: Bearer $ODY_API_KEY"const t = await ody.agents.texting(agent.id);
if (!t.live) console.log({ enabled: t.enabled, agentEnabled: t.agentEnabled, textingReady: t.textingReady, lines: t.links.textingNumbers });t = client.agents.texting(agent["id"])
print(t["live"], t["links"]["textingNumbers"]){
"enabled": true,
"agentEnabled": true,
"links": { "voiceNumbers": ["+15125550100"], "textingNumbers": ["+15125550100"] },
"textingReady": true,
"live": true,
"creditsPerSegment": 30,
"runs": [
{
"id": "7b1e…",
"agentId": "0d9c…",
"agentName": "Astra",
"conversationId": "c4f1…",
"inboundActivityId": "a1…",
"replyActivityId": "a2…",
"lineE164": "+15125550100",
"contactE164": "+15125550123",
"status": "replied",
"reason": null,
"detail": null,
"groundingMode": "vector",
"groundingSources": [{ "articleId": "9f…", "title": "Hours & location" }],
"handoff": false,
"segments": 1,
"credits": 30,
"createdAt": "2026-11-02T18:05:44.000Z"
}
]
}#Turn it on for an agent
/v1/api/agents/:id/textingSets the agent-level switch. Immediate — no publish. Lines linked to the agent stay linked when you turn it off and resume when you turn it back on.
Scope: agent:manage
enabledboolean requiredunlinkLinesboolean — Withenabled: false: also clear the texting link on every line pointing at this agent. The released lines come back asreleased.
Returns { agent, links, textingReady, released? }. The agent only answers once a line is linked (below), its master switch enabled is on and textingReady is true.
curl -X POST https://api.ody.co/v1/api/agents/$AGENT_ID/texting \
-H "Authorization: Bearer $ODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled":true}'await ody.agents.setTexting(agent.id, true);
// Later: off, and release every line's texting link too.
const { released } = await ody.agents.setTexting(agent.id, false, { unlinkLines: true });client.agents.set_texting(agent["id"], True)
client.agents.set_texting(agent["id"], False, unlink_lines=True)#Link a line
/v1/api/numbers/:e164/textingChooses which agent answers this number's inbound texts — or null to send them to the team only. Encode + as %2B in the path. Full reference on the Numbers page.
Scope: agent:manage
agentIduuid | null required — One agent per line; linking another moves it.
Returns { ok, e164, textingAgentId, agentEnabled?, agentTextingEnabled?, textingReady } — the agent-side fields say whether the link is live or merely wired.
curl -X PUT https://api.ody.co/v1/api/numbers/%2B15125550100/texting \
-H "Authorization: Bearer $ODY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"agentId":"0d9c…"}'const r = await ody.numbers.setTextingAgent("+15125550100", agent.id);
if (!r.agentTextingEnabled) await ody.agents.setTexting(agent.id, true);r = client.numbers.set_texting_agent("+15125550100", agent["id"])#Take a thread over
/v1/api/conversations/:id/ai-textingpaused: true makes the agent stay quiet in this conversation until paused: false hands it back — every other thread keeps working. Read the current state from conversation.aiTexting on GET /conversations/:id. Full reference on the Conversations page.
Scope: conversations:write
await ody.conversations.setAiTexting(conversationId, true);
await ody.messages.send({ conversationId, body: "Hi, this is Sam from Acme — I've got it from here." });client.conversations.set_ai_texting(conversation_id, True)#Recipes
Turn it on from scratch — create or pick an agent, give it knowledge, link the line, flip the switch:
const { agents } = await ody.agents.list();
const agent = agents[0];
await ody.knowledge.createArticle({ title: "Hours & location", content: "We're open Mon–Fri 9–6 at 12 Main St." });
await ody.numbers.setTextingAgent("+15125550100", agent.id);
const { textingReady } = await ody.agents.setTexting(agent.id, true);
// textingReady false? Finish 10DLC (POST /messaging/registration) — the agent starts automatically when it's approved.Escalate on hand-off — subscribe to message.received; after each inbound text, read the thread's latest run. The agent answers (or decides not to) within a few seconds of the webhook, so give it a moment, then page a human when the run has handoff: true:
// in your webhook handler, a few seconds after message.received
if (event.type === "message.received") {
const [run] = await ody.conversations.aiTextingRuns(event.data.conversationId, { limit: 1 });
if (run?.inboundActivityId === event.data.activityId && run.handoff) await notifyTeam(event.data.conversationId);
}Tell AI replies from human ones in any timeline: activity.aiTextingAgentId is set on the agent's messages and absent on yours.
Know why nothing was answered — read the thread's runs; the latest reason names the missing switch (texting_off, agent_off, texting_not_enabled, paused, no_credits, …).
The MCP server has no texting tools so far — use the REST endpoints (or the SDKs) above. The agent only answers; outbound, scheduled or proactive AI texts are not part of this feature.