# Changelog

> Notable changes to the Omni Octo API, MCP servers, and developer docs.

Notable, developer-facing changes to the Omni Octo API, MCP servers, and these
docs. Newest first. Dates are UTC.

## 2026-10-29 — Plan mode on `/v1/responses`

### Added

- `tool_choice: "none"` on `POST /v1/responses` runs the turn in plan mode:
  Octo sees its whole tool catalog and says what it would do, but calls no
  tool and treats no reply as a confirmation. Any other value, or none,
  behaves as before. A plan-mode turn reads the tools as a one-line list, not
  full schemas, so it is smaller and faster. See [plan mode](/openai-api/overview/#plan-mode-tool_choice-none).
- `reasoning.effort` on `POST /v1/responses`: `low`, `medium` or `high` for one
  turn, or `none` to turn thinking off where the model allows it. See
  [reasoning effort](/openai-api/overview/#reasoning-effort-reasoningeffort).

## 2026-10-29 — Docs: trial number, previews, paging and the event stream each in one place; Claude Code guide checked end to end

### Docs

- The shared trial number's rules live on [The trial number](/platform-api/voice/#the-trial-number) only; the overview, quickstart and testing pages link there.
- How long a preview stays usable, per channel, including `preview_expired` and `409 preview_stale`, is one row of [How the channels differ](/platform-api/overview/#how-the-channels-differ).
- New [Lists and paging](/platform-api/overview/#lists-and-paging) section: page sizes in a table, then the cursor rules. The ids rules on the overview are three short rules instead of one paragraph.
- [The event stream](/platform-api/events/#the-event-stream) is split into Connect, Resume, Frames and Limits.
- [Call campaigns from Claude Code](/mcp/claude-code-campaigns/): a voice preview's id is `rev_…`, and a round that has started keeps the `content.mission` it started with, so a new mission goes in the next round (a clone). [Edit a campaign](/platform-api/campaigns/#edit-a-campaign) says the same: `call_settings` edits reach a resumed voice campaign's calls, a new `content.mission` does not.

## 2026-10-29 — Docs: one home per rule, a recipe on every channel page, Billing and Retries pages

### Docs

- **New pages.** [Billing and credits](/platform-api/billing/) brings together the balance, what each channel costs, a campaign's cost and spending limit, the `402` refusals and the usage report. [Retries, idempotency and rate limits](/platform-api/retries/) brings together `Idempotency-Key`, replays and the limits. The old sections on the overview, Errors, SMS and WhatsApp pages now link there, and their old links still work. API errors' `docs_url` for these topics points at the new pages.
- **Recipes.** [Send a text](/platform-api/sms/#recipe-text-someone-and-read-the-reply) and [Send a WhatsApp template](/platform-api/whatsapp/#recipe-send-a-template-and-read-the-reply) open with commands that run in order as pasted: find a sender, send, read the status, and receive the reply as `message.received`. [Place a call](/platform-api/voice/#call-your-own-phone) now ends with waiting for the call to end, reading the transcript and downloading the recording, and says that the recording route answers a short-lived link.
- **One home each.** [What opts a number out](/platform-api/contacts/#what-opts-a-number-out) lists the SMS and WhatsApp opt-out words. [Phone numbers](/platform-api/overview/#phone-numbers) states the E.164 and `%2B` rule. [Events](/platform-api/events/#channel-coverage) has a table of which events each channel emits. The quickstart's campaign steps live on [Run a campaign](/platform-api/campaigns/#draft-preview-start).

## 2026-10-29 — Campaign rounds: results in counts, and the next round in one request

### Added

- `GET /v1/campaigns/{id}/results` (AI-call campaigns): one aggregated read of a round. Recipients by `status` and by call `outcome` (both sum to `recipients`), how many were `reached`, how many a next round would call back (`unreached`), calls by the agent `prompt_version` they ran on, and per Dialog Insight field what the calls extracted (counts per value for yes/no and pick-one fields; `min`, `max`, `mean` and five ranges for numbers; how many answered a text field). See [Improve an agent between rounds](/platform-api/voice/#improve-an-agent-between-rounds).
- `POST /v1/campaigns/{id}/clone` (AI-call campaigns): the next round from an existing campaign. `audience: "unreached"` calls only the people it did not reach (`no_answer`, `busy`, `voicemail`, `failed`; never anyone reached on any attempt, skipped or opted out; the campaign must have finished), ; `audience: "same"` calls everyone it had again. The new round gets a contact list of its own, never calls anyone who asked a round to stop, and `unreached` never anyone a round reached; the campaign must have finished (a draft may be copied as `same`), and one unfinished round of a family at a time (`409 round_in_progress`). Agent, sender, mission, variables, call settings, retry policy and schedule are copied; any field `PATCH` takes is an override. `start: "draft"` or `"auto"`. Send an `Idempotency-Key`: a retry answers the campaign the first request made.
- `cloned_from` (`{ id, audience, round }`, or `null`) on every campaign read.
- MCP (`/mcp/api`): `get_campaign_results`, and `clone_campaign` (needs an `idempotency_key`; with `start: "auto"` it asks to confirm first). See [API tools](/mcp/api-tools/).

## 2026-10-29 — Rehearse an agent before you dial

### Added

- `POST /v1/agents/{id}/test-turns`: a text rehearsal of an agent. You play the person on the line (`message`); the agent answers (`reply`) as it would on a call (`channel: "voice"`, the default, on the model calls run on) or in the website chat (`"chat"`), with the `mission` and `variables` a voice campaign would give it. The first answer carries the agent's `opening`; send its `session_id` with each next `message`. Nobody is called or texted, nothing is stored as a conversation and no event is published; each turn bills the agent's model tokens (`usage`). A session runs on the `prompt_version` it started on (`409 agent_changed` after an edit) and is forgotten an hour after its last request. `Idempotency-Key` makes a retried turn free. `not_rehearsed` names what text cannot rehearse. See [Rehearse an agent](/platform-api/testing/#rehearse-an-agent).
- MCP (`/mcp/api`): `test_agent_turn` (no confirm: it calls nobody).

## 2026-10-29 — What a campaign cost, what a call cost, and a spend cap on AI calls

### Added

- `GET /v1/campaigns/{id}/usage`: the credits a campaign has been billed so far, by category (`phone`, `recording`, `speech`, `ai`, `messaging`, `whatsapp`, `other`, each with an English `label`), its calls or messages, and an AI-call campaign's spending limit (`spend_cap`). MCP tool `get_campaign_usage`. See [Reading a campaign](/platform-api/reference/campaigns/#reading-a-campaign).
- `billed_credits` on every call (`GET /v1/calls`, `GET /v1/calls/{id}`, `get_call`): what the call cost so far. `null` for an outbound call that ended before 2026-10-30.
- `limits.spend_cap_credits` on AI-call campaigns (it was SMS only): before each call the campaign checks the credits billed, plus the calls under way, plus one more call; when that would not fit it pauses with `paused_reason: "spend_cap"`, its untouched recipients still queued. Raising or removing the limit of a started campaign is a person's act in the dashboard (`403 spend_cap_raise_needs_person` for a key, which can still lower it), and a resume that would pause again at once is `409 spend_cap_reached`. Both carry `dashboard_url`. A voice preview adds `spend_cap: { cap, calls_covered }`. See [Limits and retries](/platform-api/reference/campaigns/#limits-and-retries).

### Changed

- A WhatsApp or voice campaign the platform pauses itself (out of credits, or its spend cap) now sends `campaign.paused` with that `reason`, as SMS campaigns did.
- `field_not_for_channel` no longer applies to `limits.spend_cap_credits` on AI-call campaigns; a voicemail drop still refuses it.

## 2026-10-29 — Choose what calls find out (insights)

### Added

- `/v1/insights`: list, create, read, change and delete insights (Dialog Insights in the dashboard), the typed fields a call's answers are extracted into (`boolean`, `number`, `text`, `enum` with `enum_values`). Answers appear in a call's `extracted` under each field's `key`. Writes take owner or admin keys; `PATCH` takes `expected_revision` (`409 revision_conflict`). Nothing is re-run over past calls. See [Choose what calls find out](/platform-api/insights/).
- `insight` on voice campaigns (`POST /v1/campaigns`, `PATCH /v1/campaigns/{id}`, read back on `GET /v1/campaigns/{id}`): the insight that campaign's calls run. A change of `insight` alone is also taken while the campaign is queued or running.
- `insight` on `PATCH /v1/agents/{id}` (read back on `GET /v1/agents/{id}`): the agent's insight, for its inbound calls and for campaign calls whose campaign names none. From now on; not part of `prompt_version`.
- MCP tools `list_insights`, `get_insight`, `create_insight` (needs an `idempotency_key`) and `update_insight` (confirm first, with `expected_revision`); `insight` on `update_agent`, `create_campaign_draft` and `update_campaign_draft`. See [API tools](/mcp/api-tools/).

## 2026-10-29 — Create, change and delete segments; page a segment's contacts

### Added

- `POST /v1/segments`, `PATCH /v1/segments/{id}` and `DELETE /v1/segments/{id}`: save a filter over your contacts (`tag`, `name`, `company`, `channel`, `opted_out.<channel>`, `birth_month`, `attributes.<key>`, with `all` / `any`), rename it or replace its filter, and delete it. Writes need a key created by an owner or admin (`403 insufficient_role`). A filter with a problem is `400 invalid_filter`, with every problem in `issues`. A new filter or a delete is refused (`409 segment_in_use`) while a queued, running or paused campaign uses the segment. See [Build a segment](/platform-api/segments/).
- `GET /v1/segments/{id}/contacts`: the contacts a segment matches now, newest first, with a cursor bound to the segment's filter.
- `filter` on `GET /v1/segments/{id}`, in the same names.
- MCP tools `create_segment`, `update_segment`, `delete_segment` (asks for `confirm: true` first) and `list_segment_contacts`. See [API tools](/mcp/api-tools/).

### Fixed

- A segment with an `is_set` or `is_not_set` condition on a custom field (`attributes.<key>`, `customField:<key>` in the dashboard) failed to count, list or send. It now works.

## 2026-10-29 — Usage report: what the credits went on

### Added

- `GET /v1/usage`: the credits charged in a window, in total and grouped by `category` (`phone`, `recording`, `speech`, `ai`, `messaging`, `whatsapp`, `email`, `numbers`, `other`), UTC `day` and/or `label`. Default window: the current billing cycle to now. See [Usage and spend](/platform-api/overview/#usage-and-spend).
- `GET /v1/usage/events`: one row per charge, newest first, with its category, label, credits, `conversation_id`, `agent_id` and `campaign_id`; and `GET /v1/usage/export`, the same rows as a CSV file (at most 250,000 rows, refused up front over that).
- Error codes `too_many_groups`, `export_too_large` and `export_in_progress` ([Errors](/platform-api/errors/#balance-and-compliance)).
- MCP tool `get_usage` ([API tools](/mcp/api-tools/)).

## 2026-10-29 — List SMS and WhatsApp sends together

### Added

- `GET /v1/messages` without `channel` lists your SMS and WhatsApp template sends in one list, newest first by `created_at` (to the microsecond, ties by `id`), each item with its `channel`. `to`, `from`, `campaign`, `status` and the `created_*` times work as they do with `channel`; `status=read` lists WhatsApp only and `status=undelivered` SMS only. Inbound messages and inbox replies are not in it (`GET /v1/conversations/{id}/messages`). See [List texts](/platform-api/sms/#list-texts).
- MCP `list_messages` lists both channels when `channel` is left out. See [API tools](/mcp/api-tools/).

### Changed

- `channel` on `GET /v1/messages` is optional. `channel=sms` and `channel=whatsapp` answer as before and their cursors keep working; a cursor of one of them is refused without `channel`, and a cursor of the list of both is refused with one (`400 invalid_cursor`, the hint names `channel`). A blank `channel=` is `400 invalid_request` ("leave it out to list both").

## 2026-10-29 — Campaign events named for every campaign type

### Fixed

- The event envelope's description in the OpenAPI document said the `campaign.*` events were SMS-only. `campaign.started`, `paused`, `resumed`, `completed` and `canceled` come from SMS, WhatsApp and voice campaigns; `campaign.waiting` and `campaign.window_opened` from SMS campaigns only; `campaign.completed`'s `billed_credits` is `null` outside SMS.
- The docs build's request-example check now also checks enum values: a body field or query value the API would refuse as not one of its allowed values fails the build.

## 2026-10-29 — The inbox by newest message, and a poll for what changed

### Added

- `sort=last_message_at` on `GET /v1/conversations`: the conversation with the newest message first, as the dashboard's inbox shows it (a call by when it started). The default stays `created_at`. See [Newest activity first](/platform-api/inbox/#newest-activity-first).
- `updated_after` on `GET /v1/conversations`: the conversations that changed at or after a time, oldest change first, each one again later in the walk if it changes while you page. Poll with it instead of running a webhook endpoint: keep the largest `updated_at`, ask again from 2 minutes before it, and page to the end. It takes no `sort`. See [Poll for changes](/platform-api/inbox/#poll-for-changes).
- MCP `list_conversations` takes `sort` and `updated_after`.

### Changed

- A conversation's `last_message_at` now counts every message the thread shows: your AI agent's replies, campaign sends and automated messages too (before, only the person's messages and the replies a teammate or `/v1` sent). It is null for a call.
- A conversation's `updated_at` now moves on every change the object or its detail shows: a new message by anyone, the status, the contact it is linked to, the name, phone or email, the agent, the labels and the summary. It never moves backwards. Before this release it did not move for some of these: start an `updated_after` poll from the release time or later.

## 2026-10-29 — Contacts: filter by tag or attribute, and sync what changed

### Added

- `GET /v1/contacts` takes `tag` (one tag, exact and case-sensitive), `attribute_key` (has that attribute) with an optional `attribute_value` (its value as text), and `updated_after` (an ISO 8601 time: contacts changed at or after it, oldest change first, by `updated_at` then `id`). They combine with each other and with `query`, not with `phone` or `email`. The MCP tool `list_contacts` takes them too. See [Sync changes](/platform-api/contacts/#sync-changes).

### Changed

- A contact's `updated_at` now also moves when one of its numbers or emails is changed (in the dashboard or by Octo), when a workflow captures a number or email onto it, when an import adds a number to a contact you already had, and when a carrier reports that the number opted out.
- The default order of `GET /v1/contacts` and the cursors it already answered are unchanged.

## 2026-10-28 — DeepSeek V4.1 Flash

### Added

- Model `deepseek-flash` (DeepSeek V4.1 Flash) on `/v1/responses`, `/v1/chat/completions` and `GET /v1/models`.

### Changed

- `deepseek-v4-flash` stays in the list and keeps working. DeepSeek now serves it with V4.1 Flash, and it is billed at the same rate as `deepseek-flash`, which is higher than before.

## 2026-10-28 — Call transcripts, call summaries, agent filters, one campaign recipient

### Added

- `GET /v1/calls/{id}/transcript`: what was said on a call, oldest turn first. Each turn has `speaker` (`agent`, or `caller` for the other end of the line), `text` and `at_ms` (milliseconds after `started_at`, when the call was answered), with the call's `status`, `summary` and `prompt_version`. Any API key. See [Voice → Read the transcript](/platform-api/transcripts/#read-the-transcript).
- `summary` on every call object (`GET /v1/calls`, `GET /v1/calls/{id}`, `POST /v1/calls`, `POST /v1/calls/{id}/end`): what the call was about, once it was analysed.
- `?agent=` (an `ag-…` id or UUID) on `GET /v1/calls` and `GET /v1/conversations`: one AI agent's calls and conversations. An unknown or deleted agent is `404 agent_not_found`.
- `GET /v1/campaigns/{id}/recipients/{recipient_id}`: one campaign recipient by the list's `id` or its `cr-…` id, with `campaign_id`, `contact_id`, `created_at` and `completed_at`; a voice recipient adds `duration_sec`, its call's `transcript`, `extracted` and `summary`. See [Voice → One campaign recipient](/platform-api/transcripts/#one-campaign-recipient).
- `short_id` (`cr-…`) on every campaign recipient in `GET /v1/campaigns/{id}/recipients`.
- MCP API tools: `get_call_transcript` and `get_campaign_recipient`; `list_calls` and `list_conversations` take `agent`. See [API tools](/mcp/api-tools/).

### Fixed

- The docs said a call's transcript is only in the dashboard. It is on `GET /v1/calls/{id}/transcript` now, and the Conversations reference and the MCP tool descriptions point there.

## 2026-10-28 — Read and change an agent's prompt, with versions

### Added

- `PATCH /v1/agents/{id}` changes an agent's `prompt`, `greeting`, `model` (`chat`, `voice`), `voice` and `language` (owner and admin keys). Send `expected_version` (the `prompt_version` you read): when someone changed the agent meanwhile, nothing changes and the answer is `409 version_conflict` with `current_version`. A request that changes nothing answers the agent as it is. The answer carries `warnings` (`model_replaced`, `voice_language_mismatch`). `voicemail_message` is refused (`field_not_supported`): voicemail is a campaign setting. See [Improve an agent between rounds](/platform-api/voice/#improve-an-agent-between-rounds).
- Every change of an agent's prompt, greeting, model, voice or language, from the API, the dashboard, Octo or a Live signal, makes a new `prompt_version`. `GET /v1/agents/{id}/prompt-versions` lists them (newest first, with `source` and what `changed`); `GET /v1/agents/{id}/prompt-versions/{version}` reads one with its texts.
- Calls carry `prompt_version`: the version they ran on, recorded when they started (a campaign's calls: the version it started with; `null` for calls before today). `GET /v1/calls` takes `?prompt_version=` (combine it with `campaign`).
- MCP (`/mcp/api`): `update_agent` (asks to confirm first, then needs `confirm: true` and `expected_version`), `list_agent_prompt_versions`, `get_agent_prompt_version`; `get_agent` answers the new fields.

### Changed

- `GET /v1/agents/{id}` now answers the agent's `prompt`, `greeting`, `model`, `voice`, `language`, `prompt_version` and `updated_at`; before, it answered only the placeholders its texts use. `GET /v1/agents` items add `prompt_version` and `updated_at`. The texts are what the agent's owner wrote: treat them as data.

## 2026-10-28 — Call settings on voice campaigns and calls: turn phone-menu handling off

### Added

- `call_settings` on voice `POST /v1/campaigns`, `PATCH /v1/campaigns/{id}` and `POST /v1/calls`, read back (every field effective) on `GET /v1/campaigns/{id}` and the list (`null` on SMS and WhatsApp): `phone_menu` (`auto`, `always`, `never`), `record_calls`, `max_hold_seconds`, `voicemail` (`action` `hang_up` or `play_recording`, `recording`, `detect_by_agent`) and `use_knowledge_base`. **To turn phone-menu (IVR) handling off, send `"call_settings": { "phone_menu": "never" }`.** On a paused campaign the new settings reach the calls placed after it resumes; `max_hold_seconds` is fixed once a campaign has started. See [Call settings](/platform-api/campaigns/#call-settings).
- `/v1/voicemail-recordings`: upload (multipart `file`, MP3, M4A or OGG up to 5 MB), list, read with a link to listen, and delete (refused with `409 voicemail_recording_in_use` while an unfinished campaign plays it). A campaign or call plays one with `call_settings.voicemail: { "action": "play_recording", "recording": "vm-…" }`. See [Voicemail recordings](/platform-api/voice/#voicemail-recordings).
- MCP tool `list_voicemail_recordings`; `create_campaign_draft`, `update_campaign_draft` and `place_call` take `call_settings`. See [API tools](/mcp/api-tools/).

### Changed

- A new voice campaign, and a `POST /v1/calls`, with no `call_settings.phone_menu` now runs `auto`: the agent listens first, talks when a person answers and presses keys when a phone menu answers. Before, every `/v1` call expected a phone menu first (`always`), so a person who answered waited for a reply. Send `"phone_menu": "always"` for the old behaviour.
- Voice campaigns created through `/v1` before this change keep `always`, as they ran; `GET /v1/campaigns/{id}` now shows it as `call_settings.phone_menu: "always"`. To change one, pause it, `PATCH` `call_settings.phone_menu`, and resume it.
- A voice start refused because the campaign plays a voicemail recording it does not name now points at `call_settings.voicemail.recording` (it said `content.voicemail_drop`).
## 2026-10-28 — Call campaigns from Claude Code; `/mcp/api` easier to find

### Added

- New guide, [Call campaigns from Claude Code](/mcp/claude-code-campaigns/): draft, preview and start a voice campaign over the `/mcp/api` tools, wait for its calls with `wait_for_events` (or Claude Code's Monitor and the event stream), read the outcomes, and run the next round.
- The docs home, the [quickstart](/platform-api/quickstart/#mcp), the [Messaging API overview](/platform-api/overview/) and Settings → API Keys & Webhooks in the dashboard now show the `/mcp/api` server and its `claude mcp add` command.

### Fixed

- [Connect an MCP client](/mcp/connect/) said `/mcp/api` had 17 tools; the list is on [API tools](/mcp/api-tools/#the-tools).
- Two route names missing their `{id}`: `PATCH /v1/webhooks/{id}` (choosing a webhook's `api_version`) and `PATCH /v1/campaigns/{id}` (`content.media` after `POST /v1/media`).
- The docs build now checks every `/v1` request example against the API's OpenAPI document (path, method, query parameters and body fields).

## 2026-10-28 — Poll the event log, follow one campaign, and resume from MCP

### Added

- `GET /v1/events?wait=<0..25>` answers one JSON page instead of a stream: `{ object: "list", data, has_more, next_cursor, replay_truncated, render_errors }`, as soon as there is an event or after `wait` seconds with none. Without `after` it starts from now and returns your first `next_cursor`. `limit` (1 to 200, default 100) sizes the page. At most 5 waiting polls per key, 20 per workspace (`429 too_many_waits`). See [Poll instead of stream](/platform-api/events/#poll-instead-of-stream).
- `?campaign=<ca-… or UUID>` on `GET /v1/events`, stream and poll: only that campaign's events. Without `types`, its texts' status events and its calls' events are still left out, as with `"*"`.
- MCP tools `wait_for_events` (the poll, always v2, owner or admin keys) and `resume_campaign` (confirm and `idempotency_key`, like `start_campaign`). See [API tools](/mcp/api-tools/).

### Changed

- A WhatsApp or voice campaign's `campaign.completed` and `campaign.canceled` are now written exactly once: one event id per campaign, and one that could not be written when the campaign ended is written 5 to 10 minutes later instead of being lost. Its `*_at` is then the time it was written. See [the event rules](/platform-api/reference/events/#rules).
- `POST /v1/campaigns/{id}/resume` honours `Idempotency-Key` on WhatsApp and voice campaigns too (it was ignored): a retry with the same key answers the campaign as it is now and never resumes it twice, and the same key with another body is `422 idempotency_key_reuse`. If you reused one key for two different resumes within 24 hours, use a new key per resume.

## 2026-10-28 — The daily voice budget follows the workspace's own day

### Changed

- The daily voice-minute budget now resets at midnight in the workspace's timezone, not 00:00 UTC. Existing workspaces got the zone their campaign schedules use; a workspace with none stays on UTC until its first scheduled voice campaign, or until an owner sets it in Settings → Voice.
- A scheduled voice campaign is judged against the day it starts. Its calls hold minutes on that day, not on the day it was launched.
- Calls are projected at this workspace's own average billed minutes per call over the last 30 days (at least 1.5 minutes; 3 minutes until there are 20 calls), not a flat 3 minutes.

### Added

- `400 daily_voice_minutes_exceeded` on a campaign preview or start carries `metered` (minutes that day already billed), `held` and `held_by` (queued or scheduled campaigns holding minutes that day, by `ca-…` id), `day`, `time_zone`, `resets_at` and `per_call_minutes`. `used` keeps its value: it already counted queued campaigns' calls along with billed minutes, and is now documented as `metered` + `held`; read `metered` for minutes actually billed. See [Errors](/platform-api/errors/).

## 2026-10-27 — "OpenAI Realtime + your voice" voice pipeline

### Added

- A voice agent can run a fourth pipeline, picked on its Configure tab: OpenAI's realtime model (`gpt-realtime-mini` or `gpt-realtime`) hears the caller and writes the reply, and the agent's own TTS voice speaks it. There is no separate speech recognizer, so replies come sooner. A call that has to get through a phone menu runs the standard pipeline instead. The `/v1` API does not expose an agent's pipeline; it shows up in a voice campaign preview's `cost_estimate.per_call_components`, which for such an agent prices `realtime` and `tts` and no `stt`. See [Campaigns](/platform-api/reference/campaigns/).

## 2026-10-26 — WhatsApp replies as events, media uploads, read-by-id, more MCP tools

### Breaking

- `message.received` now also fires for WhatsApp messages people send you. `data.channel` is `sms` or `whatsapp`. On v2 the field was always `sms`; on 2026-10-01 it is a new optional field, and absent means `sms`. If your handler assumes SMS, check `channel`. See [WhatsApp → Hear replies](/platform-api/whatsapp/#hear-replies).

### Added

- `message.received` carries `quick_reply`: `{ payload }` for a WhatsApp button tap, otherwise `null`. It is optional on 2026-10-01.
- `POST /v1/media` uploads an MMS picture or video and answers `{ object: "uploaded_media", media, content_type, size, transcoded, preview_url, preview_expires_in, expires_at }`.
  - Use the returned `media` as `media` on `POST /v1/messages`, as `content.media` on campaigns, or as `media` on a conversation reply.
  - An upload that is never used is deleted after 7 days.
  - The SMS guide now lists MOV and WebM too.
- New single-object reads:
  - `GET /v1/templates/{id}`
  - `GET /v1/agents/{id}`, with `variables`
  - `GET /v1/segments/{id}`, with `contacts`
  - `GET /v1/sms-templates` and `GET /v1/sms-templates/{id}`
- New fields: a WhatsApp template has `uuid`, `created_at` and `all_buttons` (every button, with a URL button's `url` and `dynamic`). An agent has `description`, `language` and `created_at`.
- An SMS preview waits up to 5 s before answering. A `building` preview carries `poll_after_ms`, `progress` and `Retry-After`. See [Polling an SMS preview](/platform-api/campaigns/#polling-an-sms-preview).
- `PATCH /v1/lists/{id}` renames a contact list.
- `POST /v1/lists` accepts `contact_ids` (up to 1,000), the same as `POST /v1/lists/{id}/contacts` does. A request that names people but would add none of them is `400 empty_audience_input`, and no list is created.
- `GET /v1/events` answers an `Octo-Event-Version` header naming the payload version of its frames. The stream still defaults to `2026-10-01`; pass `?api_version=v2`, the format new webhook endpoints get. [Payload versions](/platform-api/events/#payload-versions) now lists every default in one table.
- MCP API tools (50 in all):
  - campaigns: `list_campaigns`, `list_campaign_recipients`, `update_campaign_draft` (a draft or a paused campaign), `delete_campaign_draft` (asks for `confirm: true` first);
  - sends and calls: `list_messages`, `list_calls`;
  - catalog: `list_segments`, `get_segment`, `get_template`, `get_agent`, `list_sms_templates`, `get_sms_template`;
  - lists: `rename_contact_list`;
  - webhooks: `list_webhook_deliveries` (owner or admin keys).
  - See [API tools](/mcp/api-tools/).

### Changed

- `PATCH /v1/campaigns/{id}` can change an SMS draft's `from` (E.164, `pn-…` or `ch-…`).
  - It is refused the same way as on create (`invalid_from`, `from_not_sms_capable`, `trial_sender_not_allowed`).
  - A number not cleared yet is accepted; preview and start then report `sender_not_cleared`.
  - A paused SMS campaign still answers `400 field_not_editable`.
- `GET /v1/campaigns` takes `status=preparing`: a one-request SMS create whose preview is still being built.
  - `status=draft` no longer matches those campaigns.
  - List items now read `preparing`, as `GET /v1/campaigns/{id}` already did.
  - If you listed drafts to find campaigns still preparing, filter by `preparing`.
- A `401 invalid_api_key` now has `param: "authorization"` and a message naming the cause: no key, the key in `X-API-Key`, an `Authorization` header without `Bearer `, or a key that is not valid. `type`, `code` and the status are unchanged, and the key is never repeated.
- **Corrected:** the Conversations reference said a call's transcript is on `GET /v1/calls/{id}`. It is not. That route carries the outcome and the extracted answers; the transcript is in the dashboard.
- The dashboard has a new look: four themes (Day, Night, Rose and Ocean) on a full-window gradient, with the Octo panel and the sidebar as glass layers and every page on one solid pane. Your saved theme carries over and now follows you to other devices. If your system's "reduce transparency" setting is on, every surface is solid.
- The Octo panel is rebuilt as one panel that works the same in the corner, full screen and on a phone.
  - A new header shows the animated Octo, with its state read aloud for screen readers.
  - One composer replaces the separate ones: a progress ring around the dictation mic shows how far a job has got, and the mic types what you say into the box.
  - Messages and cards (plans, confirmations, created resources) are restyled, and a finished turn is read aloud once.
  - An empty chat shows three suggestions to start from.
  - On a phone the panel opens as a sheet.
  - While a past conversation loads, you can type but Send waits until it has loaded.
  - Nothing changes in `/v1` or the MCP tools.

### Fixed

- Sending back a `null` that `GET /v1/campaigns/{id}` returned for another channel's field (`"retry": null` on an SMS campaign) is accepted on create and `PATCH`, and changes nothing.
- A body that leaves out `channel` answers ``"`channel` is required: `sms` or `whatsapp`."`` with `param: "channel"`.
- WhatsApp: a person who stops marketing messages from WhatsApp's own menu is now opted out of WhatsApp, as with a STOP. A send Meta refuses with 131050 records it as well.

Changes to the dashboard API (`/api`):

- Email channel routes answer `404` for a malformed channel, thread, draft or attachment id (was `500`). The thread routes accept `thr-…` ids and answer `404` for a thread that is not on the URL's channel.
- `POST /api/conversations/click-to-call` answers a disabled agent with `409 { "error": "agent_disabled", "message": "…" }`.
- `GET /api/billing/usage` cursors keep microseconds, so no row is skipped. A malformed cursor is `400 invalid_cursor`. `GET /api/billing/usage/export` refuses more than 250,000 events with `400 export_too_large` instead of truncating.
- `GET /api/segments/{id}/members` pages by `after` / `nextCursor` (`offset` is `400`), and the total is returned only with `includeTotal=true`. `DELETE /api/segments/{id}` answers `409 segment_in_use` while a queued, running or paused campaign uses the segment.

## 2026-10-24 — Webhook delivery history, WhatsApp opt-outs, docs corrections

### Added

- `GET /v1/webhooks/{id}/deliveries` and `GET /v1/webhooks/{id}/deliveries/{delivery_id}`: see which deliveries to an endpoint succeeded, are retrying or were given up, what your server answered (`response_status`, `error`, `response_excerpt`) and the exact body sent (`payload`). Kept for 7 days; replay from `GET /v1/events?after=<seq - 1>`. [See what was delivered](/platform-api/events/#see-what-was-delivered).
- WhatsApp: a person's taps on your template's quick-reply buttons now reach the conversation and your agent like a typed reply, with the button's payload. Reactions and stickers show in the conversation too.
- WhatsApp: a tap on a "Stop" / "Stop promotions" button, or STOP, STOP ALL, UNSUBSCRIBE or OPT OUT sent as the whole message, opts the person out of WhatsApp. Later WhatsApp sends to them are refused with `409 opted_out` until they send START. It does not stop texts or calls. See [WhatsApp → Opt-outs](/platform-api/whatsapp/#opt-outs).

### Fixed

- Octo no longer redeploys, edits or crawls for a deleted agent; a deleted agent stops answering inbound messages and calls even if a deployment still points at it.

### Changed

- **Corrected: texts are Twilio-only too.** The Errors page said single texts work on every provider. They do not: like SMS campaigns, API texts send from Twilio numbers only for now, and a Telnyx or Vonage `from` is refused (`400 from_not_sms_capable` on a text, `422 sender_provider_unsupported` on a campaign). If you planned to text from a Telnyx or Vonage number, use a Twilio number.
- **Corrected: the compliance acknowledgment gates campaign starts only.** The account reference said a send could be refused over it. Single texts, WhatsApp messages and calls never check it; only a campaign preview and start do.
- **Corrected: `message.received` is SMS only.** The Webhooks page listed it for "replies and inbound texts" with no channel. WhatsApp messages people send you are not events yet: read them with `GET /v1/conversations`. If you built a WhatsApp inbound handler on `message.received`, it will never fire.
- The shared trial number calls any phone number on your profile or a teammate's (`GET /v1/me` `user_phone`), not only your own; the Overview, Testing, Errors and Place a call pages, and the API's `fix`/`hint` text, now say so.
- `billed_credits` is the cost of an SMS; WhatsApp sends answer `null` (see WhatsApp pricing).
- `opted_out` on a text also covers a number on your do-not-contact list, not only one that texted STOP.
- MMS uploads take MOV and WebM videos as well as MP4; every video is sent as an MP4 of at most 500 KB.
- An SMS preview older than 30 minutes, or replaced by a newer one, reads `preview_expired`; a start from it is refused `409 preview_stale`.
- An `Idempotency-Key` collides only with a request of the same kind (the same route). `POST /v1/contacts`, `POST /v1/lists` (and adds to a list) and `POST /v1/conversations/{id}/messages` are keyed too, now listed in the Idempotency table.
- Authentication names the whole messaging API and links its own rate limits.
- MCP `start_campaign` needs a `preview_id` on every channel, unlike `/v1`, where WhatsApp and voice may start without one.
- The campaign draft examples send an `Idempotency-Key`; the SMS preview example is a `ready` preview with `samples`; the send examples show `text` and `media`.

Changes to the dashboard API (`/api`):

- `POST /api/lk/token` now requires a signed-in session and an agent of your workspace. It answers `401` without one, `403 session_required` for an API key, `404 agent_not_found` for an agent id that is not a live agent of your workspace, and `409 agent_disabled` for a turned-off agent.

## 2026-10-22 — Test calls let the agent ask you

### Changed

- A call with `source: "agent-test"` now keeps the ask-a-human tool on
  unless you pass `skipInputGathering: true`: when the person on the call
  asks something only you can answer, the question reaches you on the
  dashboard. Phone-menu navigation still defaults to off for test calls.

## 2026-10-22 — Any agent can call from the trial number

### Changed

- [`POST /v1/calls`](/platform-api/voice/) from the shared trial number
  (`pfn-…`) accepts any agent of your workspace as `agent`. The agent chosen
  for the trial number in the dashboard is now the default when `agent` is
  left out; naming a different agent of your workspace used to be
  `400 invalid_from`. The route guard and the trial caps are unchanged.
- MCP `list_phone_numbers` adds `deployed_agents`, `can_dial` and
  `inbound_agent` per voice and SMS channel, and `only_active` now keeps every
  number that can dial, whether or not an agent answers it. `outbound_agents`
  and `outbound_enabled` still return the same values for one more release;
  move to `deployed_agents` and `can_dial`.

## 2026-10-20 — Octo builds a list from an SMS campaign's recipients

### Added

- Octo tool `create_list_from_campaign`: a contact list of an SMS
  campaign's recipients, filtered by `status`, `clicked` and `replied`
  (for example everyone who got the text but did not click), with
  `previewOnly` to count first. People who opted out are always left out.
  Listed in the [tool catalog](/mcp/tools/). No `/v1` change.

## 2026-10-19 — Octo sends a WhatsApp template by name

### Added

- MCP `send_whatsapp_template` names its template by `templateName` (plus
  `language` when that name is approved in more than one language), as
  `POST /v1/messages` already does with `template.name`. `templateId` still
  works. A name that matches several templates, or none, is refused before
  any confirmation is asked for, listing the matches or the closest name.
- [Send a WhatsApp template](/platform-api/whatsapp/): how to send a
  template that has no variables, as a single message or in a campaign.

### Fixed

- [Run a campaign](/platform-api/campaigns/): the example preview id now has
  the launch hash, so a start copied from the guide no longer answers
  `409 preview_stale`. The guide also shows the `rev_…` id a WhatsApp or
  voice preview returns.

## 2026-10-19 — SMS confirmations follow the conversation's language

### Changed

- On `POST /v1/responses` and `POST /v1/chat/completions`, the text of a
  pending SMS campaign confirmation (people, segments, credits, the time it
  takes, a scheduled start, a re-count, a test send) is now written in the
  conversation's language, as the closing "Reply YES …" line already was.
  The action code, `YES`, the sender and every number are unchanged; numbers
  are formatted for the language ("1.240" in Portuguese), so do not parse
  the English sentence.

## 2026-10-19 — A deleted agent is an unknown agent

### Fixed

- A deleted agent's id is refused like an unknown one, and nothing is
  dialed: `POST /v1/calls` (`agent`) and `POST /v1/campaigns` /
  `PATCH /v1/campaigns/{id}` (`content.agent`) answer `404 agent_not_found`,
  the same body as for an id that names nothing. MCP `place_call`,
  `create_campaign_draft`, `preview_campaign` and `start_campaign` follow.
- A voice campaign whose agent was deleted after it was saved: a `PATCH` of
  its content answers `404 agent_not_found`, a preview lists
  `agent_not_found`, and a start refuses it. A campaign whose agent is
  deleted while it runs skips its remaining calls as `agent_disabled`.
- `POST /v1/calls` without `agent`, from a number whose own agent was
  deleted, answers `400 agent_required`, as for a number with no agent.

Changes to the dashboard API (`/api`):

- Agent ids are now checked against the workspace.
  `POST /api/conversations/click-to-call` answers
  `404 { error: "agent_not_found" }` for an `agentId` that is not a live
  agent of this workspace (was: the call was placed, or `400` / `409`), and
  accepts an `ag-…` id as well as a UUID.
- `POST /api/email-channel/{channelId}/threads/{threadId}/mode` and
  `POST /api/simulation/start` answer `404` for such an agent.

## 2026-10-19 — MCP can start campaigns again

### Fixed

- On the [messaging MCP server](/mcp/api-tools/), `preview_campaign` (SMS)
  and `start_campaign` answered `404 not_found` for every preview, so no
  campaign could be started through MCP. Preview ids gained a launch hash
  (`snap_…_<ms>_<hash>`), and the MCP server's id check had not learned
  the new shape. Nothing about the ids changes for REST callers.

## 2026-10-19 — A send linked to its inbox message, paged search, one primary per kind

### Added

- A [conversation message](/platform-api/inbox/) carries `message_id`, its
  `GET /v1/messages/{id}` id; a search match adds `api_message_id`. A
  [message](/platform-api/sms/#read-a-texts-status) adds `text` and `media`.
- `GET /v1/conversations/search` pages: newest first, with `after`.
- `GET /v1/contacts/{id}/channels` pages with `limit` and `after`.
- `GET /v1/senders` adds `sms.channel_id` and `voice.channel_id`.
- Preview samples add `contact_short_id` (the `ct-…` id; `contact_id`
  stays the UUID); the error envelope declares
  `channels`, `cap`, `used`, `remaining`, `resets_at`, `allowed_country`
  and `closes_at`.

### Changed

- A contact has one primary phone number and one primary email: making an
  email primary leaves the primary number as it is, and a number's SMS,
  calls and WhatsApp are one address. `primary` is what the contact's
  `phone` or `email` shows.
- A contact's `updated_at` moves when a number or email is added or
  removed, and on an opt-out or opt-in.
- Search results come newest first; `score` is the search index's
  relevance when it scored a match, else `0`.
- `{{contact.id}}` on a WhatsApp or AI-call campaign is
  `400 invalid_variable` on that variable, with a hint.

### Fixed

- A conversation opened by `POST /v1/messages` answers its `phone`, and
  every conversation fills `name`, `phone` and `email` from its contact.
- A preview with no audience lists `sender_not_cleared` too.
- An SMS campaign's `variables` keep the text's own `|fallback`.
- `invalid_rows` counts the `contact_ids` an import skipped.
- Example idempotency keys are unique; keys are per workspace.

## 2026-10-19 — A WhatsApp opt-out keeps its Idempotency-Key

- **[`POST /v1/messages`](/platform-api/whatsapp/) with
  `channel: "whatsapp"` now keeps a refusal that came after the message was
  stored**, and a retry with the same `Idempotency-Key` replays it
  (`Idempotency-Replayed: true`). That is `409 opted_out`, and the refusals
  for a number pending verification or disconnected, a suspended workspace
  or a blocked destination. Before, the key was freed, so a retry stored a
  second not-sent message. A refusal that stored nothing (the template's
  variables, billing, the channel, a sending rate) still frees the key.
  [How retries work](/platform-api/errors/#idempotency).

## 2026-10-19 — The inbox: conversations, replies, search

### Added

- **[Work the inbox](/platform-api/inbox/)**, a new guide to
  `/v1/conversations`.
- `GET /v1/conversations` lists your inbox on every channel, and
  `GET /v1/conversations/{id}` reads one, with its `summary` and `labels`.
- `GET /v1/conversations/{id}/messages` pages a conversation's messages,
  with who wrote each (`author`).
- `POST /v1/conversations/{id}/messages` replies in the thread, through the
  same checks as a reply from the inbox. On SMS it sends `message.sent` with
  `author: "api"`.
- `PATCH /v1/conversations/{id}` hands a conversation to a person, returns
  it to the AI agent, or closes it.
- `GET /v1/conversations/search` finds messages by their words.
- `POST /v1/calls/{id}/end` ends a call in progress, and
  `GET /v1/calls/{id}/voicemail` downloads its voicemail.
- Seven new [MCP API tools](/mcp/api-tools/) for the inbox, including
  `reply_to_conversation` and `end_call` behind `confirm: true`.

## 2026-10-18 — An auto-start nobody can receive keeps its draft

- **[`POST /v1/campaigns`](/platform-api/campaigns/) with `start: "auto"`
  and a WhatsApp or voice audience nobody can be sent to keeps the campaign
  as a draft**, as an SMS one already did. The answer is still
  `422 no_recipients` with `skipped_by_reason`, and now names the draft in
  `campaign_id` (with `short_id` and `state: "refused"`); before, no
  campaign was created. Fix the audience and start the draft, or delete it.
  A retry with the same `Idempotency-Key` answers the same refusal for the
  same draft and creates no second campaign; it used to create the campaign
  once the audience was fixed. `GET /v1/campaigns/{id}` reports the
  refusal in `refusal`.

## 2026-10-18 — Contacts: several numbers, tags, opt-outs, search

### Added

- **[Keep contacts](/platform-api/contacts/)**, a new guide to
  `/v1/contacts`.
- `GET /v1/contacts?query=` searches names, companies, tags, numbers and
  emails.
- A contact carries `tags`; `POST /v1/contacts` takes them, and
  `POST /v1/contacts/{id}/tags` and `DELETE /v1/contacts/{id}/tags/{tag}`
  change them. Tag workflows start for them.
- `POST /v1/contacts/{id}/dnc` opts a contact out of texts, calls,
  WhatsApp or email, or back in. An opt-in never clears the person's own
  STOP (`409 stopped_by_person`).
- `/v1/contacts/{id}/channels` lists, adds and removes a contact's phone
  numbers and emails.
- `GET /v1/contact-fields` lists the custom contact fields.
- `POST /v1/lists/{id}/contacts` takes `contact_ids`, contacts you already
  have.
- Ten new [MCP API tools](/mcp/api-tools/) for contacts and lists,
  including `delete_contact_list` behind `confirm: true`.

## 2026-10-18 — An auto-start nobody can receive is refused

- **[`POST /v1/campaigns`](/platform-api/campaigns/) with `start: "auto"`
  and a WhatsApp or voice audience nobody can be sent to is
  `422 no_recipients`** (with `skipped_by_reason`), and no campaign is
  created. A voice campaign used to complete empty; a WhatsApp one answered
  `400 audience_has_no_whatsapp_contacts`. `start: "draft"` is unchanged.
- An opt-out on a contact's old number stays on the number when
  `PATCH /v1/contacts/{id}` changes or removes it, however old the opt-out.

## 2026-10-18 — Contacts and lists, previews that run every start check

- **Breaking: `start` is required on
  [`POST /v1/campaigns`](/platform-api/campaigns/)** (`"draft"` or
  `"auto"`); without it the answer is `400 start_required` and nothing is
  created.
- **Breaking: an id of the wrong kind is `400 invalid_request`, not
  `404`**, on every campaign `{id}` route, `GET /v1/calls/{id}`,
  `GET /v1/messages/{id}`, the `campaign` filters, `/v1/media` and
  `/v1/recordings`. See [Ids](/platform-api/overview/#ids).
- **Breaking: [`/v1/events`, `/v1/media` and
  `/v1/recordings`](/platform-api/errors/#events-media-and-recordings)
  answer the `/v1` error envelope**, refuse unknown or repeated query
  parameters, and refuse an `after` that is not a whole number
  (`400 invalid_cursor`; it used to replay seven days).
- **Breaking: webhook endpoint `created_at` and `updated_at` are ISO 8601
  strings**, not Unix seconds. See [the webhooks
  reference](/platform-api/reference/webhooks/).
- **Breaking:** a WhatsApp template `id` with another template's `name` or
  `language` is `400`; a misspelled `audience.contacts[]` key is
  `400 unknown_field`; WhatsApp previews' `no_whatsapp` is
  `no_phone_number`; a WhatsApp or voice start that reaches nobody is
  `422 no_recipients`; `GET /v1/senders` has the new statuses
  `provider_unsupported` (SMS) and `trial_agent_not_chosen` (voice).
- **New: [Contacts](/platform-api/reference/contacts/) and
  [Contact lists](/platform-api/reference/lists/)** on `/v1`, and
  [Balance and compliance](/platform-api/reference/account/)
  (`GET /v1/balance`, `GET /v1/compliance`, `POST /v1/compliance/accept`).
  Six new [MCP API tools](/mcp/api-tools/) read them.
- **A [preview](/platform-api/reference/campaigns/#rules) runs every check
  its start makes**, lists every problem with a `hint`, and goes stale
  when the sender, template, agent, compliance acceptance or audience
  changes. Voice previews add `cost_estimate`; WhatsApp samples add
  `buttons`.
- A campaign reaches a contact through the number on any phone channel;
  WhatsApp and voice skip a recipient with a blank variable, as SMS does.
- `GET /v1/calls` filters by `to` and `status`; WhatsApp
  `GET /v1/messages` takes `from` and `campaign`.
- A new [Quickstart](/platform-api/quickstart/), and
  [Environments](/platform-api/overview/#environments) and
  [How the channels differ](/platform-api/overview/#how-the-channels-differ)
  on the overview.

## 2026-10-17 — Calls need an agent, v2 on the event stream

- **Behaviour change: [`POST /v1/calls`](/platform-api/voice/) without
  `agent`, from a number with no agent of its own, is `400 agent_required`**
  and dials nothing. It used to call the real person with a built-in test
  agent. Name an agent from `GET /v1/agents`.
- **[The event stream](/platform-api/events/#the-event-stream) sends v2
  with `?api_version=v2`**, the same body a v2 webhook endpoint receives.
  The default is still `2026-10-01`.
- Outbound test calls now send `call.ringing` before `call.answered`.
- [MCP API tools](/mcp/api-tools/): waits end within `wait_seconds`, and
  every method other than POST is `405`.
- `schedule.end_at` must be at least 1 ms after `start_at`, and a schedule
  reads back with the fraction of a second you wrote.
- [Reference](/platform-api/reference/): clearer `Idempotency-Key`, message
  and call fields, required fields first, a WhatsApp example, links to each
  page's error codes, and `paused_reason` values.

## 2026-10-17 — Messaging API docs as task guides, and a field reference

- **Task guides:** [Send a text](/platform-api/sms/),
  [Send a WhatsApp template](/platform-api/whatsapp/),
  [Place a call](/platform-api/voice/), [Run a campaign](/platform-api/campaigns/)
  [Receive webhooks](/platform-api/events/) and the new
  [Test without spending](/platform-api/testing/). Each is short
  and links to the one page that states each rule.
- **The Quickstart is now [Call your own phone](/platform-api/voice/#call-your-own-phone).**
  The old URL still works.
- **[Errors](/platform-api/errors/) says what every code means and how to
  fix it**, one table per resource, with every rate limit in
  [Rate limits](/platform-api/errors/#rate-limits).
- **[Reference](/platform-api/reference/) is generated from the OpenAPI
  file:** every field of every endpoint, with its type, values and limits,
  on one page per resource. The rules that span several campaign fields
  are on [Reference → Campaigns](/platform-api/reference/campaigns/#rules).
- **[Events](/platform-api/reference/events/) lists every v2 event type field
  by field**, with what each event promises. The legacy format's tables are on
  [Events, 2026-10-01 format](/platform-api/reference/events-2026-10-01/).
- The webhook mechanics (registering, verifying, retries, the event stream)
  are documented once, in Receive webhooks; the OpenAI-compatible
  [Events & webhooks](/openai-api/events/) page keeps the event catalog and
  links there.
## 2026-10-17 — Webhook payloads v2

- **Payload versions per endpoint.** Every webhook endpoint has an
  `api_version`. Endpoints that existed before this release (v1.84.0.0) keep `2026-10-01` and receive
  exactly the same bytes as before. A new endpoint (API or dashboard) gets
  `v2` unless you ask for `"api_version": "2026-10-01"`; `PATCH` switches it.
  The `GET /v1/events` stream still sends `2026-10-01`. See
  [Payload versions](/platform-api/events/#payload-versions), with an upgrade
  recipe.
- **v2 events use the `/v1` words and ids**: `object`, `api_version` and
  `livemode` on the envelope; `channel` everywhere; `canceled`; message events
  keyed by the `/v1` message id (`message.sent` adds `message_ids` for a split
  reply); one `message.status_updated` shape for SMS and WhatsApp with
  `error.message` and `read_at`; `campaign.completed` `counts` in the
  `GET /v1/campaigns/{id}` buckets, as of completion; short ids and UUIDs side by side, `sender_id`;
  and your own `metadata` on message, call and campaign events. The OpenAPI
  document lists every v2 event under `webhooks`.
- **`/v1/webhooks` answers like the rest of `/v1`** (breaking): the `/v1`
  error envelope with `code` and `request_id`, strict bodies and query
  strings (`400 unknown_field` / `unknown_parameter`), a paged list
  (`has_more`, `next_cursor`, `after`; it used to stop at 200 endpoints),
  `405` with `Allow`, `400` for an id of another kind, and the per-key
  request limit of the messaging routes (300 reads, 100 writes a second)
  instead of 60 a minute.

## 2026-10-17 — The Messaging API as MCP tools

- **New MCP server: [API tools](/mcp/api-tools/) at
  `https://api.omniocto.com/mcp/api`.** 13 tools over the Messaging API
  (`send_message`, `place_call`, `get_message`, `get_call`,
  `create_campaign_draft`, `preview_campaign`, `start_campaign`,
  `get_campaign`, `pause_campaign`, `list_senders`, `list_templates`,
  `list_agents`, `list_contact_lists`), with a workspace API key. Each tool
  makes the `/v1` request you would make, so errors, rate limits and
  idempotency are the API's own.
- `send_message`, `place_call` and `start_campaign` need `confirm: true` and
  an `idempotency_key`; without them they answer `confirmation_required` and
  send nothing. `start_campaign` needs a `preview_id`.
  `create_campaign_draft` needs an `idempotency_key` too.
- There is no test mode yet: every message and call is live.
- `/mcp` and `/mcp/tools` are unchanged.

## 2026-10-17 — Messaging API: rate limits, schedules that end, small fixes

- **Rate limits per API key** (breaking: a `429` is now possible): the
  messaging routes take 300 reads (`GET`) and 100 writes a second per key.
  Every answer carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
  `X-RateLimit-Reset` (Unix seconds); past the limit the answer is
  `429 rate_limited` with `Retry-After`. SMS sends keep their own, stricter
  send limit (10 a second), whose `429` carries its own `Retry-After`. See
  [Rate limits](/platform-api/errors/#rate-limits).
- **`schedule.end_at` is enforced** (breaking): at `end_at` a campaign starts
  no new text or call, a call or text under way finishes, everyone not reached
  reads `skipped`, and the campaign turns `completed` with the new
  `completed_reason: "schedule_ended"`. A date is the end of that day in `tz`.
  `end_at` must be after `start_at` and now, and a start after it has passed
  is `400 invalid_schedule` on `schedule.end_at`. A voice recipient the
  campaign never called (the end, DNC) reads `skipped`.
- **Schedules are checked** (breaking: some schedules accepted before are now
  `400`): `hours` `start` is `00:00`–`23:59` and `end` `00:00`–`24:00`
  (`25:99` was taken); a window that starts and ends at the same time is
  `invalid_schedule`; a later `start` than `end` is an overnight window. A
  `start_at` with an offset is taken (it was refused). `start_at` and
  `end_at` read back as instants with the zone's offset.
- **Cursors belong to their filters** (breaking: every cursor answered before
  this release is refused once; start again without `after`): a cursor reused
  with other filters (`to`, `status`, `campaign`, the times, …) is
  `400 invalid_cursor` with a hint. The same filter written another way keeps
  the cursor.
- **`field_not_for_channel`**: a field of another channel's campaigns
  (`content.mission` on SMS, `limits.spend_cap_credits` on voice, `retry` on
  WhatsApp, …) is one `400` on create and PATCH, with `param` and `fields`
  (it was `unknown_field`, `field_not_editable` or `invalid_request`).
- **Templates list their words**: `GET /v1/templates` answers `body` and
  `header_text`.
- **A call's `mission` takes 4,000 characters** (it was 500), as a campaign's.
- `unmapped_variable` puts its example in `hint`, and only a reference that
  works (`"header.name": "{{contact.name}}"`, never `{{contact.header.name}}`);
  a position or a copy code gets none.
- `?to=+1…` with an unencoded `+` (read as a space) gets a `hint` saying so,
  with the `%2B` form (`to`, `from`).
- PATCH `variables` merges in one database write: two PATCHes of different
  names at the same moment both land.

## 2026-10-17 — Messaging API: list everything sent, `metadata`, ids that join

- **`GET /v1/messages` lists everything you sent** on a channel: `to` and
  `campaign` are optional filters now, on SMS and WhatsApp. Both channels come
  newest first by `created_at`, ties by `id`. **Breaking for SMS:** the SMS
  list was ordered by hand-over time (texts not handed over yet first); it is
  now `created_at`, newest first, and a cursor from the old order is refused
  once with `400 invalid_cursor` (start again without `after`). WhatsApp and
  call cursors keep working.
- **`metadata` on single sends and calls**: `POST /v1/messages` (both
  channels) and `POST /v1/calls` take `metadata`, stored and returned on the
  message or call. One limit everywhere: string values only, at most 20 keys,
  keys 1 to 40 characters, values at most 500. **Campaign `metadata` now has
  the same limits** (it had none but the value length); past one is
  `400 invalid_request` with `param` `metadata` or `metadata.<key>`. `{}` is
  the same as no `metadata`, including for an `Idempotency-Key` retry.
- **Ids that join**: every call object carries `short_id` (`cv-…`, the id the
  dashboard shows; `id` is unchanged). Campaign recipients carry `message_id`
  (SMS and WhatsApp: the `id` `GET /v1/messages` answers) and, on voice,
  `call_short_id`. `message_id` is `null` for WhatsApp messages sent before
  2026-10-16 and when the message could not be recorded.

## 2026-10-16 — Calls: `call.*` events and an outcome for every call

- **Call webhooks**: `call.ringing`, `call.answered`, then exactly one of
  `call.completed` (status `completed`, `voicemail`, `no_answer` or `busy`)
  or `call.failed` (a system or carrier failure). Each carries `call_id`
  (the call's `id`), `conversation_id`, `direction`, `from`, `to`,
  `agent_id`, `campaign_id`, `status` and `occurred_at`; `call.answered`
  adds `answered_at`; the terminal two add `answered_at`, `outcome`,
  `duration_sec`, `ended_at` and
  `disconnect_reason`. A campaign's calls reach only endpoints that name the
  types. See [Call events](/platform-api/events/#call-events).
- **Every ended call gets an `outcome`** within 5 minutes, `unknown` when
  there was nothing to classify (a new `outcome` word; inbound calls, which
  are not classified, read it too). A call that stops reporting is closed as
  `failed` / `unknown`, normally within 20 minutes, up to 2 hours when the
  liveness signal is unavailable or the call ran on an older voice agent.
  After the terminal event a call's `status` and `outcome` never change.
- A call nobody answered (SIP no-answer) or that was declined now reads
  `no_answer` / `busy` instead of `failed`.

## 2026-10-16 — Messaging API: PATCH merges `variables`, campaigns name their audience

- **`PATCH /v1/campaigns/{id}` merges `variables`**: a name you send is set,
  `"name": null` removes it, and the rest keep their values (it replaced the
  whole map). A PATCH that changes the template, text, mission or agent and
  leaves names with no placeholder is `400 unknown_variable` with every such
  name in `fields`; remove them with `null` in the same request. The guide
  has one table of what each field does on PATCH.
- **Every campaign answers `audience`**: `{ "type": "list" | "segment" | null, "id" }`.
  The create answer's import counts moved to `audience.import`. `refusal` is
  now `{ status, error }`, the `/v1` error a start would answer.
- **Every problem at once**: a preview lists every unfilled placeholder on
  every channel, and `unknown_variable` / `unmapped_variable` carry `fields`.
  A ready voice preview has `samples` (the mission and greeting filled in).
- **Cursors belong to their list**: a cursor of another list (or of another
  campaign's recipients) is `400 invalid_cursor`. Earlier cursors, and the
  raw recipients cursors, are no longer taken: start from the first page.
- **The shared trial number** as the `from` of a text, a WhatsApp message or a
  campaign is `422 trial_sender_not_allowed` (was `400 invalid_from`).
- An unknown key inside `audience` is `400 unknown_field` with a did-you-mean;
  an id of the wrong kind (`sg-…` as `audience.list`) is
  `400 invalid_request` naming the prefix. A template name in more than one
  language without `language` is `400 invalid_request` (was `404`). A voice
  draft may leave out `content`. Call `outcome` values are listed in
  [Place calls](/platform-api/voice/#read-a-call). `413` carries `request_id`.

## 2026-10-16 — Messaging API: lists, segments, cursors and stricter previews

- **`GET /v1/lists` and `GET /v1/segments`** list the `cl-…` and `sg-…` ids a
  campaign's `audience` takes. `GET /v1/agents` and `GET /v1/templates` now
  page with `limit` (default 50) and `after`, like every other list; agents
  carry `uuid` and senders `channel_uuids`, the ids events use. Templates
  carry `variable_keys`, the keys a campaign's `variables` takes.
- **An SMS preview goes stale on any edit**, its name included, as WhatsApp and
  voice previews do. SMS preview ids are now `snap_…`.
- **Starting a finished campaign** is `409 campaign_completed` (or
  `campaign_canceled`), before any preview check; previewing a campaign that
  is not a draft or paused is `409 campaign_not_previewable`.
- **A preview reports every problem** it finds before counting (`error.also`).
- **`retry.on` no longer takes `busy`** (it was dropped silently: a busy line
  counts as `no_answer`). **`content.voicemail_drop`** is
  `400 voicemail_drop_unavailable` (it was saved but could not play).
- Campaigns answer `started_at` and `completed_at`; `variables` is `{}` when
  empty; list items have no channel block. A replayed create answers the
  first answer word for word; an SMS message's `created_at` is the same on
  create, replay and read. A write with a query parameter is
  `400 unknown_parameter`. `GET /v1/me` adds `user_phone`.

## 2026-10-15 — The messaging API: texts, WhatsApp, calls and campaigns under `/v1`

- **One API for every channel.** `POST /v1/messages` sends a text
  (`"channel": "sms"`) or an approved WhatsApp template (`"channel": "whatsapp"`),
  `POST /v1/calls` has an AI agent call a number with a `mission`, and
  `POST /v1/campaigns` sends texts, templates or calls to a whole audience.
  `GET /v1/senders` lists your numbers with their readiness on each channel,
  and `GET /v1/me` names the workspace and key. Bodies are snake_case and
  strict (`400 unknown_field`), every object has one `status`, and every
  refusal is one envelope, `{ "error": { "type", "code", "message", "param", … } }`.
  Start with the [Quickstart](/platform-api/quickstart/): an AI agent calls
  your own phone from the shared trial number.
- **Campaigns share one body and one lifecycle.** `content` is the only part
  that changes by channel. `"start": "auto"` (the default) checks and starts
  in one request; `"start": "draft"` lets you preview and then start a
  preview. SMS previews freeze the audience; WhatsApp and voice previews do
  not, and starting one after the campaign was edited is `409 preview_stale`.
  `variables` are template strings such as `"{{contact.shoe_size|unknown}}"`.
  See [Campaigns](/platform-api/campaigns/).
- **One rule for placeholders on every channel.** A `variables` name the
  content has no placeholder for is `400 unknown_variable` with a
  did-you-mean. A placeholder nothing fills makes the preview
  `status: "failed"` (`unmapped_variable`, `field`), and a start, or a create
  that starts, `422 unmapped_variable`: SMS, WhatsApp and voice alike.
- **Edits check what a create checks.** `PATCH` refuses another number's
  template (`422 template_not_on_sender`), an unknown agent or template, and
  can set a draft's `audience.list` or `audience.segment`. A start missing
  something answers `422 launch_fields_missing` with `missing` in `/v1` field
  names. Campaigns return `limits` and `retry` as written, and take a
  WhatsApp template by `name` and `language` as a single send does.
- **Strict query strings and clearer refusals.** An unknown query parameter is
  `400 unknown_parameter`; `GET /v1/templates` and `GET /v1/agents` take
  `limit`. Did-you-mean hints use edit distance at the field's own path,
  validation messages are plain sentences, a campaign contact's bad `phone`
  is `400 invalid_number` with its E.164 form, `schedule.tz` must be an IANA
  zone, and every `/v1` response (an unknown path included) carries
  `X-Request-Id` and, for errors, the JSON envelope.
- **WhatsApp statuses are events.** `message.status_updated` now fires for
  WhatsApp messages too (`sent`, `delivered`, `read`, `failed`), and every
  payload carries `channel` and the public `status`.
- **Campaign events for every channel.** WhatsApp and voice campaigns now send
  `campaign.started`, `campaign.paused`, `campaign.resumed`,
  `campaign.canceled` and `campaign.completed`. `campaign_type` can be
  `whatsapp_template`, `voice_ai` or `voicemail_drop`, and `billed_credits` is
  `null` for those. See [Delivery & webhooks](/platform-api/events/).
- **New guides:** [Send SMS](/platform-api/sms/),
  [Send WhatsApp](/platform-api/whatsapp/), [Place calls](/platform-api/voice/),
  [Campaigns](/platform-api/campaigns/), [Delivery & webhooks](/platform-api/events/)
  and [Errors](/platform-api/errors/). The dashboard's own `/api` routes are
  still there for the dashboard, in the
  [Dashboard API reference](/openapi/platform/).

Changes to the dashboard API (`/api`):

- `POST /api/wa-templates/{id}/send` answers `402` (was `422`) for
  `workspace_suspended` and `route_blocked`.
- `POST /api/campaigns` answers `409 create_in_progress` (was
  `idempotency_conflict`) while a create with the same `Idempotency-Key` is
  still running. `POST /api/seax/campaigns` is unchanged.
- `POST /api/conversations/click-to-call` called with an API key defaults
  `source` to `manual`.
- `message.status_updated` payloads gain `channel` and `status`, and a
  WhatsApp variant (`channel: "whatsapp"`).
- `campaign.*` events also fire for WhatsApp and voice campaigns, with
  `campaign_type` `whatsapp_template`, `voice_ai` or `voicemail_drop`, and
  `billed_credits` `null` for those.

## 2026-10-14 — A single text's `to` must be E.164; compliance refusals carry `code`

- **`POST /api/messaging/sends` refuses a `to` that is not E.164.** A number
  such as `415-555-0100` or `+1 415 555 0100` used to be read in the sender's
  country, sent and billed. It is now `400 invalid_number` with
  `param: "to"`, and nothing is sent or charged. When the number is clear,
  the `hint` says which E.164 we think you meant
  (`Did you mean +14155550100?`). Campaign `contacts[].phone` was already
  strict; a `contactsCsv` file is still read loosely.
- **`/api/workspaces/{id}/outbound-compliance` refusals carry `code`.** A
  stale version is `409` with `code: "version_mismatch"` (`error` is
  unchanged); the other refusals there carry `invalid_request`,
  `insufficient_role`, `forbidden` or `not_found`.
- **[Send SMS with the API](/platform-api/sms/) now shows** the phone-number
  list and billing summary as they answer, how to tell a number you brought
  from your own Twilio account (`mode: "byo"`), `workspaceId` for the
  compliance call, one rule for naming the sender, the exact
  `Idempotency-Key` characters, `400 empty_audience_input`, the full webhook
  endpoint object, listing, changing (`event_types` replaces the list) and
  deleting endpoints, the `replay_truncated` frame and how to start the event
  stream near the present.

## 2026-10-13 — WhatsApp messages from your own account cost 0.1 credits

- **Every WhatsApp message sent from your own WhatsApp Business account now
  costs 0.1 credits**, the same as a text from your own Twilio account. That
  covers template sends, campaign messages, inbox and agent replies, and
  replies inside Meta's free 24-hour window. Meta still bills its own charges
  to you directly. The usage line is `whatsapp.byo_platform`, shown as
  "WhatsApp platform fee (your WhatsApp account)".
- **A WhatsApp campaign the balance cannot cover does not start.** Starting
  one (`POST /api/campaigns/{id}/start`, or `POST /api/campaigns` with a
  launch) answers `402 insufficient_credits` with `shortCredits` when the
  balance is below 0.1 × recipients. A create that launches also returns the
  `campaignId` of the draft it wrote: start it again once the balance covers
  it.

## 2026-10-11 — Restoring a BYO number's call routing refuses what it cannot do safely

- `POST /api/phone-numbers/{id}/voice/restore-previous` now answers with the
  release's codes when it cannot give the calls back under the number's own
  record: `409 release_in_progress` (another change to the number's calls is
  running; retry in a minute; this replaces `409 call_setup_in_progress` here),
  `503 release_not_ready` (a passing problem; retry), and
  `503 routing_unrecoverable` (what is needed to record the give-back is
  missing; contact support). When the number's record says its calls were
  already given back it answers `409 nothing_to_restore`, and when it says
  they were moved elsewhere since, `409 call_routing_changed`. Nothing is
  changed on any of them. The body is `{ "error", "message" }`; show
  `message` to people.

## 2026-10-11 — The integration webhook is retired; its events are webhook events

- **Two more webhook and `/v1/events` types:** `meta_response.completed` (a
  background `POST /v1/responses` finished or failed) and `meta_chat.updated`
  (a chat with Octo got a new reply). `"*"` endpoints receive them: answer
  2xx to any `type` you do not handle. To receive only these, list them by
  name. See [Octo events](/openai-api/events/#octo-events).
- **Their payloads are snake_case now** (`response_id`, `conversation_id`,
  `message_count`, …), like every other event, and a failed response's event
  carries its `error`.
- **`/api/users/me/integrations` is retired.** `GET`, `PUT` and `PATCH` answer
  `410` with `{ "error": "integration_webhook_retired", "message", "docs_url" }`.
  That one-URL webhook had no retries or delivery history; add a webhook
  under Settings → API Keys & Webhooks or with `POST /v1/webhooks` instead.
  Its two call-session events are not carried over: nothing sends them any
  more.
- **If you received `meta_response.completed` on the old webhook:** add a
  webhook endpoint subscribed to it (Settings → API Keys & Webhooks, or
  `POST /v1/webhooks`). The body is now the standard event envelope (`id`,
  `type`, `workspace_id`, `created_at`, `seq`, `data`) instead of the old
  `{ event, occurredAt, payload }`, and it is signed like every other webhook
  (`webhook-id`, `webhook-timestamp`, `webhook-signature`, keyed by the
  endpoint's own secret) instead of `x-webhook-signature: sha256=…`. See
  [Verify a delivery](/openai-api/events/#verify-a-delivery).

## 2026-10-10 — Webhooks in the dashboard; change a secret, send a test event

- **Webhooks are owners and admins only.** `/v1/webhooks` now refuses a key
  whose owner has the Agent role in the workspace with `403
  insufficient_role`, on every route. A webhook receives every event of the
  workspace, so the same rule applies in the dashboard.
- **`GET /v1/events` is owners and admins only too**, for the same reason: the
  feed carries every event of the workspace, message text included. An
  Agent-role key gets `403 insufficient_role`.
- **`POST /v1/webhooks/{id}/rotate-secret`** returns a new signing secret,
  once. The old one stops working at once; deliveries after the change,
  retries included, are signed with the new one.
- **`POST /v1/webhooks/{id}/test`** sends one sample `webhook.test` event and
  returns what your server answered (`status`, `duration_ms`, the start of the
  reply). Nothing is stored or counted; 10 per workspace per minute. See
  [Send a test event](/openai-api/events/#send-a-test-event).
- **A webhook `url` is at most 2,048 characters.**
- **Manage webhooks without the API** under Settings → API Keys & Webhooks
  (the page was called API Keys): add, pause, turn back on, change the secret,
  test, and read the last 7 days of deliveries.
- **A lost campaign event is now written later.** If a `campaign.*` status
  event could not be written when the campaign changed, it is written within
  about 15 minutes, once. Its `*_at` time is when it was written, not when the
  campaign changed, and a late `campaign.resumed` has `previous_reason: null`.
  `campaign.waiting` and `campaign.window_opened` are not repaired;
  `GET /api/campaigns/{id}` stays the source of truth.

## 2026-10-10 — Campaign events for sending hours

- **Two more webhook and `/v1/events` types:** `campaign.waiting` (an SMS
  campaign reached the end of its sending hours, or started outside them, and
  waits; `resumes_at` says when its hours next start and `tz` the time zone
  they are set in) and `campaign.window_opened` (its hours started again and it
  goes on sending). Waiting is not a pause: the campaign's `status` stays
  `queued` or `running`. A pause or cancel ends the wait: the campaign sends
  its own event and that wait never gets a `campaign.window_opened`; resumed
  while its hours are still closed, it sends a new `campaign.waiting`. Both
  events can be up to about a minute after the hours end or start. See
  [Campaign events](/platform-api/events/#campaign-events).
- **`"*"` endpoints receive them too**, like the other `campaign.*` events.
  Answer 2xx to any `type` you do not handle.

## 2026-10-10 — Campaign events: know when an SMS campaign stops

- **Five new webhook and `/v1/events` types:** `campaign.started`,
  `campaign.paused`, `campaign.resumed`, `campaign.completed` and
  `campaign.canceled`, one per status change of an SMS campaign. A pause says
  why (`reason`: out of credits, its credit limit, the carrier's daily limit,
  too many failures, the carrier refusing the number, a needed review, and so
  on) and what to do (`message`). `campaign.completed` carries the final
  totals and the credits charged. List them by name to receive only these.
  See [Campaign events](/platform-api/events/#campaign-events).
- **Existing `"*"` endpoints start receiving them.** An endpoint subscribed to
  everything gets these five types from now on: ignore (and answer 2xx to) any
  `type` you do not handle, or a receiver that rejects unknown types will have
  its deliveries given up and, after 20 in a row, be disabled.

## 2026-10-10 — Importing your own Twilio number asks before taking its calls

- **`POST /api/phone-numbers/byo` can answer `409 call_routing_choice_needed`.**
  When the number's incoming calls already go somewhere (a voice URL, a TwiML
  app or another SIP trunk), the import no longer moves them to Omni Octo
  unasked. The 409 names where they go (`currentVoiceRouting`; the full URL for
  owners and admins only) and suggests `defaultCallRouting: "texts_only"`.
  Send `callRouting: "texts_only"` (texts here, calls left alone) or
  `"texts_and_calls"` (owners and admins; the current call setup is saved first
  and can be restored). Before, such an import answered 201. A number whose
  calls go nowhere imports as before. See
  the [Dashboard API reference](/openapi/platform/).
- **Other new answers on that route:** `403 calls_takeover_needs_admin`,
  `409 call_routing_changed` (the setup changed in Twilio during the import;
  read it again), `409 call_setup_in_progress`, `503 call_routing_recovery_failed`.
- **A texts-only number cannot place calls.** Calls and voice campaigns from it
  are refused with `calls_not_routed`.
- **Releasing an imported number** gives back its call setup as well as its SMS
  webhook (`callRouting.outcome` in the DELETE response).

## 2026-10-10 — SMS docs match the shipped behaviour; `spend.recorded` in credits

- **`spend.recorded` fields are now in credits.** `amount_usd` is now
  `amount_credits` and `balance_usd` is now `balance_credits` (numbers, in
  workspace credits). Nothing emits this event yet; update a subscriber
  before it does.
- **SMS prices and reports are credits only.** No SMS response carries a
  dollar amount. A number brought from your own Twilio account is billed by
  Twilio separately (`byoCarrier: true` marks it).
- **`GET /api/campaigns/{id}/records` (SMS).** New `sort`, `dir` and sealed
  `after` paging (`400 invalid_cursor`; de-duplicate by `id`), plus each row's
  latest reply (`replyText`, `lastReplyAt`) and `clicks`.
- **Clicks.** Scanner and preview fetches do not count, and repeat visits
  within 30 minutes are one click.
- **`not_receiving` numbers** (incoming texts diverted to a Twilio Messaging
  Service) cannot send: `422 sender_not_cleared` from the API
  (`senderStatus` on a single send, `status` on a campaign start). Octo's
  single-text tool answers `sender_not_receiving`; a workflow `sms.send` step
  is `skipped` with that reason.
- **Scheduling.** `schedule.startAt` as a day or an instant with offset;
  Octo and MCP tools take `schedule.startLocal` plus an IANA `tz` and refuse a
  past start.

## 2026-10-03 — MCP tools for Dialog Insight batch runs

- **New MCP tool: `dialog_insight.analyze_batch`.** Runs a Dialog Insight over
  many existing conversations in the background. Pass `dialog_insight_id` and
  exactly one of `conversation_ids` (up to 1,000), `campaign_id` (voice
  campaigns only) or `agent_id` (optionally narrowed with `activity_since`, an
  ISO 8601 instant, to conversations active since then; refused with the other
  selectors). Every selector takes only conversations that have ended: closed,
  a finished call, or a chat with no activity for 30 minutes, so a chat that
  went quiet counts even if it is still open. A live one (still in a call, or
  a chat with activity in the last 30 minutes) is skipped and analysed
  automatically when it ends; live ids in `conversation_ids` are counted in
  `excluded_live`. `rerun_analyzed` (default `false`) re-runs conversations
  the insight already analysed; a re-run can also remove labels that no longer
  apply. `trigger_workflows` (default `false`) fires `on_label` workflows for
  the labels the job adds or removes. With `false` the labels change silently:
  neither `on: applied` nor `on: removed` workflows fire, then or later. It
  costs LLM credits per conversation and is `spends_money`, so over
  `/mcp/tools` it answers `confirmation_required`, over `/mcp` it waits for a
  human YES, and the stateless `/v1` API does not offer it. The confirmation
  states how many ended conversations the selector reaches; if that number
  grows by more than 10% before the human confirms, the confirmation is
  refused and asked again. A request with zero or several selectors, or
  `activity_since` without `agent_id`, is refused before it is parked. While a
  job for the same insight and agent (or campaign), or an Octo job for the
  same insight over exactly the same conversation ids, is queued or running,
  it creates nothing and returns `code: "batch_already_open"` with
  `existing_batch: { id, short_id }`.
- **`dialog_insight.analyze_batch` reports what the job will actually do.**
  The result's `batch.status` is the job's real status: `queued`, or
  `completed` when this insight had already analysed every selected
  conversation (nothing to run, nothing billed, no notification). `batch`
  also carries `already_analyzed` (skipped at creation, not billed) and
  `will_analyze` (`item_count − already_analyzed`, an upper bound: a
  conversation with no transcript is skipped too). A queued job started by a
  signed-in user adds a `notification` line: the starter gets an in-app
  notification when it finishes; jobs started by an API key with no user get
  none. Past the workspace's open-jobs limit it answers
  `code: "too_many_open_batches"` with `open_batches`, `limit` and a
  `next_step`; nothing is created, and retrying before a job finishes is
  refused the same way.
- **`dry_run: true` on `dialog_insight.analyze_batch`** creates nothing and
  costs nothing. It returns `ended`, `already_analyzed`, `will_analyze` and
  `live` for the selector, and refuses an unknown insight or agent and a
  missing or non-voice campaign like the real run. A dry run is `safe`, so over
  `/mcp/tools` and `/mcp` it runs without a confirmation (`/v1` still does not
  offer the tool, whose static class is `spends_money`). Any other value of
  `dry_run` is the real, billed run.
- **The confirmation is more precise.** It now says "up to K will be analysed
  (N ended; M already analysed by this insight are skipped)". The drift check
  still compares N. A `campaign_id` that is missing or not a voice campaign is
  refused before anything is parked.
- **`dialog_insight.batch_status`** adds `skipped_by_reason`
  (`already_analyzed`, `no_transcript`, `not_ended`). An attach-time job is
  labelled as the analysis of the last 48 hours or of all past
  conversations. When operators have paused batch analysis platform-wide,
  the result has `paused: true` and a `paused_note`: open jobs wait in the
  queue and resume on their own.
- **New MCP tool: `dialog_insight.batch_status`** (`safe`). Takes a job id
  or `dib-…` short id and returns the job (including `origin`: `api`, `octo`
  or `attach_dialog`, and `activity_since`), counts by item state (`skipped`
  includes conversations that were live again when their turn came, reason
  `not_ended`), and the first 50 failed conversations with their
  `reason_code`; without `batch_id` it lists the 10 most recent jobs with
  their insight's name. There is no MCP tool to cancel a job: a started job
  runs to the end, and the workspace's monthly LLM budget (`llm.check_budget`
  / `llm.set_budget`) stops it with status `stopped_over_budget` if spend
  runs out.

## 2026-10-03 — Analyse an agent's past conversations with a Dialog Insight

- **A workspace can have at most 5 batch jobs queued or running at once.** A
  request past it — `POST /api/dialog-insight-batches`, or `PATCH
  /api/agents/{id}` with `dialogInsightScope: "analyze_history"` or
  `"all_history"` — answers `429` with `code: "too_many_open_batches"`,
  `openBatches` and `limit`, and the message "This workspace already has N
  analysis jobs queued or running. Try again when they finish." Nothing is
  created, and an attach attaches nothing. Retry once a job finishes. A request
  whose conversations this insight has all analysed already (a job created
  `completed`) is never refused. Same-insight duplicates still answer `409`
  `batch_already_open` first. The `PATCH /api/agents/{id}` refusal carries
  `openBatches` and `limit` too, like the `POST`.
- **The history attach's `dialogInsightBatch` adds `status`**: `"queued"`, or
  `"completed"` when every conversation was already analysed (nothing to run).
  It is now `{ id, shortId, status, itemCount, alreadyAnalyzed }`.
- **New failed-item reason `llm_throttled_exhausted`** (in a job's failed
  items and `GET /api/dialog-insight-batches/{id}`): the AI provider was still
  rate limiting or overloaded on the conversation's 30th try, about 50 minutes
  of continuous refusals. Run the conversation again in a later job. Until
  then a busy provider only slows a job down; it never fails a conversation or
  switches models.

- **New value for `dialogInsightScope` on `PATCH /api/agents/{id}`:
  `all_history`.** Attaches the insight (new conversations are analysed from
  now on, like `from_now_on`) and, in the same request, starts a background job
  that analyses every past conversation of the agent. It costs LLM credits.
  The response adds `dialogInsightBatch: { id, shortId, itemCount }`, or `null`
  when the agent has no past conversation. Attaching and starting the job
  happen together: if either fails, neither happens. Only conversations that have
  ended (as below) are included; a live one is analysed automatically when it ends. While
  that job is queued or running, another `all_history` attach of the same
  insight to the same agent is a `409` (`code: "batch_already_open"`).
- **`dialogInsightScope: "analyze_history"` is a background job now, like
  `all_history`.** It attaches the insight (automatic analysis from now on) and,
  in the same request, starts a job over the agent's conversations that ended
  with activity in the last 48 hours. The response adds the same
  `dialogInsightBatch` field. Before, it moved the attachment's start time back
  48 hours and left the catch-up to the fallback sweep, which always fired
  workflows. **No scope back-dates the attachment any more**: every scope
  starts automatic analysis at the moment of the attach. While either history
  job is queued or running, another history attach of the same insight to the
  same agent is a `409` (`batch_already_open`).
- **New optional field `triggerWorkflows` (boolean, default `false`), with
  `dialogInsightScope: "analyze_history"` or `"all_history"`.** `true` fires
  `on_label` workflows for the labels the job writes; labels are written either
  way. Sent with `from_now_on` or with no scope it is a `400` with
  `code: "trigger_workflows_without_history_scope"`.
- **`POST /api/dialog-insight-batches` accepts `activitySince`** (ISO 8601, with
  `agentId` only): the job takes only the agent's conversations whose last
  activity is at or after that instant. With `conversationIds` or `campaignId`
  it is a `400` (`activity_since_requires_agent`). Jobs report it back as
  `activitySince` (`null` when unset).
- **`GET /api/agents/{id}/dialog-insight/backfill-estimate` takes an optional
  `dialogInsightId` query parameter (UUID or `di-…`) and returns three new
  fields.** `allHistory` (what `all_history` would select: every ended
  conversation) and `last48h` (what `analyze_history` would select: ended, with
  activity in the last 48 hours) are each `{ ended, alreadyAnalyzed,
  willAnalyze }`, where `alreadyAnalyzed` counts conversations with an
  up-to-date result from that insight, which the job skips. Both are `null`
  without `dialogInsightId`. `live` counts conversations that have not ended.
  An unknown `dialogInsightId` is a `404` (`dialog_insight_not_found`). The
  existing fields are unchanged but deprecated: they describe what the
  automatic paths could reach, which no longer predicts either option.
- **`dialogInsightScope` now accepts `from_now_on`, `analyze_history` and
  `all_history`**; any other value is a `400` naming the three.
- **`POST /api/conversations/{id}/analyze` accepts `triggerWorkflows`
  (boolean, default `false`).** Re-analysing a conversation no longer fires
  `on_label` workflows (alert emails and the like) unless you send `true`;
  labels are written either way. Before, every manual analysis fired them. The
  `force` field is deprecated and ignored: the endpoint always re-runs.
- **Batch jobs (`POST /api/dialog-insight-batches`) take only conversations
  that have ended, for every selector.** Ended means closed, a finished call,
  or 30 minutes with no activity — the same moment automatic analysis treats a
  chat as over, so a WhatsApp or webchat conversation that went quiet is
  included even though it is still open. A `conversationIds` entry that has not
  ended gets no item; the create response's new `excludedLive` field counts
  them (always `0` for `campaignId` / `agentId`), and when every id is live the
  response is the zero-items `200`. A conversation the customer writes to again
  before its turn comes is skipped with `reasonCode: "not_ended"`. Each is
  analysed automatically when it ends. Ids that are not in the workspace still
  fail with `conversations_not_found`.
- **A batch job skips, at creation, the conversations its insight has already
  analysed** (an up-to-date result from that insight; nothing is skipped with
  `rerunAnalyzed: true`). They become items with `state: "skipped"` and
  `reasonCode: "already_analyzed"` straight away, so a job's progress counts
  only what it will actually run, the same number the attach estimate's
  `willAnalyze` gives. `POST /api/dialog-insight-batches` (`202`) and the attach
  response's `dialogInsightBatch` add `alreadyAnalyzed`; `itemCount` is still
  every conversation the job took. When every one was already analysed the job
  is created with `status: "completed"`.
- **`GET /api/dialog-insight-batches/{id}` and the list's items add
  `skippedByReason`**, skipped items by reason code, e.g.
  `{ "already_analyzed": 3, "no_transcript": 1, "not_ended": 0 }`. Those three
  keys are always present; any other reason appears when an item has it.
- **`GET /api/dialog-insight-batches` takes `agentId` (UUID or `ag-…`) and
  `status=open` (queued or running)**, separately or together, to find a job
  still running for an agent. An `agentId` that is not in the workspace is a
  `404` (`agent_not_found`). These filters are for API clients; the dashboard
  does not poll jobs.
- **When a batch job ends, the person who started it gets an in-app
  notification** (no email): kind `dialog_insight.batch_completed` in
  `GET /api/notifications` and on the workspace event stream
  (`notification.new`). It is sent once per job, when the job finishes
  (`status: "completed"`) or stops because the workspace reached its LLM budget
  (`"stopped_over_budget"`). It is not sent for a cancelled job, for a job
  created already `completed` (everything had been analysed), or for a job
  started with an API key that has no user behind it. The payload is snake_case:
  `batch_id`, `batch_short_id`, `status`, `dialog_insight_id`,
  `dialog_insight_name`, `agent_id` / `agent_short_id` / `agent_name` and
  `campaign_id` / `campaign_short_id` / `campaign_name` (each `null` unless the
  job was for that agent or campaign), `selector`, `origin`, `analysed`,
  `skipped_other`, `already_analyzed`, `failed`, `item_count`, and `url`, a
  dashboard path: the LLM budget settings for a stopped job, otherwise the
  conversations list (filtered to the agent for an agent job).
- **A result from an insight that is not the conversation's own is stored in
  the conversation's Dialog Insight result only.** A batch job, or
  `POST /api/conversations/{id}/analyze` with a `dialogInsightId`, that runs a
  different insight from the one the conversation resolves to (campaign insight
  → campaign's own extraction fields → agent default) no longer writes
  `extractedData` on the call or the campaign record, so a campaign's records
  and CSV export keep only its own fields. Running the conversation's own
  insight, and correcting its result, update them as before.

## 2026-09-30 — WhatsApp Business App numbers, and handing a conversation back to the AI

A WhatsApp number can now stay live in the owner's WhatsApp Business app on
their phone while it is connected here (Meta's "coexistence"). If your
integration creates channels, sends messages or reads conversations, these are
the changes you will see.

- **New MCP tool: `return_conversation_to_ai`.** Hands a paused (`handed_off`)
  conversation back to the AI — what the inbox's **Return to AI now** button
  does. Nothing is sent to the customer; the AI answers their next message. It
  is `mutates_live_deployment`, so over `/mcp/tools` it answers
  `confirmation_required`, and over `/mcp` and `/v1` it waits for a human YES.
  It accepts only a conversation that is `handed_off`, and returns
  `previous_handoff_reason` / `previous_handoff_cause`.
- **`get_conversation` (MCP) tells you who wrote each message and why the AI
  paused.** Each message carries `sender` (`customer`, `ai`, `human_agent`,
  `business_app`, `system`) and `source` (`live`, `business_app_reply`,
  `imported`); a message the customer never received adds `delivery_status` and
  `sent_to_customer: false`. A `handed_off` conversation adds `handoff_reason`,
  `handoff_cause` (`business_app`, `team`, `ai`, `workflow`, `unknown`),
  `handed_off_at`, and `auto_return_at` when the channel returns conversations
  to the AI automatically.
- **`is_business_app` on channel and conversation tools.** `whatsapp.get_status`,
  `list_configured_channels`, `get_channel`, `get_conversation` and
  `get_conversations` mark a WhatsApp Business App number with
  `is_business_app: true`.
- **Return to AI automatically, per channel.** Set
  `config.autoReturnToAi = { enabled, minutes }` (`minutes` 10–60) on
  `POST` / `PATCH /api/channels`, or `auto_return_to_ai` on the
  `update_channel` tool: a handed-off conversation goes back to the AI after
  that many minutes without a reply from your team. A new WhatsApp Business App
  channel starts with it on at 15 minutes.
- **`PATCH /api/channels/{id}` merges `config` on a WhatsApp Business App
  channel:** the keys you send are set and every other key is kept.
- **New `409` codes on `POST /api/channels` and `PATCH /api/channels/{id}`,**
  with a `{ error, message }` body: `whatsapp_number_on_business_app` (the
  number is in use on the WhatsApp Business app — connect it from the WhatsApp
  channel page in the dashboard instead), and on a Business App channel
  `coexistence_reconnect_required` / `coexistence_number_change` (its
  credentials change only by reconnecting through **Connect my WhatsApp
  Business App**).
- **New refusal `number_disconnected`** (`422`) on
  `POST /api/wa-templates/{id}/send` and `POST /api/conversations/{id}/messages`:
  the WhatsApp Business App number was disconnected from the app. Nothing was
  sent to Meta; reconnect it from Channels and send again. If you branch on
  `code`, add this value.

## 2026-09-25 — Attaching a Dialog Insight no longer reaches into an agent's history

If you attach Dialog Insights through the API or through MCP, the first bullet
changes behaviour on a call you are already making.

- **An attachment now covers new conversations only.** Pointing
  `defaultDialogInsightId` at an insight — `PATCH /api/agents/{id}`, or the
  `dialog_insight.attach` tool — used to leave every conversation the agent
  already had in scope, and the automatic paths then analysed them as each went
  idle. One attach produced ~920 analyses of pre-existing conversations, and the
  labels they applied fired workflows. An attach that names no scope now covers
  only conversations that **start** after it. Agents that already have an insight
  attached are untouched: they keep today's behaviour until somebody re-attaches.
- **New optional field on `PATCH /api/agents/{id}`: `dialogInsightScope`.**
  `from_now_on` (the default when the field is absent) or `analyze_history`,
  which reaches back 48 hours — the same activity window the fallback sweep uses
  — and no further. It must ride with `defaultDialogInsightId`; on its own it is
  refused with `400 dialog_insight_scope_without_pointer`, and any other value is
  a `400` naming the two that are accepted. Detaching (`null`) clears the scope,
  so a later re-attach is a fresh "from now on". The stored timestamp is derived,
  never client-writable.
- **New endpoint: `GET /api/agents/{id}/dialog-insight/backfill-estimate`.**
  Read-only, nothing attached and nothing billed: six integers telling you what
  an `analyze_history` attach would do — `totalConversations`, `activeLast48h`,
  `backfillReachable`, `reachableStillOpen`, `reachableRecentlyClosed`,
  `reachableOlderThan48h`. **`backfillReachable - reachableOlderThan48h` is the
  number that matters**, because that scope floors at the same 48-hour window;
  `backfillReachable` alone is the age-blind upper bound and can overstate it
  badly (180 against 0 on a real agent). It is an upper bound in a second way
  too: a conversation with no transcript is skipped before any model call.
  Listed in the [Platform API reference](/openapi/platform/).
- **A conversation older than the attachment is skipped, not failed.** The
  recorded reason says so in plain language, and it is only the automatic paths
  that stop: `POST /api/conversations/{id}/analyze` and `dialog_insight.test`
  both analyse any conversation on demand. A campaign-level insight is never
  scoped this way — a campaign is a finite batch whose own calls are what its
  extraction columns are for.
## 2026-09-25 — `/mcp` now waits for a human YES before a risky action

- **`send_message` on `/mcp` no longer runs risky actions on the first call.**
  These docs already said the delegate pauses for a human YES before anything
  that spends money. It did not: a job that needed a spend, mass-message,
  destructive or live-deployment action ran it immediately. Now it parks, as
  `/v1` always has. The result carries `pending_action: { action_code, summary,
  how_to_confirm }`. Nothing is done until you call `send_message` again on the
  same `conversation_id` with `YES <code>` (or `NO <code>`). That second call
  returns `executed: { ok, summary }`. See [MCP overview](/mcp/overview/).
- **What to change:** if a script drives `/mcp` unattended and relied on risky
  actions going through, it now gets a `pending_action` back and has to ask a
  person. `/mcp/tools` is unchanged: it still refuses those tools with
  `confirmation_required`.

## 2026-09-23 — Campaign errors you can branch on, and two status codes that moved

If you integrate against `/api/campaigns`, four of these change behaviour. The
first two are the ones worth reading before your next release.

- **A `type` or `status` we do not recognise is a `400`, not a `500`.** On
  `GET /api/campaigns` both went straight into a database enum comparison, so a
  spelling we do not have raised a cast error and surfaced as a server fault.
  The body now names the problem: `error: "invalid_filter_value"` plus
  `parameter`, `received` and `accepted[]`. The same fix is on
  `GET /api/campaigns/{id}/records`, whose `status` had the identical hole.
  `disposition` on that endpoint is still unvalidated on purpose: it is free
  text written by the call classifier, so an unrecognised value is an empty
  result rather than a mistake. An explicitly empty `?type=` still means "no
  filter", unchanged.
- **Cancel, pause and resume answer `409` when the campaign is in the wrong
  state.** All three used to answer `404`, indistinguishable from a campaign
  that does not exist, so a caller firing them from a webhook or a timer could
  not tell a wrong id from a race it lost. Now `404` means fix your id, and
  `409 campaign_not_cancellable` / `campaign_not_pausable` /
  `campaign_not_resumable` means stop asking. The body carries the `status` the
  campaign is actually in and the set the verb accepts. For cancel, a `409` is
  safe to treat as success: the campaign is over either way.
- **The refusals that block a launch carry a stable code.** `launch_fields_missing`,
  `name_required`, `agent_disabled`, `too_many_recipients` and
  `max_concurrent_calls_too_high` replace English sentences that used to sit in
  `error`. They are returned by `POST /api/campaigns`, `POST /api/seax/campaigns`
  and `POST /api/campaigns/{id}/start`. **Read `launch_fields_missing.missing[]`:**
  a voice campaign will not dial without `agentId`, `config.callMission` and
  `config.phoneMenuChosen`, which are optional when saving a draft, required to
  place a call, and documented nowhere else. On the SeaX endpoint those names
  arrive in SeaX's own spelling.
- **`GET /api/campaigns/{id}` no longer returns `recipients` for a launched
  campaign.** That field is the composer's working state, not a recipient list.
  For a head-count use `stats.total`, which is on the list response too; for
  per-person outcomes use `GET /api/campaigns/{id}/records`. A campaign still in
  `draft` or `paused` returns it in full, and `?include=recipients` asks for it
  on a launched one.
- **`providerCallId` on `/results` is always `null` for a voice campaign.** This
  is not new, but the field's description implied otherwise. Key your records on
  `callId`, which is stable and unique per attempt.

## 2026-09-22 — Send an approved WhatsApp template from your own system

- **New endpoint: `POST /api/wa-templates/{id}/send`.** Sends one approved
  template to one E.164 number with a workspace API key, for "when X happens on
  our side, send template Y to this customer". `{id}` is the template's `wt-…`
  short id or its UUID. The body is `to` plus Meta's own `components` array,
  verbatim, so a script that already speaks the Cloud API can pass its payload
  through. Listed in the [Platform API reference](/openapi/platform/).
- **Meta's 24-hour window does not apply to templates.** The message lands in
  the number's existing conversation (the thread the customer's reply joins),
  reopening it if it was closed, or starts one.
- **`components` are checked against the template before anything is written.**
  A missing, extra or unknown variable, a header of the wrong kind, or a button
  parameter on the wrong slot is refused with `422 template_components_mismatch`
  and an `error` that names the mismatch. Supported buttons are `url`,
  `copy_code` (Meta's `coupon_code` shape, up to 15 characters) and
  `quick_reply` (with a `payload`), each at its `index` in the template.
- **Refusals carry a stable `code`:** `402 billing_not_eligible`,
  `404 template_not_found`, `409 blocked_dnc`, `422 template_not_approved` /
  `channel_unavailable` / `channel_pending_verification` / `workspace_suspended`
  / `route_blocked`, `502 send_failed`. When a message row was written, the body
  also carries `messageId`, `conversationId` and `conversationShortId`.
- **Not idempotent.** Every successful call sends and bills one template. After a
  timeout, read the thread before retrying.
- **`POST /api/conversations/{id}/messages` accepts `template`**
  (`{ templateId, components }`) instead of `content` / `mediaUrl` on a WhatsApp
  thread, with the same checks. It cannot be combined with text or an attachment.
- **`send_whatsapp_template` (MCP) goes through the same send path,** so its
  sends now pass the same billing check and land in the same thread as the
  endpoint's. A missing variable is refused before the send instead of reaching
  Meta as an empty string.

## 2026-09-12 — Send an Instagram DM from Octo, inside the 24-hour window

- **Two new MCP tools for manual Instagram replies.** `can_send_instagram_message`
  (`safe`) answers whether a specific DM can go to a specific person right now:
  it checks that the thread exists, that they have not opted out, that Instagram's
  24-hour messaging window is still open, and that the text fits, then returns how
  long the window has left, who will answer a reply, and a signed `send_token`.
  It cannot send. `send_instagram_message` (`mass_message`, so it pauses for a
  human YES on `/mcp` and `/v1`) sends, and requires that token.
- **The token is the point.** It binds the workspace, the user, the conversation
  and a hash of the message text, so the DM that goes out is the one the operator
  was shown. Change a word and the send refuses rather than delivering something
  nobody approved.
- **Refusals say what is actually wrong**, with a stable code you can branch on:
  `window_closed` (with the time of their last message), `opted_out`,
  `no_instagram_thread` (Instagram does not let a business start a conversation),
  `ambiguous_thread`, `text_too_long`, `manual_send_cap_reached`. Meta's own error
  text is never returned; the numeric codes are.
- **`comment_automation.list_leads` now returns `conversation_id`** per lead, so a
  reachable lead can be handed straight to the two tools above.
- Instagram's 24-hour window opens on the customer's last message and nothing you
  send reopens it. These tools will not pretend otherwise.

## 2026-09-08 — Trial phone numbers, and a catalog that describes itself

- **Three new MCP tools for the shared trial number.** Omni Octo lends a pooled
  phone number so a workspace can place outbound calls **without buying one**.
  `trial.get_number` (`safe`) reports whether the number is available to this
  workspace, which agent is dialling from it, and the only countries it can
  reach. `trial.bind_agent` and `trial.release_number`
  (`mutates_live_deployment`) point an agent at it and stop it again.
- **Check `trial.get_number` before concluding a workspace has no phone
  number.** `list_phone_numbers` returns only numbers the workspace *owns*, so
  it comes back empty for a trial workspace that nonetheless has a free number
  waiting. An empty list is not the same answer as "cannot call".
- **`start_voice_call` accepts a `pfn-…` id.** That is the trial number's
  identifier, returned by `trial.get_number`. It only dials once an agent is
  bound to it, and only to that number's allowed countries — a `+1` destination
  outside them (Jamaica, the Dominican Republic) is still refused.
- **The number is shared and outbound-only.** The person called sees Omni Octo's
  caller ID rather than the workspace's, and nobody can call it back and reach
  the agent. Using it requires a **card on file**; the card is stored, not
  charged. It does **not** raise the trial call allowance — see
  `trial.check_call_quota` — which only a subscription lifts.
- **One agent per workspace, pool-wide.** Binding a second agent revokes the
  first, so the previous agent stops dialling from the number.
- **The bind and release tools are `mutates_live_deployment`,** so they are
  refused over `/mcp/tools` like every other gated class, and are available
  through `/mcp` or `/v1` where Octo can park them for confirmation. They also
  require an `owner` or `admin` key: a deployed agent must not repoint a
  workspace's outbound caller ID.
- **Fixed: the tool catalog's "What it does" column was empty for all 225
  tools.** The generator read the tool module's own `description` rather than
  the `description` on its exported `definition`, so every row rendered `—`.
  Every tool on [the catalog page](/mcp/tools/) now carries its first sentence.

## 2026-08-01 — Reassigning who answers a channel

- **MCP: `deploy_agent` accepts `take_over_inbound`.** A channel has at most
  **one** agent that answers inbound traffic, while many can share it to send
  or dial out. Previously `deploy_agent` could only claim that inbound seat
  when it was empty: deploying to a channel another agent already answers
  returned outbound-only, and there was no parameter that changed the outcome.
  Pass `take_over_inbound: true` to move the seat to the agent you are
  deploying. The displaced agent stays deployed and keeps its outbound
  capability — only who answers changes — and the response names it in
  `replacedAgentId`.
- **The default is unchanged and stays `false`.** Deploying to an occupied
  channel still grants outbound only, so a routine deploy can never silently
  redirect live inbound traffic away from a working agent. Requires
  `channel_id`; the legacy `channel` type parameter does not identify a
  channel row to own and is rejected.
- **`config` does not control routing.** It is opaque per-deployment JSON.
  `config: { inbound: true }` and similar were accepted and ignored; use
  `take_over_inbound` instead.

## 2026-07-28 — Outbound compliance + AI disclosure

- **Launching an outbound campaign can now return `409`.** Before a workspace
  launches its **first** outbound campaign of any type (voice, SMS, or
  WhatsApp), an owner or admin must accept the Outbound Calling Compliance
  acknowledgment. `POST /api/campaigns/` (non-draft) and
  `POST /api/campaigns/{id}/start` return
  `{ "error": "outbound_compliance_required", "version": "…" }` until they do.
  Handle it by surfacing the message rather than retrying — acceptance is a
  human action at **Settings → Compliance → Outbound compliance**, and there is
  deliberately no API or tool that performs it. **Drafts are never gated**, so
  `draft: true` keeps working; only the launch is blocked.
- **Voice agents can speak an AI + call-recording disclosure.** A per-agent
  notice delivered before anything else on the call, either synthesized from
  editable text or played from an uploaded recording (which is the only way to
  guarantee verbatim wording on realtime voice pipelines). Agents created from
  now on default to **on**; agents that existed before this shipped default to
  **off** and opt in, so no deployed agent changes what it says to callers
  without its operator doing it.
- **MCP: `get_agent` now returns a `disclosure` block** — `enabled`, `applies`,
  `spoken_text`, `mode`, `using_default_text`, and a plain-language `note`. Read
  it instead of the raw columns: a null `disclosure_text` means "use the
  platform default", not "say nothing", and `applies: false` distinguishes a
  text-only agent from a voice agent with the safeguard switched off.
- **MCP: `update_agent` accepts `disclosureEnabled`, `disclosureText`, and
  `disclosureRecordingId`.** Turning the disclosure off is supported — it is
  opt-out by design — but you remain responsible for AI-disclosure and
  recording-consent law wherever you call. A recording id must belong to your
  workspace and be of kind `disclosure`; voicemail-drop clips are a separate
  kind and are rejected.

## 2026-07-15 — Bulk operations

- **Delete or modify many resources in one call.** New workspace tools —
  `delete_conversations`, `delete_channels`, `delete_contacts`,
  `delete_campaigns`, `undeploy_agents`, and `cancel_campaigns` — act on many
  matching rows at once (callable over the [MCP servers](/mcp/overview/) and the
  [OpenAI-compatible API](/openai-api/overview/)). Each takes either a `filter`
  or an explicit `ids` list. This replaces the old behavior where "clean up all
  matching X" fanned out into one risky call — and one confirmation — per item.
- **One confirmation for the whole batch.** A bulk delete parks a single
  confirmation stating the count and a sample, not N sequential asks. See
  [Confirmations](/openai-api/confirmations/#bulk-operations-confirm-once).
- **Guardrails.** A full-workspace `{ all: true }` filter requires a matching
  `confirm_count`; a confirmation is drift-aborted if the matching set grows
  >10% before you reply; `delete_campaigns` skips running campaigns unless you
  opt in; `delete_contacts` is a recoverable soft delete.

## 2026-07-15 — Events & webhooks

- **Subscribe to workspace events.** Agents can now listen for state changes
  they didn't initiate. Register signed, retried outbound webhooks with
  `/v1/webhooks`, or hold open a [`GET /v1/events`](/openai-api/events/) SSE
  stream with a resume cursor. Both carry the same snake_case event envelope.
- **Event catalog.** `conversation.created`, `turn.completed`,
  `confirmation.requested`, `confirmation.resolved`, `spend.recorded`,
  `run.completed`. See [Events & webhooks](/openai-api/events/).
- **Signed & replayable.** Deliveries use the Standard Webhooks (svix)
  signature scheme; the SSE feed replays a durable, cursor-paginated log via
  `?after=` / `Last-Event-ID` so a reconnecting client never misses an event.

## 2026-07-15 — Phone number provisioning

- **Search & buy phone numbers.** Octo can now search available phone numbers
  and purchase them directly — the tools are in the workspace catalog (so
  they're callable over the [MCP servers](/mcp/overview/) and the
  [OpenAI-compatible API](/openai-api/overview/)) and the routes are in the
  [Platform API reference](/openapi/platform/) under `/api/phone-numbers`.
- **Spend-gated purchase.** Buying a number is a spend action, so it parks for
  explicit confirmation like every other risky action — the purchase only fires
  after you reply `YES <code>`. See [Confirmations](/openai-api/confirmations/).
- **Credits, not dollars.** Availability results and prices are quoted in
  workspace **credits**; the underlying USD cost is never exposed on any
  customer surface.
- **Regulatory routing.** Only US and CA are instant-purchase. For other
  countries Octo links you to the in-app regulatory-approval flow rather than an
  external carrier dashboard.

## 2026-07-14 — Agent-friendliness pass

- **Rate-limit headers on every response.** The OpenAI-compatible `/v1` API now
  returns `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`
  on success as well as on `429`, so an agent can pace itself proactively
  instead of only reacting to `Retry-After`. See [Rate limits](/openai-api/rate-limits/).
- **Self-correcting error envelope.** Errors keep the OpenAI shape
  (`message`, `type`, `param`, `code`) and now add `hint` and `docs_url` on the
  auth, rate-limit, and server-error paths so a caller can recover without a
  human. See [Errors](/openai-api/errors/).
- **Skills catalog.** A machine-readable task catalog is published at
  `/.well-known/skills/index.json` — each entry links to markdown instructions
  for a common job (authenticate, create a key, handle confirmations, respect
  rate limits, connect MCP, …). See [Agent resources](/agents/resources/).

## 2026-07 — Developer portal

- **Docs site.** Launched [docs.omniocto.com](https://docs.omniocto.com) covering
  the three Octo surfaces: the OpenAI-compatible `/v1` API, the MCP servers, and
  the platform REST API, with interactive API references.
- **Agent-readable docs.** Added `/llms.txt`, `/llms-full.txt`, a raw-markdown
  view for every page (append `.md` to any URL), and OpenAPI JSON for both APIs.
- **Docs-search MCP.** A live MCP server (`/mcp-docs`) with `search_docs` and
  `get_doc` so an agent can query these docs in context.
- **OpenAI-compatible API.** `/v1/responses`, `/v1/chat/completions`, and
  `/v1/models`, callable by pointing any OpenAI client at the Octo `base_url` and
  an `sk-octo_` key. SSE streaming, background mode, `Idempotency-Key` replay, and
  `previous_response_id` continuation.
- **API keys in the dashboard.** Create and revoke `sk-octo_` keys from
  Settings → API Keys. See [Get an API key](/getting-started/api-keys/).