> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vocily.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# API changes

> What changed in the agent API, and what to send instead.

## 2026-09-25

A field-by-field review of all 53 operations. Most of it was wording that did not match what the
API does, and those corrections need nothing from you. The removals below do.

### Fields removed

| Gone from                 | What                                                               | Instead                                                                                                                                                                                       |
| ------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v1/calls` response | `call_sid`                                                         | Nothing takes it back, and `GET /v1/calls/{id}` never returned it, so it was readable once and usable nowhere                                                                                 |
| `POST /v1/calls` response | `message`                                                          | A fixed sentence. `202` and `status` already say it. A retry that returned your original call now sets the **`Idempotent-Replay: true`** response header, so the body is identical either way |
| memory subjects           | `subject_msisdn`                                                   | `msisdn_full`, which is now real E.164 with the `+`. It was described as masked and never was, so nothing is lost but the wrong impression                                                    |
| memory changes            | `model_id`, `context_tokens`, `prompt_tokens`, `completion_tokens` | A change reports what was put in front of the agent and what came back. Memory is not billed per token                                                                                        |
| `memory.fields[]`         | `sort_order`                                                       | Fields order by `key`                                                                                                                                                                         |
| analysis groups and items | `sort_order`                                                       | They order by when you created them                                                                                                                                                           |
| widget read and configure | `public_key`                                                       | `public_key_prefix` tells you which key is live. The key itself is shown only by [rotate](/developers/widgets/rotate-key), which is the only moment a secret can be shown                     |
| `GET /v1/transcribers`    | `keyword_param`                                                    | It named the vendor's own parameter. Send `transcriber.keywords` and the mapping is ours                                                                                                      |
| variable suggestions      | `label`                                                            | It was the same text as `value` on every row                                                                                                                                                  |

<Note>
  On a **built-in** analysis value, `sort_order` was one of three fields you could change. The
  other two, `description` and `enabled`, are unaffected.
</Note>

### `POST /v1/calls` takes a version number

It took the version **uuid** and nothing else, while every other version route takes the number —
so after publishing v4 you had to look its uuid up before you could place a call on it.

```json theme={"dark"}
{ "agent_id": "…", "to_number": "+919876543210", "version": 4 }
```

`agent_version_id` still works. Sending both is a `400 invalid_request`: they name the same
thing, so honouring one and ignoring the other would be a guess. Omit both and the agent's live
version answers, as before.

### The widget is one resource, not a collection

An agent has exactly one widget, so the id in the path could only ever be one value — one you had
to fetch before you could use any of the other routes.

| Was                                                   | Is now                                   |
| ----------------------------------------------------- | ---------------------------------------- |
| `GET /v1/agents/{id}/widgets` (an array of one)       | `GET /v1/agents/{id}/widget`             |
| `GET /v1/agents/{id}/widgets/{widget_id}`             | — same call as above                     |
| `PATCH /v1/agents/{id}/widgets/{widget_id}`           | `PATCH /v1/agents/{id}/widget`           |
| `POST /v1/agents/{id}/widgets/{widget_id}/rotate-key` | `POST /v1/agents/{id}/widget/rotate-key` |

The read returns the object itself rather than a one-item array, and prepares the widget if it
does not exist yet — so it never 404s. `id` is still on the body, which is what you want in a log
or when telling one rotation from another.

<Note>
  `widget_not_found` is no longer among the [reason codes](/developers/api-reference#reason-codes)
  on this surface: with no id to get wrong, there is nothing to miss.
</Note>

### Every growing list pages the same way

Four shapes reached you before this: `?skip=` on agents, `?offset=` on remembered callers,
cursors on calls and chats, and nothing at all on knowledge bases, versions, deploy history and
the analysis library — which returned every row your workspace held.

They are all cursors now, and all return the same envelope:

```json theme={"dark"}
{ "data": [ … ], "has_more": true, "next_cursor": "eyJ…" }
```

| List                                  | Was                                 |
| ------------------------------------- | ----------------------------------- |
| `GET /v1/agents`                      | `?skip=` + a bare array             |
| `GET /v1/agents/{id}/versions`        | a bare array, unpaged               |
| `GET /v1/agents/{id}/activations`     | a bare array, silently capped at 50 |
| `GET /v1/agents/{id}/memory/subjects` | `?offset=` + a bare array           |
| `GET /v1/knowledge-bases`             | a bare array, unpaged               |
| `GET /v1/custom-analysis`             | `{"groups": […]}`, unpaged          |

<Note>
  **`?skip=` and `?offset=` are gone.** Both drift: a row written while you page can appear on two
  pages or on none. A cursor names the boundary instead, so new rows land on page 1 and the point
  you are reading from does not move. An offset also gets slower the deeper you page; a cursor
  does not.
</Note>

<Warning>
  The four lists that were unpaged now return **20 rows by default**, not everything. If you were
  reading one of them expecting the full set, page until `has_more` is `false`.
</Warning>

The lists that do NOT page are the bounded ones: numbers, WhatsApp numbers, the memory changes for
a single call, and the five catalogues. Those stay bare arrays so a picker is one request.

`GET /v1/custom-analysis` also settles its envelope key. It was `{"categories": […]}`, briefly
`{"groups": […]}`, and is now `data` like every other list — the **path** rename to
`/v1/custom-analysis/groups` stands.

### Call provenance is two fields, not three

`call_type` is **removed**. It was `direction` and `origin` fused into one compound value —
`outbound_api` is `direction: outbound` plus `origin: api` — so it could not be read without
knowing how it was assembled, and it published a second vocabulary for two questions those fields
already answer.

`direction` now publishes `inbound`, `outbound`, or **`web`** for a call that happened in a browser.
It used to return the raw stored value, so a widget call came back as `widget_talk` and a dashboard
test as `talk_test` — internal words, and two values these docs never promised. Use `origin` to
tell a real widget call from your own test click.

A web call's `from_number` and `to_number` are now **`null`**, which is what this page has always
said they were. They were returning the sentinels `"widget"` and `"talk"`.

The list filter follows: **`?call_type=phone|web` is now `?direction=inbound|outbound|web`**, taking
exactly the values the field publishes. An unknown one answers `400 invalid_direction`.

<Note>
  The same change applies to the `call` object in every `call.*` webhook, which is the same shape.
</Note>

### Response speed takes four values, not any number

`conversation.response_eagerness` accepted any number from 0 to 1 and **snapped it to the nearest
of four presets**, so `0.6` saved, ran as Balanced, and read back as `0.6` forever. It now accepts
only the four, and anything else is refused rather than quietly rounded:

| Send   | Preset                 |
| ------ | ---------------------- |
| `1`    | Fast                   |
| `0.75` | Responsive             |
| `0.5`  | Balanced (the default) |
| `0`    | Patient                |

The type has not changed - it is still a number, and an agent already set to one of these four
needs nothing. `response_speed_seconds` in
[`GET /v1/transcribers`](/developers/catalogues/transcribers) is keyed by the preset names, and
that page gives the pairing.

### `response_speed_seconds` is `null` where seconds mean nothing

[`GET /v1/transcribers`](/developers/catalogues/transcribers) gave every model a table of
seconds per preset. On the two models that run their own end-of-turn detection those numbers
were **their provider's figures, measured on a different model** — byte-identical to the other
models on that provider, for a behaviour they do not share. Those models answer when they are
confident the caller has finished, not after a fixed wait, so no number describes them.

Those rows now return `null`:

```json theme={"dark"}
{ "model": "flux-general-multi", "response_speed_seconds": null }
```

Every other model is unchanged. `conversation.response_eagerness` still applies on **all** of
them and still takes the same four values — only the prediction is withheld where there is
nothing to predict.

<Note>
  If you render the seconds in a picker, check for `null` first. Reading `.patient` off it now
  throws on two of the six models — where it previously returned a number that was wrong.
</Note>

### ElevenLabs voices now appear in `GET /v1/voices`

`GET /v1/tts-capabilities` listed `elevenlabs` with its languages while
[`GET /v1/voices`](/developers/catalogues/voices) returned **no ElevenLabs rows at all**, so the
provider looked available and there was nothing to put in `voice.voice_id`.

Its 19 built-in voices are now published, the same ones the dashboard offers. Nothing else
changed: no other provider gained or lost a voice, and `?provider=elevenlabs` filters them like
any other.

### `GET /v1/tts-capabilities` names each provider's model

Each provider is now `{model, languages}` rather than a bare language array:

```json theme={"dark"}
{ "providers": { "sarvam": { "model": "bulbul:v3", "languages": ["hi-IN", "…"] } } }
```

Every voice provider ships exactly one model, `voice.model` on an agent is filled in from
`voice.provider`, and until now no endpoint said what that value meant or what else was legal.

### Numbers read back in E.164

A transfer number and an agent's `numbers.inbound_whatsapp` were returned in whatever spelling they
were stored in — `+91 97935 79700` from a dashboard edit, Meta's display format for WhatsApp. Both
are normalised on read, as this page has always promised.

### Renamed

| Was                                              | Is now                                   | Where                                                                                                                                                                                                     |
| ------------------------------------------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `execution_id`                                   | `id`                                     | the `POST /v1/calls` response. Every read already called it `id`, and `execution` is our own word for the row                                                                                             |
| `/v1/agents/{id}/memory/calls/{execution_id}`    | `/v1/agents/{id}/memory/calls/{call_id}` | it is a call id, and every other route calls one that                                                                                                                                                     |
| `POST/PUT/DELETE /v1/custom-analysis/categories` | `…/custom-analysis/groups`               | the concept is a **group** in `analysis_group_ids`, in the docs and on these pages; only the URL still said "category"                                                                                    |
| `{"categories": […]}`                            | `{"data": […]}`                          | the `GET /v1/custom-analysis` envelope — see the pagination change above                                                                                                                                  |
| `{category_id}`                                  | `{group_id}`                             | the same path parameter                                                                                                                                                                                   |
| `activated_at`                                   | `at`                                     | a deploy-history row. Nothing was *activated* when an agent went **off** air                                                                                                                              |
| `status`                                         | `carrier_status`                         | `GET /v1/numbers`. It sounds like the primary field and is the narrower question — whether the carrier still lists the number. `lifecycle_status`, where it is in its rental life with us, keeps its name |

<Note>
  The webhook payload already called the call id `id`, and carried a parenthetical explaining that
  it "matches `execution_id` from `POST /v1/calls`". That note is gone — there is one name now.
</Note>

### Added

* A call now carries **`agent_version`** — which version answered it — and **`dashboard_url`**, a
  deep link you can paste into a bug report. Chats already had both.
* `GET /v1/models` now carries **`supports_temperature`**. `model.temperature` is accepted on
  every model, but only some honour it; read this before setting one. `rate_per_min` is also
  described correctly now: it is what the model contributes to a voice minute, not the price of a
  call.
* New: [`GET /v1/whatsapp/templates`](/developers/integrations/whatsapp-templates) — the approved
  names for `send_whatsapp_template.allowed_templates`. A typo there used to be stored without
  complaint and the agent then could not send.

### Corrected, with nothing to change on your side

* `Idempotency-Key` was published as optional. The server has always refused a request without
  it, so a generated SDK failed on its first call.
* The knowledge-base read described `kb_type: semantic`, which the API never returns. It is
  `non_deterministic`.
* Call `status` was described as four values; there are twelve, and they are now listed. Chat
  `status` is three, and `completed` means "no longer active" — including a visitor closing the
  tab.
* `memory.fields[].sort_order` aside, every enum we return is now written down: KB document
  status, widget status and position, fact `source`, memory-change `source_kind`, activation
  `action`, `ended_reason`, and both number statuses.
* A memory subject's `last_channel` claimed `phone, WhatsApp, the widget`. It is `voice` or
  `whatsapp`; widget conversations are never recorded in memory.
* A version compare reported changed platform lines under `system_messages.*`, a path this API
  stopped accepting in the previous release, and could name internal memory settings you cannot
  send. Both fixed.
* `model.temperature: 0` ran as `0.4`.
* `transcriber.provider` is derived from `transcriber.model`, as the model and both backups
  already were. A pair that disagreed used to save and then fail the call.
* `transcriber.provider`, `model.provider` and `voice.model` now say they are read-only, which
  they always were.

## 2026-09-23

One release, four changes to the agent body. Three remove things the API could not honour; the
fourth moves each platform-spoken line next to the setting that speaks it. All four are breaking —
there is no window in which both shapes work, so update before you take the release.

### Tools are built in the dashboard

`tools` and `pre_call_tools` are no longer part of the agent body. `POST /v1/agents` and
`PATCH /v1/agents/{agent_id}` do not accept them, and no read returns them.

Nothing changes for an agent that already has tools: they are stored, they still run, and the
dashboard still edits them. A prompt that arms a tool the agent does not have is refused with
`prompt_tool_reference_invalid`, so build the tool on the agent's **Tools** tab first, then write
a prompt that arms it.

`GET /v1/integrations/cal-com/event-types` went with them — its only purpose was filling a booking
tool's `cal_com.event_type_id`.

<Note>
  A version [compare](/developers/agents/compare) never names a tool either. A difference always
  names a field you can read and write here.
</Note>

### `whatsapp` and `knowledge_messages` are gone

Both were settings the API published but could not properly support.

* `whatsapp` (history window, debounce, opt-in/out keywords, media policy) is per-workspace
  WhatsApp setup, configured in the dashboard. The number an agent answers on,
  `numbers.inbound_whatsapp`, is unchanged.
* `knowledge_messages` did not match what the runtime does with it — a custom line is shortened to
  120 characters — so it is not published until that is settled.

### Platform lines moved next to their settings

`system_messages` — the flat map keyed by stage — **is removed**. Each line the platform speaks now
lives on the feature that decides when it is spoken:

| Was                                     | Is now                                            |
| --------------------------------------- | ------------------------------------------------- |
| `system_messages.goodbye`               | `builtin_tools.end_call.message`                  |
| `system_messages.wait_acknowledgement`  | `builtin_tools.hold_call.acknowledgement_message` |
| `system_messages.wait_follow_up`        | `builtin_tools.hold_call.follow_up_message`       |
| `system_messages.wait_timeout`          | `builtin_tools.hold_call.timeout_message`         |
| `system_messages.transfer_intro`        | `builtin_tools.transfer_call.intro_message`       |
| `system_messages.transfer_busy`         | `builtin_tools.transfer_call.busy_message`        |
| `system_messages.silence_reminder`      | `conversation.silence_reminder.message`           |
| `system_messages.silence_timeout`       | `call.end_on_silence_message`                     |
| `system_messages.call_limit`            | `call.max_duration_message`                       |
| `system_messages.business_hours_closed` | `inbound.business_hours_closed_message`           |
| `system_messages.inbound_fallback`      | `inbound.fallback_message`                        |

A hold check-in now sits beside the `follow_up_seconds` that decides when it is spoken, and the
silence nudge beside the `seconds` that trigger it.

<Note>
  The value is unchanged — the same `{mode, prompt, text}` object, with the same rules. Only where
  it sits in the body has changed, so a client that reads a body, edits one line and sends the whole
  thing back needs no rewrite beyond the field name. Sending `system_messages` now answers `422`
  naming it as an unknown field.
</Note>

### Other refusals added in the same release

Values that used to save with a `200` and then do nothing are now refused, with a message naming the
fix. The full list is in each field's reference entry; the ones most likely to affect a working
integration:

* a backup (`transcriber.fallback`, `model.fallback`, `voice.fallback`) that could never be used,
  including one on the main engine's own provider, which used to save and read back as `null` —
  see [Backups](/developers/agents/backups)
* a `builtin_tools.transfer_call.numbers[]` entry whose `timezone` is not an IANA zone, whose hours
  are an empty window, or whose `number` is not E.164
* `builtin_tools.hold_call.follow_up_seconds` at or above `max_seconds`
* `knowledge_base_ids` or `analysis_group_ids` naming something this workspace does not hold
* `voice.emotion` outside the published list, or on a provider that cannot speak it
* `variables[].system_defined` / `.deletable` set to anything other than what the read returned
* a `memory.fields[]` key declared twice, which used to keep the last one silently
* `memory.fields[].enum_values` on a field that is not a `string`, where the extractor ignores it

Validation errors also changed shape: a `422` now uses the same envelope as every other error. See
[Validation errors](/developers/api-reference#validation-errors).

### Every body refuses a field it does not know

`POST`/`PATCH /v1/agents` has always answered `422` for a field it does not declare. Knowledge
bases, custom analysis, widgets and memory accepted one and ignored it, so `{"nam": "Support"}`
created a knowledge base called "Untitled" and said nothing. They now answer `422` naming the field,
like the agent routes.

### A widget is configured, not created or deleted

Every agent has exactly one widget, prepared the first time you read it. `POST /v1/agents/{id}/widgets`
could therefore only ever answer `409 Widget already exists`, and `DELETE` did not remove anything -
the next read made a fresh widget with a **new public key**, silently breaking the snippet already
embedded on your site. Both are removed.

What is left is the whole lifecycle: [read it](/developers/widgets/get),
[configure it](/developers/widgets/update), and
[rotate its key](/developers/widgets/rotate-key) when you mean to.

### `invalid_request` and `document_invalid` mean different things

`invalid_request` was doing two jobs: a malformed request (a `?version=` we cannot parse, an
`If-Match` that is not a revision) and a well-formed value the agent cannot use. They need different
fixes, so they now have different codes - the second is `document_invalid`, which already meant
exactly that for `numbers`. Messages are unchanged.

### Every refusal carries a reason code

`detail.code` used to be missing on most `404`s and `409`s - the reason was prose only, and three
codes this page lists (`version_not_found`, `agent_not_published`, `not_public_api`) never actually
appeared. Every public refusal now carries one, and the
[reason codes](/developers/api-reference#reason-codes) table lists them. Messages are unchanged, so
anything reading `detail` as text still reads the same sentence.
