# AI texting

> 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.

Source: https://dev.ody.co/api/ai-texting

**Business AI Texting** lets one of your [AI agents](https://dev.ody.co/api/agent) 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](https://dev.ody.co/api/knowledge), 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](https://dev.ody.co/guides/live-sms-agent): no LLM, no webhook server, no loop — three switches.

> **Where this shows up:**
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`](#turn-it-on-for-an-agent) | 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`](#link-a-line) | 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`](#take-a-thread-over) | 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](https://dev.ody.co/api/knowledge#concepts): `texting` (AI texting on every number) or `texting:<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: true` and 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`](#texting-status-for-an-agent)) or per thread ([`GET /conversations/:id/ai-texting/runs`](https://dev.ody.co/api/conversations#why-did-or-didnt-the-ai-reply)).

- `id` *uuid*
- `agentId` *uuid | null*, `agentName` *string | null*
- `conversationId` *uuid | null*
- `inboundActivityId` *uuid | null* — The customer's text that triggered the run.
- `replyActivityId` *uuid | null* — The agent's reply, when `status` is `replied`. The same activity carries `aiTextingAgentId` in the [thread](https://dev.ody.co/api/conversations#the-activity-object); webhook payloads do not carry it — read the thread or the runs.
- `lineE164` *string | null*, `contactE164` *string | null*
- `status` *"replied" | "skipped" | "failed"*
- `reason` *string | null* — Why no reply went out. `texting_off` no agent linked to the line · `agent_off` the agent or its texting switch is off · `texting_not_enabled` texting isn't live on the workspace yet · `paused` a human owns the thread · `superseded` / `already_replied` a teammate answered first · `opt_out_keyword` STOP/HELP/… · `empty_message` media-only text · `rate_limited` the 20-per-hour cap · `no_credits` · `workspace_restricted` · `trial_locked` · `model_declined` the agent chose to hand off without a reply · `model_unavailable` / `not_configured` / `send_failed` infrastructure.
- `detail` *string | null* — Free text for `failed` runs.
- `groundingMode` *"vector" | "fulltext" | "inline" | "truncated" | "none" | null* — How knowledge was retrieved.
- `groundingSources` *array* — The knowledge articles the reply drew on (`articleId`, `title`).
- `handoff` *boolean* — The agent handed the thread to the team.
- `segments` *integer | null*, `credits` *integer | null* — For `replied` runs.
- `createdAt` *timestamp*

**`GET /v1/api/agents/:id/texting`**

### Texting status for an agent

Everything about one agent's texting in one read.

**Scope:** `agent:manage` · **Query:** `limit` (runs, 1–100, default 20)

**Returns**

- `enabled` *boolean* — The agent's AI texting switch.
- `agentEnabled` *boolean* — The agent's master switch.
- `links` *{ voiceNumbers, textingNumbers }* — Lines that send this agent their calls, and lines that send it their texts.
- `textingReady` *boolean* — Outbound texting is live on the workspace (10DLC approved or toll-free).
- `live` *boolean* — `enabled && agentEnabled && textingReady && links.textingNumbers.length > 0`: the agent is answering texts right now. When `false`, the four fields above tell you which switch is missing.
- `creditsPerSegment` *integer* — 30.
- `runs` *array of runs* — Newest first.

cURL:

```bash
curl https://api.ody.co/v1/api/agents/$AGENT_ID/texting \
  -H "Authorization: Bearer $ODY_API_KEY"
```
TypeScript:

```ts
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 });
```
Python:

```python
t = client.agents.texting(agent["id"])
print(t["live"], t["links"]["textingNumbers"])
```

Response:

```json
{
  "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"
    }
  ]
}
```

**`POST /v1/api/agents/:id/texting`**

### Turn it on for an agent

Sets 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`

- `enabled` *boolean* **required**
- `unlinkLines` *boolean* — With `enabled: false`: also clear the texting link on every line pointing at this agent. The released lines come back as `released`.

**Returns** `{ agent, links, textingReady, released? }`. The agent only answers once a line is linked ([below](#link-a-line)), its master switch `enabled` is on and `textingReady` is true.

cURL:

```bash
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}'
```
TypeScript:

```ts
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 });
```
Python:

```python
client.agents.set_texting(agent["id"], True)
client.agents.set_texting(agent["id"], False, unlink_lines=True)
```

**`PUT /v1/api/numbers/:e164/texting`**

### Link a line

Chooses 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](https://dev.ody.co/api/numbers#choose-which-agent-answers-a-numbers-texts) page.

**Scope:** `agent:manage`

- `agentId` *uuid | 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:

```bash
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…"}'
```
TypeScript:

```ts
const r = await ody.numbers.setTextingAgent("+15125550100", agent.id);
if (!r.agentTextingEnabled) await ody.agents.setTexting(agent.id, true);
```
Python:

```python
r = client.numbers.set_texting_agent("+15125550100", agent["id"])
```

**`POST /v1/api/conversations/:id/ai-texting`**

### Take a thread over

`paused: 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`](https://dev.ody.co/api/conversations#retrieve-a-conversation). Full reference on the [Conversations](https://dev.ody.co/api/conversations#take-a-thread-over-from-the-ai-or-hand-it-back) page.

**Scope:** `conversations:write`

TypeScript:

```ts
await ody.conversations.setAiTexting(conversationId, true);
await ody.messages.send({ conversationId, body: "Hi, this is Sam from Acme — I've got it from here." });
```
Python:

```python
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:

TypeScript:

```ts
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`](https://dev.ody.co/webhooks/events#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`:

TypeScript:

```ts
// 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`, …).

> **Not exposed yet:**
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.
